Webhooks
A webhook tells your own system when something happens, instead of polling. You add an https address in the console and pick events; each time one of them happens, SocialToAI posts a signed JSON message to it. Messages carry numbers, ids and links only, never text anyone wrote. Delivery is at least once and retried for about a day; webhooks cost nothing.
Add an endpoint
- In the console open Settings → Webhooks and add an endpoint: an https address of your own and the events it should get. Endpoints are managed in the console only; there is no route for them with a key.
- The address must be https and reach the public internet. Before it is saved we post one signed check message to it (
typeisendpoint.check), and it has to answer 2xx. - Then the signing secret is shown, once. It starts with
whsec_; copy it into your receiver's settings right away. The console only ever shows its last four characters again. - An account holds up to 10 endpoints. An endpoint belongs to the account, not to a key: revoking a key does not touch it.
Events
Every message is a JSON object with id (the event's id), type and created_at, then the fields of its event. summary holds the run's numbers by name.
| type | When it is sent | Fields |
|---|---|---|
tool_run.finished | A ready-made tool's run ended with ok or empty. | tool, handle, status, summary, cost_credits, quoted_credits, result_url |
tool_run.failed | A ready-made tool's run ended with partial or failed. | tool, handle, status, summary, cost_credits, quoted_credits, result_url |
batch.finished | A batch job ended, whatever its status. | tool, batch_id, summary, cost_credits, download_url |
schedule_run.finished | A run of a schedule finished. summary.new_items counts what the run before had not; new_links are the links of the first of them. | tool, schedule_id, handle, summary, cost_credits, new_links, report_url |
schedule.alert | A schedule's main number moved past its alert_percent, or became non-zero after zero. | tool, schedule_id, handle, summary, cost_credits, report_url |
balance.low | The balance fell below its low-balance line, when the low-balance email goes out. | balance_credits, threshold_credits, billing_url |
daily_cap.reached | The account's daily cap refused a call for the first time that UTC day. | daily_limit_credits, resets_at, settings_url |
{ "id": "evt_0123456789abcdef01234567", "type": "schedule_run.finished", "created_at": "2026-10-25T09:00:12.000Z", "tool": "search", "schedule_id": "00000000-0000-4000-8000-000000000021", "handle": "res_00000000000040008000000000000003", "summary": { "new_items": 12 }, "cost_credits": 2, "report_url": "https://socialtoai.com/console/tools/"}Example: ids and numbers are placeholders.
Check the signature
Each message comes with two headers. SocialToAI-Signature is t=<seconds>,v1=<hex>: t is the Unix time it was signed, and v1 is the HMAC-SHA256, in hex, of <t>.<body> under your signing secret, where the body is the raw request body. SocialToAI-Webhook-Id is the delivery's id, the same on every retry.
- Compute the HMAC over the body exactly as it arrived, before parsing it, and compare in constant time.
- Refuse a message whose
tis more than 5 minutes from your clock: that stops a replay. - A message can arrive more than once. Skip one whose
idyou have handled already; a message you send again by hand from the console keeps itsidand gets a new delivery id.
import { createHmac, timingSafeEqual } from "node:crypto";// rawBody: the request body exactly as it arrived, before any JSON parsing.export function verify(rawBody, header, secret) { const parts = Object.fromEntries(header.split(",").map((part) => part.split("="))); const seconds = Number(parts.t); if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false; const expected = createHmac("sha256", secret).update(`${seconds}.${rawBody}`).digest(); const given = Buffer.from(parts.v1 ?? "", "hex"); return given.length === expected.length && timingSafeEqual(given, expected);}import hashlib, hmac, time# raw_body: the request body as bytes, exactly as it arrived.def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(part.split("=", 1) for part in header.split(",")) seconds = int(parts.get("t", "0")) if abs(time.time() - seconds) > 300: return False signed = f"{seconds}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))Delivery and retries
- Answer 2xx within 8 seconds. Redirects are not followed, and the address is checked again before every post.
- Anything else, no answer, or no outcome within 2 minutes is a failed try. It is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours: 7 tries in all.
- 10 failed tries in a row pause that endpoint alone, and you get one email. Nothing else stops: your schedules keep running. Resume it in the console.
- The console lists recent deliveries for 30 days, sends a test event, and sends any delivery again.
Last updated: