SocialToAIStart freeConsole

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 (type is endpoint.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.

typeWhen it is sentFields
tool_run.finishedA ready-made tool's run ended with ok or empty.tool, handle, status, summary, cost_credits, quoted_credits, result_url
tool_run.failedA ready-made tool's run ended with partial or failed.tool, handle, status, summary, cost_credits, quoted_credits, result_url
batch.finishedA batch job ended, whatever its status.tool, batch_id, summary, cost_credits, download_url
schedule_run.finishedA 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.alertA 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.lowThe balance fell below its low-balance line, when the low-balance email goes out.balance_credits, threshold_credits, billing_url
daily_cap.reachedThe account's daily cap refused a call for the first time that UTC day.daily_limit_credits, resets_at, settings_url
A message
{  "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 t is more than 5 minutes from your clock: that stops a replay.
  • A message can arrive more than once. Skip one whose id you have handled already; a message you send again by hand from the console keeps its id and 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: