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 5 days), 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
}Any Standard Webhooks library verifies these deliveries too.
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.