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. A key an agent collected from an approval reads only its host: https://host/. |
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) | |
created_by_user | string or null | The person (GET /v1/members user_id) who created this endpoint in the dashboard, or whose API key created it. They may have left the account since. Null when no person did, or it is from before this was recorded. |
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 same check ships in the TypeScript SDK, and any Standard Webhooks library verifies these deliveries.
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.
A target that has stopped answering at all gets a shorter timeout, 5 seconds, on most of its attempts until it answers again. One that answers slowly keeps 15 seconds.
Pacing
Deliveries are paced per account and per host so that no one's traffic holds up anyone else's: a delivery over the
pace waits, pending with a later next_attempt_at, and is never dropped.
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
Reading webhook endpoints and their deliveries needs the account:read scope; creating, changing, deleting,
rotating, testing and redelivering need admin.