Webhooks
Distinguish v1 and v2 webhook payloads, select the correct task ID, and verify signatures with working Node.js, Python, and curl examples.
A webhook (or callback) is a request we send to your server when a task finishes, so you do not have to keep asking. Pass callBackUrl to createTask and we make a POST delivery when the task reaches a terminal state. For a successful task we wait until its result files are ready to download, so the callback carries working links. Failed deliveries follow the retry schedule below.
Callbacks fire on terminal states only. There is no queued → running notification.
Before you start
- Your server needs a public HTTPS address. Our servers must be able to reach the URL over the internet, and it must start with
https://.http://localhostand addresses inside your office or home network will not work. While developing on your own computer, either poll withrecordInfoinstead, or run a tunnel tool (for example ngrok or Cloudflare Tunnel) that gives your local server a temporary public HTTPS address. The full rules are under Constraints on the callback URL. - There are two ways to receive events, and they coexist. Passing
callBackUrlon a single task is the quickest; registering an endpoint on your account covers every task without repeating the URL. See Which one to use. - There is no "send test event" button. Test your signature check first with the curl script below, then create one inexpensive real task with
callBackUrl— it is billed like any other task — and watch Console → Webhooks → Webhook deliveries, where each delivery appears with its status.
Which one to use
callBackUrl on a task | Account-level endpoint | |
|---|---|---|
| Where you set it | One field on each createTask | Once, over the API (no console screen yet) |
| What it delivers | That one task's terminal state | Every event type you subscribe to, for every task |
| Headers | X-Webhook-* | webhook-id / webhook-timestamp / webhook-signature |
| Body | The {code, msg, data} envelope, same as recordInfo | A bare event object with id, type and data.object |
| Signing secret | One per account, under Webhook signing key | One per endpoint, shown once when you create or rotate it |
| Key rotation without downtime | No — the old secret stops verifying at once | Yes — both signatures are sent during the grace period |
Neither replaces the other. A task that carries callBackUrl and belongs to an account with a matching subscription produces two deliveries, one in each shape, and that is a normal configuration rather than a mistake.
If you are integrating today and have no strong reason either way, register an account-level endpoint: you write the URL once, you can rotate its secret without a flag day, and the payload is the shape most webhook libraries already understand.
The rest of this page describes the callBackUrl delivery unless a section says otherwise. Account-level endpoints covers what differs.
Set a signing secret first
Every delivery is signed with your account's signing secret, so your server can prove the request came from SpicyAPI. One secret is shared by all API keys on the account.
SpicyAPI shows the secret only once
Accounts usually start with a secret already configured, but its value has never been shown to you, so you have to create one you can see:
- Open Console → Webhooks.
- On the Webhook signing key card, click Generate (if it says Not set) or Rotate (if it says Configured).
- Copy the value straight into
SPICY_WEBHOOK_SECRETon your server, then click I’ve saved it. It cannot be shown again; if you lose it, rotate again.
Rotating takes effect immediately: the old secret stops verifying at once, and deliveries that are still being retried are signed with the new one. Update every receiver right after you rotate.
Without a secret, a delivery cannot be signed: each attempt counts as failed and follows the retry schedule. Poll recordInfo for any task whose callback was missed while the secret was unavailable.
Start with the SDK verifier
For a new v2 receiver, use verifyWebhook from @spicyapi/sdk/webhooks. It checks the raw-body signature and the five-minute timestamp window and returns taskId and deliveryId. This helper uses Node.js crypto; keep it on your server.
The example below uses Express and PostgreSQL (npm install @spicyapi/sdk express pg). Configure the database through the usual PG* environment variables and set SPICY_WEBHOOK_SECRET to the signing secret from your console. Serve the route behind HTTPS. The body contains your full input (including the prompt), so the example allows up to 10 MB; size your own limit accordingly. Create these two application tables once:
CREATE TABLE spicy_tasks (task_id text PRIMARY KEY);
CREATE TABLE spicy_webhook_inbox (
delivery_id text PRIMARY KEY,
task_id text NOT NULL REFERENCES spicy_tasks(task_id),
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Persist each taskId returned by your own task submissions in spicy_tasks. The receiver accepts events only for those tasks; if an unusually fast callback arrives before that insert, its non-2xx response allows a delivery retry. In a shared application, apply your tenant's task ownership rule as well.
import express from "express";
import pg from "pg";
import { verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET");
const db = new pg.Pool();
const app = express();
app.post("/hooks/spicy", express.raw({ type: "application/json", limit: "10mb" }), async (req, res) => {
if (req.get("X-Webhook-Payload-Version") !== "2") return res.sendStatus(400);
let event;
try {
event = verifyWebhook({
rawBody: req.body,
timestamp: req.get("X-Webhook-Timestamp") ?? "",
signature: req.get("X-Webhook-Signature") ?? "",
payloadVersion: 2,
secret,
maxBodyBytes: 10 * 1024 * 1024,
});
if (!event.deliveryId) return res.sendStatus(400);
} catch { return res.sendStatus(401); }
try {
const owned = await db.query("SELECT 1 FROM spicy_tasks WHERE task_id=$1", [event.taskId]);
if (!owned.rowCount) return res.sendStatus(404);
await db.query(
"INSERT INTO spicy_webhook_inbox(delivery_id,task_id,payload) VALUES($1,$2,$3) ON CONFLICT(delivery_id) DO NOTHING",
[event.deliveryId, event.taskId, JSON.stringify(event.payload)],
);
return res.sendStatus(204);
} catch { return res.sendStatus(503); }
});
app.listen(8080, "127.0.0.1");Register this raw-body route before a global express.json() middleware. Return 2xx only after the inbox write commits, then let your background worker process the stored event. The primary key makes duplicate deliveries a no-op. Validate data.state and data.settled before treating a result as successful or its cost as final. If a stored media URL has expired, query the task again while its result is still retained.
This new-integration example accepts v2. For historical v1 receivers, retain the version-aware verification below; the same SDK helper supports payloadVersion: 1, but v1 has no deliveryId.
The request
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk… (base64)
X-Webhook-Payload-Version: 2| Header | Notes |
|---|---|
X-Webhook-Timestamp | Unix seconds for this delivery. Used for replay protection |
X-Webhook-Signature | HMAC-SHA256, base64-encoded |
X-Webhook-Payload-Version | Body shape version. New integrations default to 2; historical deliveries may still use 1. Always branch on this header. See The payload |
User-Agent | Always SpicyAPI-Webhook/1 |
We do not follow redirects — a 3xx from your endpoint counts as a failed delivery. The timeout is 15 seconds.
The payload
v2 matches recordInfo; v1 is the legacy flat shape
A version 2 callback has the same shape as GET /jobs/recordInfo: the {code, msg, data, request_id} envelope and the same camelCase fields inside data. Version 1 has no envelope and puts snake_case task fields at the top level.
Build new integrations for v2, but make the verification entry point read X-Webhook-Payload-Version: take the task ID from top-level task_id for v1 and from data.taskId for v2.
The fixtures below use MODEL_ID_FROM_CATALOG to stand for the exact model ID originally selected from the authenticated live catalog. It is not a literal ID.
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
"expiresAt": "2026-08-28T09:32:11Z",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:11Z"
}
}The example above is v2. The field reference for data lives in Asynchronous tasks; it governs both recordInfo and v2 callbacks, so it is not repeated here.
Like recordInfo, ready data.output.assets[] include url, expiresAt, and media metadata. Fetch the URL directly without API credentials; no download-ticket request is required. Money fields such as cost are decimal strings with no $.
One callback per task, sent when it is ready
Each task gets one callback event. A failed task's event is built the moment it fails. A successful task's event is built once its result files have been copied into our storage, so every asset normally has a url. Retries resend that same event; only the temporary media links are refreshed.
One thing can still change afterwards, and it does not trigger another callback:
data.settledisfalse→ the final charge was not decided yet. ReadrecordInfolater for the finalcost.
In the rare case a result file cannot be stored at all, the callback still arrives: that asset has unavailable: true and no url. If none of the task's files could be stored, the task is not charged (cost is "0").
A callback with state: "succeeded", settled: true and a url on every asset is complete: you can use it without another query.
v2 code is always 200, including for failed tasks
Never use code to decide whether the task succeeded
The envelope's code describes whether this delivery is well-formed, not whether the generation worked. A callback for a failed task also carries 200:
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:39Z"
}
}Code that reads if (body.code === 200) markSucceeded() marks every failed task as a success, and nothing about it ever looks broken. Success or failure lives in data.state; the reason lives in data.errorCode and data.errorMessage. A callback has no request header to go on, so errorMessage follows the account's language setting and defaults to English; see Error message language.
This matches recordInfo, which also answers 200 when the task it describes has failed. The two outlets agree on this deliberately — a disagreement would recreate exactly the "same data, two ways to read it" problem this shape was designed to remove.
v2 request_id is the delivery ID — use it as your idempotency key
The request_id in the envelope looks like whd_…. It identifies this delivery record, not the task:
- It does not change across retries. Automatic retries and manual redelivery reuse the same business event and
request_id. Only temporary mediaurlandexpiresAtfields are refreshed before each send; do not deduplicate v2 using a body hash. - That makes "have I already handled this callback?" a one-line question: unique-index it in your own database and return 2xx on conflict.
- The same slot in a
recordInforesponse holds the HTTP request ID (req_…). Same meaning in both places: the identifier of this one delivery.
For v2, the task is in data.taskId — that answers "which task". request_id answers "which delivery". v1 has no request_id; deduplicate it with a key derived from task_id, state, and the body digest.
The version header follows the bytes
X-Webhook-Payload-Version reports which version this body was built with, not which version we are currently configured for.
The business payload and its version are fixed at enqueue time. A retry refreshes only temporary media URLs and signs the exact body it sends; it never substitutes the latest task snapshot. The header has to travel with them: otherwise a retry that sat in the queue for hours arrives with a version number that contradicts its own content — and that header is what you branch on.
v1 is a frozen compatibility layer. Do not build against it
The payload version is a platform-wide compatibility switch operated by SpicyAPI; you cannot choose it, and new deliveries use version 2. That switch can pin the payload to 1, the legacy shape: snake_case, no envelope, fields flat at the top level, and missing input, settled and completedAt. It exists for one reason — so integrations built against the old shape do not break the day the new one lands.
v1 takes no new fields. Everything from here on is added to v2 only. Once we confirm nobody is still on v1, it will be removed along with the setting.
Build new integrations against v2. Historical deliveries may still be retried as v1, so do not infer the version from the JSON shape. Read X-Webhook-Payload-Version and branch as shown below for both signature verification and parsing.
The signature
signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) )Three parts joined by .:
- the task ID, selected by
X-Webhook-Payload-Version: top-leveltask_idfor v1,data.taskIdfor v2 (neverrequest_id) - the value of
X-Webhook-Timestamp - the SHA-256 digest of the raw request body, lowercase hex
The body digest must be in the signature — and your verifier must actually compute it
Sign only the task ID and the timestamp, and anyone who has intercepted one legitimate delivery can pair those two values with a body of their own and replay it at your endpoint. You verify with the published algorithm, and the signature checks out.
That amounts to us endorsing a forged "task complete, artefact is here" — which you then act on by charging a customer or shipping a result. The digest is not optional.
Digest the raw bytes
Do not deserialise and re-serialise first. Key order, whitespace and Unicode escaping differ between JSON libraries; the bytes you get back are almost certainly not the bytes we signed, and verification will fail every time.
How to reach the raw body: express.raw() in Express, rawBody in Fastify, request.get_data() in Flask, request.body in Django, await request.body() in FastAPI.
Verification code
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SPICY_WEBHOOK_SECRET;
// The raw bytes are required. express.json() consumes them.
// express.raw() defaults to a 100 KB limit; the body carries your full input.
app.post('/hooks/spicy', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
if (!verify(req.body, req.headers, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
const version = req.headers['x-webhook-payload-version'];
const task = version === '1' ? event : event.data;
// Acknowledge first, work later. Do not make us wait on your pipeline.
res.status(200).end();
enqueue(task);
});
function verify(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
const sig = headers['x-webhook-signature'];
const version = headers['x-webhook-payload-version'];
if (!ts || !sig || (version !== '1' && version !== '2')) return false;
// Replay protection: reject anything outside the tolerance window,
// however valid its signature
const timestamp = Number(ts);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
// Nothing parsed out of an unverified body can be trusted yet.
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return false;
}
const taskId = version === '1' ? event.task_id : event.data?.taskId;
if (typeof taskId !== 'string' || !taskId) return false;
const digest = crypto.createHash('sha256').update(rawBody).digest('hex');
const expected = crypto
.createHmac('sha256', secret)
.update(`${taskId}.${ts}.${digest}`)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
// timingSafeEqual throws on a length mismatch, so check that first
if (a.length !== b.length) return false;
// Constant time — a plain === leaks information through timing
return crypto.timingSafeEqual(a, b);
}import base64, hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SPICY_WEBHOOK_SECRET"]
@app.post("/hooks/spicy")
def hook():
# get_data() returns the raw bytes; request.json does not
raw = request.get_data()
if not verify(raw, request.headers, SECRET):
return "", 401
event = json.loads(raw)
version = request.headers["X-Webhook-Payload-Version"]
task = event if version == "1" else event["data"]
enqueue(task) # acknowledge first, work later
return "", 200
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
version = headers.get("X-Webhook-Payload-Version")
if not ts or not sig or version not in ("1", "2"):
return False
# Replay protection: reject anything outside the tolerance window
try:
timestamp = int(ts)
event = json.loads(raw_body)
except (ValueError, TypeError, json.JSONDecodeError):
return False
if abs(time.time() - timestamp) > 300:
return False
# v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
# Nothing in an unverified body can be trusted yet.
task_id = event.get("task_id") if version == "1" else event.get("data", {}).get("taskId")
if not isinstance(task_id, str) or not task_id:
return False
digest = hashlib.sha256(raw_body).hexdigest()
expected = base64.b64encode(
hmac.new(secret.encode(), f"{task_id}.{ts}.{digest}".encode(), hashlib.sha256).digest()
).decode()
# Constant time
return hmac.compare_digest(expected, sig)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("SPICY_WEBHOOK_SECRET"))
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
type event struct {
TaskID string `json:"task_id"`
Data struct {
TaskID string `json:"taskId"`
} `json:"data"`
}
func hook(w http.ResponseWriter, r *http.Request) {
// The raw bytes are required. Read them once, and never re-encode
// the parsed JSON: key order and escaping would not survive.
// The body contains the full input, so allow up to 10 MB.
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(rawBody, r.Header) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
taskID := parsed.Data.TaskID
if r.Header.Get("X-Webhook-Payload-Version") == "1" {
taskID = parsed.TaskID
}
// Acknowledge first, work later. Do not make us wait on your pipeline.
w.WriteHeader(http.StatusOK)
enqueue(taskID)
}
func verify(rawBody []byte, headers http.Header) bool {
ts := headers.Get("X-Webhook-Timestamp")
signature := headers.Get("X-Webhook-Signature")
version := headers.Get("X-Webhook-Payload-Version")
if ts == "" || signature == "" || (version != "1" && version != "2") {
return false
}
// Replay protection: reject anything outside the tolerance window,
// however valid its signature.
timestamp, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
return false
}
// Nothing parsed out of an unverified body can be trusted yet.
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
return false
}
taskID := parsed.Data.TaskID
if version == "1" {
taskID = parsed.TaskID
}
if taskID == "" {
return false
}
digest := sha256.Sum256(rawBody)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(taskID + "." + ts + "." + hex.EncodeToString(digest[:])))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Constant time — a plain == leaks information through timing.
return hmac.Equal([]byte(expected), []byte(signature))
}
func enqueue(taskID string) {
// Your own queue. Return from the handler before doing any real work.
_ = taskID
}
func main() {
http.HandleFunc("/hooks/spicy", hook)
_ = http.ListenAndServe(":8080", nil)
}Test your verifier with curl
Save the succeeded v2 example from The payload as payload.json, set SPICY_WEBHOOK_SECRET to the same secret your receiver uses, and change the URL at the bottom to your own endpoint. Then run:
# Sign a payload file yourself and post it at your own endpoint. This is the
# only way to confirm your verification code actually passes before go-live.
BODY_FILE=payload.json
VERSION=2
TS=$(date +%s)
TASK_ID=$(jq -r --arg version "$VERSION" \
'if $version == "1" then .task_id else .data.taskId end' "$BODY_FILE")
DIGEST=$(openssl dgst -sha256 -hex "$BODY_FILE" | awk '{print $NF}')
SIG=$(printf '%s.%s.%s' "$TASK_ID" "$TS" "$DIGEST" \
| openssl dgst -sha256 -hmac "$SPICY_WEBHOOK_SECRET" -binary \
| base64)
curl -X POST https://your-app.example.com/hooks/spicy \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Payload-Version: $VERSION" \
--data-binary "@$BODY_FILE"Expected: your endpoint returns a 2xx status. Now change one character of SPICY_WEBHOOK_SECRET and run it again — it must return 401. If it still returns 2xx, your receiver is not checking signatures. (The SDK receiver above also answers 404 for a task ID it does not have in spicy_tasks; insert the example taskId there first, or test against the plain verification code.)
Account-level endpoints
An endpoint is a URL registered on your account together with the list of events it wants. Once it exists, every matching event is delivered to it — you never pass a URL on a task again.
Register one
Over the API:
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/spicy",
"enabledEvents": ["task.completed"],
"description": "production"
}'No console screen for this yet
Endpoints are API-only today. Console → Webhooks manages the account-wide signing secret used by callBackUrl and lists deliveries; it has no page for registering endpoints. Everything on this page is done with the four calls shown here, and you need a console session cookie to make them — the same credential your browser uses, not an sk-spicy- key.
The response carries secret, which looks like whsec_…. It is shown exactly once. No later call returns it — not the list, not the detail view. Lose it and your only option is to rotate.
An account can hold 16 endpoints. The URL must be a public HTTPS address, checked when you save it and again at delivery time against the address it actually resolves to.
Events you can subscribe to
| Event | Fires when |
|---|---|
task.completed | Shorthand for all four below. Subscribe to this unless you genuinely need to tell them apart |
task.succeeded | A task finished |
task.failed | A task failed; the hold is normally released. Content refusals may be charged when the model page explicitly discloses this exception |
task.canceled | Kept for historical records — tasks accepted today cannot be canceled |
task.expired | A task passed its deadline; any hold on your balance is released |
GET /console/v1/webhook-events returns this list at runtime, so you can build a picker without hard-coding it.
Subscribe to task.completed rather than listing three of the four
Spelling out succeeded, failed and canceled while forgetting expired is the common mistake, and its symptom is a task your code waits on forever. task.completed cannot develop that gap.
Unknown event names are rejected when you save, rather than quietly ignored — a typo should fail at registration, not six weeks later when you notice one kind of event has never arrived. Wildcards ("*") are not accepted for the same reason.
The delivery
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/2
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,9tXk…{
"id": "msg_01k3m8x9q2z4v7n5p6r8s0t1w3",
"type": "task.succeeded",
"created": 1787045531,
"api_version": "2026-09-20",
"data": {
"object": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "succeeded",
"settled": true,
"cost": "0.0300"
}
}
}data.object is the same task record recordInfo returns, and it is a snapshot taken when the event was created, not current state. Process a delivery three days late and you still read the values from that moment.
settled tells you whether cost is final
On the delayed-settlement path a task reaches its terminal state before billing closes, so the event goes out with settled: false and cost still holding the estimate. No correcting event follows. If the exact amount matters to you, read recordInfo when settled is false.
Verifying
signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )This follows the Standard Webhooks specification, so a library written for Stripe or Replicate works with only the header names changed. Two differences from the callBackUrl signature above, both of which silently produce a signature that never verifies if you get them wrong:
- The body itself is signed, not a digest of it. There is no inner
sha256step here. - The HMAC key is the bytes you get by base64-decoding what follows
whsec_— not that string. Using the string is the single most common mistake; nothing errors, the signature is simply always wrong.
import crypto from 'node:crypto';
// req.body must be the raw bytes, not a parsed object
function verify(req, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const id = req.headers['webhook-id'];
const ts = req.headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected =
'v1,' +
crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(req.body).digest('base64');
// The header may hold several space-separated signatures during a rotation
return String(req.headers['webhook-signature'])
.split(' ')
.some((got) => got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)));
}webhook-id stays the same across retries of one message — use it as your idempotency key. webhook-timestamp is refreshed on every attempt, because it guards against replay rather than duplication.
Rotating a secret without downtime
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
-H 'Content-Type: application/json' \
-d '{"gracePeriodSeconds": 86400}'During the grace period every delivery carries two signatures, space-separated in one header, and either verifying is enough. That is what the .some(…) loop above is for. So the safe order is: deploy a receiver that accepts both → rotate → move the new secret into place → let the window close. Nothing has to go down.
The default window is 24 hours and the maximum is 7 days. Omit the body entirely to take the default.
When an endpoint stops working
Deliveries retry on the schedule below. On top of that:
- A
410 Goneresponse disables the endpoint immediately and drops its queued messages. Continuing to retry an address whose owner told us it no longer exists helps nobody.GET /console/v1/webhook-endpoints/{id}then reportsstatus: "disabled"withdisabledReason: "gone_410". - Every other failure increments
consecutiveFailureson the endpoint, which the same call returns. Nothing is disabled automatically today.
A broken endpoint stays subscribed
Automatic disabling after days of sustained failure is not implemented yet. An endpoint that is down, but answering with something other than 410, keeps receiving every event and burning its full retry budget on each one. Poll consecutiveFailures yourself, and PATCH the endpoint with {"enabled": false} while you repair it.
balance.low and webhook.endpoint.disabled are likewise not available yet, which is why they are absent from the table above.
Re-enabling an endpoint clears its failure counters, so a freshly repaired one does not carry old failures into its next hiccup.
Deleting one
Deleting stops future deliveries. Messages already queued for it keep their history — you can still see how they ended — but without the endpoint they can no longer be signed, so they finish as failed rather than vanishing from your delivery log.
What to respond
Any 2xx means received. Any other status, a connection failure, or more than 15 seconds counts as a failure and triggers a retry.
Acknowledge before doing the work — push the task ID onto your own queue and return. Downloading, transcoding and notifying inside the callback request just times the delivery out and gets it redelivered, while your side has already half-processed it.
Retry schedule
| Retry | Delay after the previous attempt |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
There are eight automatic attempts including the first delivery. The scheduled delays add up to 8 hours, 42 minutes and 40 seconds; request duration and scheduling can extend the elapsed time. After the retries are exhausted, automatic retries stop and the delivery shows as failed under Console → Webhooks → Webhook deliveries. There, Retry queues it once more (up to 6 manual retries per minute). You cannot manually resend a delivery that is still being retried automatically, one that was already delivered, or one whose content has been removed under your retention settings — use recordInfo for those.
Every retry is signed afresh
The timestamp is the one at the moment of that delivery, not the first attempt. Otherwise a retry sent six hours later would arrive carrying a six-hour-old timestamp and be rejected by any correctly implemented replay window — the retry mechanism would fail exactly when you needed it.
What this means for you: do not cache or compare timestamps across deliveries. Validate each one against the current time.
Idempotency
A callback for the same task can arrive more than once: retries, network duplicates, and manual redelivery. Never assume exactly-once.
For v2, deduplicate on the envelope's request_id (whd_…). It identifies the delivery record and stays the same across retries, which is precisely what an idempotency key needs to do. The cheapest implementation is a unique index on it in your own database — on conflict, return 2xx and move on.
v1 has no request_id. If you still receive v1, derive an idempotency key from task_id, state, and the raw-body digest. Do not deduplicate on the task ID alone, because that could suppress a distinct terminal-state record for the same task in the future.
Constraints on the callback URL
httpsonly. Plainhttp://URLs are rejected, because the callback body carries your prompt and signed result links.- No explicit port other than
443or80; the default port of anhttps://URL is fine. - Do not include a username or password in the URL.
- The hostname must resolve, and every resolved address must be public. Loopback, private networks, link-local addresses, and cloud metadata addresses are rejected.
- We re-check the resolved IP at connection time as well, which closes the DNS-rebinding path.
Invalid callBackUrl values on task creation, streaming, or quoting return HTTP 400 with business code: 400, msg: "Invalid callback URL", and a request_id. The response does not echo the submitted URL, hostname, IP address, or DNS diagnostics.
Both checks exist to stop anyone using our servers as a jump host into a private network. The side effect is that http://localhost:3000 will not work in local development — use a tunnel (for example ngrok or Cloudflare Tunnel) for a public HTTPS address, or poll while developing locally.

