# Webhooks

> Get a signed POST when SuperCool work finishes. Register endpoints, pick events, verify Standard Webhooks signatures, and handle retries.

Source: https://supercool.com/docs/api/webhooks

Agent work takes minutes. Instead of polling, register a URL and SuperCool POSTs a signed event to it when a job changes and when a message reaches a status other than `processing`. Webhooks read the same record as `GET /v1/messages/{id}`, so they never disagree with polling.

## Create an endpoint

Create endpoints in the [API dashboard](https://supercool.com/dashboard#api) or with the API:

```bash
curl https://api.supercool.com/v1/webhooks \
  -H "Authorization: Bearer $SUPERCOOL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/supercool",
    "events": ["message.completed", "message.partial", "message.failed", "message.needs_input", "message.blocked", "message.expired"],
    "description": "prod"
  }'
```

```json
{
  "object": "webhook_endpoint",
  "id": "whe_4c1d2e3f4a5b6c7d8e9f",
  "url": "https://example.com/hooks/supercool",
  "events": ["message.completed", "message.partial", "message.failed", "message.needs_input", "message.blocked", "message.expired"],
  "description": "prod",
  "created_at": "2026-09-30T13:40:00.000000Z",
  "disabled_at": null,
  "disabled_reason": null,
  "failing_since": null,
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}
```

The `secret` is shown **only in this response** (and when you rotate it). Store it with your other secrets: you need it to verify signatures.

- The URL must be public `https` on port 443 or 8443, with no username or password in it.
- Omit `events`, or pass `["*"]`, to get every event.
- An account can have up to 10 endpoints.
- Manage them with `GET /v1/webhooks`, `GET`, `PATCH` and `DELETE /v1/webhooks/{id}`, and `POST /v1/webhooks/{id}/rotate-secret`. See the [reference](https://supercool.com/docs/api/reference#webhooks).

## Events

| Event | Sent when |
|---|---|
| `message.completed` | A message completed. |
| `message.partial` | Some of a message's work finished and some didn't. |
| `message.failed` | A message failed. |
| `message.needs_input` | The agent asked a question and is waiting for your answer. |
| `message.blocked` | A message is blocked, usually on credits. Sent even if queued work can still resume. |
| `message.expired` | A message's work didn't report back in time. |
| `job.completed` | One job finished. |
| `job.failed` | One job failed or was stopped. |
| `job.blocked` | One job is blocked (credits, hosting fees, or 5 jobs already running). |
| `job.resumed` | A job that was waiting for credits started running. |
| `job.expired` | A job was queued too long, or ran too long without reporting back. |

Each message event is sent once per status a message reaches. A message can reach more than one: queued work that resumes after you add credits goes `message.blocked`, then `message.completed`. **Use `data.final`** to know whether more events can follow for that message.

Most integrations only need the `message.*` events. Add `job.*` events to react to each piece of work as soon as it's done.

## Payload

Every event is a JSON POST with the same envelope:

```json
{
  "type": "message.completed",
  "timestamp": "2026-09-30T14:06:41.020000Z",
  "data": {
    "id": "msg_3f9a1c2e7b8d4e5f6a7b8c9d",
    "object": "message",
    "status": "completed",
    "final": true,
    "reply": "On it. I'm making a 15 second vertical ad with warm candlelight shots.",
    "jobs": [{ "job_id": "exe_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e", "status": "completed", "files": [] }],
    "files": [{ "file_id": "5d0c3e2a-8f1b-4c6d-9e7f-1a2b3c4d5e6f/candle_ad.mp4", "name": "candle_ad.mp4", "url": "https://api.supercool.sh/api/v1/files/s/eyJmIjoiNWQwYzNl..." }],
    "credits_used": 42.5
  }
}
```

- For `message.*` events, `data` is the full [message](https://supercool.com/docs/api/reference#object-message), exactly as `GET /v1/messages/{id}` returns it.
- For `job.*` events, `data` is the [job](https://supercool.com/docs/api/reference#object-job) with `"object": "job"` and its `message_id`.

The request carries these headers:

| Header | Value |
|---|---|
| `webhook-id` | The event's id (`evt_…`). The same on every retry of that event. |
| `webhook-timestamp` | Unix seconds when this attempt was signed. |
| `webhook-signature` | `v1,` followed by the base64 signature. |
| `Content-Type` | `application/json` |

## Verify signatures {#verify}

Signatures follow the [Standard Webhooks](https://www.standardwebhooks.com) spec, so off-the-shelf libraries work. Always verify before trusting a payload, and verify the **raw** request body: parsing and re-serializing the JSON changes the bytes.

### With a library

```python tab="Python"
# pip install standardwebhooks flask
import os

from flask import Flask, abort, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["SUPERCOOL_WEBHOOK_SECRET"])  # the whsec_... secret

@app.post("/hooks/supercool")
def supercool_webhook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        abort(400)
    if event["type"] == "message.completed":
        for f in event["data"]["files"]:
            print("ready:", f["name"], f["url"])
    return "", 204
```

```js tab="Node"
// npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.SUPERCOOL_WEBHOOK_SECRET); // the whsec_... secret

app.post("/hooks/supercool", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString("utf8"), req.headers);
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "message.completed") {
    for (const f of event.data.files) console.log("ready:", f.name, f.url);
  }
  res.sendStatus(204);
});

app.listen(3000);
```

The libraries also reject timestamps more than 5 minutes old, which stops replayed requests.

### By hand

The signature is an HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`. The key is your secret with the `whsec_` prefix removed, base64-decoded. The result is base64-encoded and sent as `v1,<signature>`. The header can hold several space-separated signatures; accept the request if any `v1` one matches.

```python tab="Python"
import base64
import hashlib
import hmac
import time

def verify_supercool(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    headers = {k.lower(): v for k, v in headers.items()}
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # too old (or clock skew): possibly a replay
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for sig in headers["webhook-signature"].split(" "):
        version, _, value = sig.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

```js tab="Node"
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySupercool(secret, headers, rawBody, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return String(headers["webhook-signature"]).split(" ").some((sig) => {
    const [version, value] = sig.split(",");
    if (version !== "v1" || !value) return false;
    const got = Buffer.from(value, "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

To test your code: with the secret `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`, the id `msg_p5jXN8AQM9LWM0D4loKWxJek`, the timestamp `1614265330` and the body `{"test": 2432232314}`, the signature is `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` (turn the timestamp check off for this test).

## Respond quickly, process later

- Return any `2xx` within **10 seconds**. Anything else, a timeout or a network error counts as a failure and is retried.
- Do the real work (downloading files, updating your database) after you respond, in a background job.
- **Redirects are not followed.** A `3xx` counts as a failure. If your URL moved, update the endpoint.

## Retries and duplicates

Delivery is **at least once**. A failed delivery is retried with growing gaps: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and then once a day.

- Every retry of an event has the same `webhook-id`. Store the ids you've processed and skip repeats.
- Events can arrive out of order. Use `data.status` and `data.final`, or read `GET /v1/messages/{id}`, rather than assuming an order.
- If an endpoint keeps failing for **3 days**, it's turned off (`disabled_at` is set), its pending events are dropped, and the account owner gets an email.

To turn an endpoint back on after fixing it, click **Enable** in the dashboard or send `PATCH /v1/webhooks/{id}` with `{"enabled": true}`. The dashboard's delivery log shows each attempt with its status code, response and latency, and lets you **resend** any event.

## Send one message's events elsewhere {#per-message}

By default, every endpoint that subscribes to an event receives it. To send a single message's events to one endpoint only, create the message with `webhook_endpoint_id`:

```json
{ "message": "A product video for SKU 8812", "webhook_endpoint_id": "whe_4c1d2e3f4a5b6c7d8e9f" }
```

That endpoint then gets all of the message's events, whatever its `events` list says, and no other endpoint gets them. The endpoint must belong to your account, so your code already has its secret.

## Rotate a secret

`POST /v1/webhooks/{id}/rotate-secret` returns a new secret, shown once. Every delivery from then on is signed with it, including retries of older events. Update your verifier before or right after rotating. The Standard Webhooks libraries accept only one secret, so for a zero-downtime rotation, try the new secret first and then the old one for a few minutes.

## Test locally

Your endpoint must be reachable from the internet over `https`. For local development, use a tunnel (for example `cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`) and register the tunnel's `https` URL as an endpoint. Then send a quick message, like "say hi", and watch for `message.completed`.
