Guides
Webhooks
Get a signed POST when SuperCool work finishes. Register endpoints, pick events, verify Standard Webhooks signatures, and handle retries.
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 or with the API:
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"
}'{
"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
httpson 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,PATCHandDELETE /v1/webhooks/{id}, andPOST /v1/webhooks/{id}/rotate-secret. See the reference.
#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:
{
"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,datais the full message, exactly asGET /v1/messages/{id}returns it. - For
job.*events,datais the job with"object": "job"and itsmessage_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
Signatures follow the Standard Webhooks 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
# 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// 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.
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 Falseimport { 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
2xxwithin 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
3xxcounts 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.statusanddata.final, or readGET /v1/messages/{id}, rather than assuming an order. - If an endpoint keeps failing for 3 days, it's turned off (
disabled_atis 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
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:
{ "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.