Docs
Webhooks
Receive signed notifications when jobs end or credits run low: Standard Webhooks signatures, retries for days, delivery logs and secret rotation.
Instead of polling, register an HTTPS endpoint and SocialTap sends it an event when something happens: a job ends, your credits run low. Deliveries follow Standard Webhooks exactly, so its verification libraries work too.
| Route | What it does | Key scope |
|---|---|---|
POST /v1/webhook_endpoints | Register a webhook endpoint | admin |
GET /v1/webhook_endpoints | List webhook endpoints | account:read |
GET /v1/webhook_endpoints/{id} | Get a webhook endpoint | account:read |
PATCH /v1/webhook_endpoints/{id} | Update a webhook endpoint | admin |
DELETE /v1/webhook_endpoints/{id} | Delete a webhook endpoint | admin |
POST /v1/webhook_endpoints/{id}/rotate_secret | Rotate a webhook endpoint's signing secret | admin |
POST /v1/webhook_endpoints/{id}/test | Send a webhook.test event to an endpoint | admin |
GET /v1/webhook_endpoints/{id}/deliveries | List an endpoint's delivery attempts | account:read |
Register an endpoint
POST /v1/webhook_endpoints takes the url and the events it receives: event types, a family such as
job.*, or * for all. The answer is the only time you see its signing secret (whsec_…):
store it with your other secrets. An account has at most 16 endpoints.
| Field | Type | Description |
|---|---|---|
object | string | Always webhook_endpoint. One of webhook_endpoint |
id | string | we_… |
url | string | https URL deliveries are POSTed to. |
events | string[] | Event types it receives: types, prefix wildcards (job.*) or * for all. |
description | string or null | |
status | string | One of enabled, disabled |
disabled_reason | string or null | Why it is disabled: gone (it answered 410), failing (deliveries failed for disable_after_failing_s), user. Null while enabled. One of gone, failing, user |
secret? | string or null | The signing secret (whsec_…): only in the create and rotate_secret answers. Store it. |
previous_secret_expires_at | string (date-time) or null | Until then the previous secret signs too; null when none. |
created_at | string (date-time) | |
updated_at | string (date-time) |
The URL must use https, on port 443 or a port above 1023, without credentials, and its host must resolve only to
public addresses: private, loopback, link-local and cloud metadata addresses are refused when you register it and
checked again before every delivery. Redirects are not followed: a 3xx answer is a failed delivery, so register the
final URL.
A job can also notify one of these endpoints that its subscriptions don't cover: set the job's webhook_url to the
endpoint's URL.
What a delivery looks like
Each delivery is a POST of the event as JSON, with these headers:
| Header | Type | Description |
|---|---|---|
webhook-id | string | The event ID (evt_…), the same on every retry: deduplicate deliveries on it. |
webhook-timestamp | int | When this attempt was signed, in Unix seconds: reject deliveries outside your tolerance (replays). |
webhook-signature | string | Space-separated v1,SIGNATURE entries, each the base64 HMAC-SHA256 of webhook-id, webhook-timestamp and the raw body joined by dots, with one active secret (two during a rotation): accept the delivery when one matches. |
The body is small, a summary and links, never a job's results: fetch those from the links when you need them.
Verify every delivery
Compute the HMAC-SHA256 of webhook-id.webhook-timestamp.<raw body> with the
secret's bytes (the base64 after whsec_), and accept the delivery only when one of the
v1,<signature> entries of webhook-signature matches, compared
in constant time, and the timestamp is within 5 minutes of your clock. Use the raw body as it
arrived: parsing and re-serializing JSON changes the bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
/** The verified event, or null: pass the RAW body, before any JSON parsing. */
export function verify(rawBody: string, headers: Record<string, string>, secret = process.env.SOCIALTAP_WEBHOOK_SECRET!): unknown {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const want = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
const ok = sigs.split(" ").some((s) => {
const [version, b64] = s.split(",", 2);
const got = Buffer.from(b64 ?? "", "base64");
return version === "v1" && got.length === want.length && timingSafeEqual(got, want);
});
return ok ? JSON.parse(rawBody) : null; // deduplicate on its id
}import base64, hashlib, hmac, json, os, time
def verify(raw_body: bytes, headers: dict, secret: str = os.environ["SOCIALTAP_WEBHOOK_SECRET"]):
"""The verified event, or None: pass the RAW body, before any JSON parsing."""
msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not msg_id or not ts or not sigs or abs(time.time() - int(ts)) > 300:
return None
key = base64.b64decode(secret.removeprefix("whsec_"))
want = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
for part in sigs.split(" "):
version, _, got = part.partition(",")
if version == "v1" and hmac.compare_digest(got, want):
return json.loads(raw_body) # deduplicate on its id
return None// Verify returns the raw event when one signature matches: pass the RAW body.
func Verify(body []byte, h http.Header, secret string) ([]byte, bool) {
id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
t, err := strconv.ParseInt(ts, 10, 64)
if id == "" || sigs == "" || err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return nil, false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return nil, false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(sigs, " ") {
if version, got, ok := strings.Cut(part, ","); ok && version == "v1" && hmac.Equal([]byte(got), []byte(want)) {
return body, true // deduplicate on the event's id
}
}
return nil, false
}The SDKs ship the same check (verifyWebhook in TypeScript, verify_webhook in Python, VerifyWebhook in Go), and
any Standard Webhooks library verifies these deliveries.
Answer with any 2xx quickly (within 15 seconds) and do the work afterwards. Deliveries can repeat
and arrive out of order: deduplicate on the event ID (webhook-id), the same on every retry.
Retries
A delivery that doesn't get a 2xx within 15 seconds is retried on this schedule, each delay
varied by a little so retries don't arrive in lockstep:
| Attempt | After the previous one | Since the event |
|---|---|---|
| 1 | immediately | 0 |
| 2 | 5 seconds | about 5 seconds |
| 3 | 5 minutes | about 305 seconds |
| 4 | 30 minutes | about 2105 seconds |
| 5 | 2 hours | about 155 minutes |
| 6 | 5 hours | about 455 minutes |
| 7 | 10 hours | about 1055 minutes |
| 8 | 14 hours | about 32 hours |
| 9 | 20 hours | about 52 hours |
| 10 | 1 day | about 76 hours |
On 429, 502 and 504, a longer retry-after from your server is respected. A 410 Gone answer disables the
endpoint at once; an endpoint whose deliveries keep failing for 5 days is
disabled too. A disabled endpoint's disabled_reason says why; set its status back to enabled with
PATCH /v1/webhook_endpoints/{id} once it works.
Delivery logs and redelivery
GET /v1/webhook_endpoints/{id}/deliveries lists every delivery to an endpoint with its last status code, duration, the
start of your server's answer and when the next attempt is due:
| Field | Type | Description |
|---|---|---|
object | string | Always webhook_delivery. One of webhook_delivery |
id | string | whd_… |
event_id | string | |
event_type | string | |
webhook_endpoint_id | string | |
status | string | pending: an attempt is due (next_attempt_at). failed: every attempt failed, or the endpoint was disabled or deleted. One of pending, succeeded, failed |
attempts | integer | Attempts made so far. |
status_code | integer or null | The last attempt's HTTP status; null when it got none (timeout, refused, blocked URL). |
duration_ms | integer or null | The last attempt's duration. |
response_excerpt | string or null | The start of the last response body. |
error | string or null | Why the last attempt failed when there was no response. |
created_at | string (date-time) | |
delivered_at | string (date-time) or null | |
next_attempt_at | string (date-time) or null |
POST /v1/events/{id}/redeliver sends an event again (with the same webhook-id), to one endpoint or to
every endpoint subscribed to it. POST /v1/webhook_endpoints/{id}/test sends a webhook.test event to one endpoint,
to check your handler end to end.
Rotate a secret
POST /v1/webhook_endpoints/{id}/rotate_secret issues a new secret, shown once. For previous_secret_expires_in seconds
(default and maximum 1 day) every delivery carries two signatures, one per
secret, so you can deploy the new secret without dropping a delivery. Verify against both during the switch.
Keys and test mode
Reading webhook endpoints and their deliveries needs the account:read scope; creating, changing, deleting,
rotating, testing and redelivering need admin. A test key manages a separate set of test endpoints: they receive
test events only (a test job's job.* events and test deliveries), signed and retried like live ones, and live
endpoints never receive them.