# 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.

- Page: https://socialtoai.com/docs/webhooks/
- Last updated: 2026-10-25

## Add an endpoint

- In the console open Settings → Webhooks (https://socialtoai.com/console/settings/) 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.

| 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 |

**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.

**Node.js**

```
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);
}
```

**Python**

```
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.
