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.

RouteWhat it doesKey scope
POST /v1/webhook_endpointsRegister a webhook endpointadmin
GET /v1/webhook_endpointsList webhook endpointsaccount:read
GET /v1/webhook_endpoints/{id}Get a webhook endpointaccount:read
PATCH /v1/webhook_endpoints/{id}Update a webhook endpointadmin
DELETE /v1/webhook_endpoints/{id}Delete a webhook endpointadmin
POST /v1/webhook_endpoints/{id}/rotate_secretRotate a webhook endpoint's signing secretadmin
POST /v1/webhook_endpoints/{id}/testSend a webhook.test event to an endpointadmin
GET /v1/webhook_endpoints/{id}/deliveriesList an endpoint's delivery attemptsaccount: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.

FieldTypeDescription
objectstringAlways webhook_endpoint.
One of webhook_endpoint
idstringwe_…
urlstringhttps URL deliveries are POSTed to.
eventsstring[]Event types it receives: types, prefix wildcards (job.*) or * for all.
descriptionstring or null
statusstringOne of enabled, disabled
disabled_reasonstring or nullWhy 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 nullThe signing secret (whsec_…): only in the create and rotate_secret answers. Store it.
previous_secret_expires_atstring (date-time) or nullUntil then the previous secret signs too; null when none.
created_atstring (date-time)
updated_atstring (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:

HeaderTypeDescription
webhook-idstringThe event ID (evt_…), the same on every retry: deduplicate deliveries on it.
webhook-timestampintWhen this attempt was signed, in Unix seconds: reject deliveries outside your tolerance (replays).
webhook-signaturestringSpace-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.

TypeScript
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
}
Python
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
Go
// 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:

AttemptAfter the previous oneSince the event
1immediately0
25 secondsabout 5 seconds
35 minutesabout 305 seconds
430 minutesabout 2105 seconds
52 hoursabout 155 minutes
65 hoursabout 455 minutes
710 hoursabout 1055 minutes
814 hoursabout 32 hours
920 hoursabout 52 hours
101 dayabout 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:

FieldTypeDescription
objectstringAlways webhook_delivery.
One of webhook_delivery
idstringwhd_…
event_idstring
event_typestring
webhook_endpoint_idstring
statusstringpending: an attempt is due (next_attempt_at). failed: every attempt failed, or the endpoint was disabled or deleted.
One of pending, succeeded, failed
attemptsintegerAttempts made so far.
status_codeinteger or nullThe last attempt's HTTP status; null when it got none (timeout, refused, blocked URL).
duration_msinteger or nullThe last attempt's duration.
response_excerptstring or nullThe start of the last response body.
errorstring or nullWhy the last attempt failed when there was no response.
created_atstring (date-time)
delivered_atstring (date-time) or null
next_attempt_atstring (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.