spicyapiDocs
Main content

Webhooks

Distinguish v1 and v2 webhook payloads, select the correct task ID, and verify signatures with working Node.js, Python, and curl examples.

Pass callBackUrl to createTask and we make a POST delivery when the task reaches a terminal state. Failed deliveries follow the retry schedule below.

Callbacks fire on terminal states only. There is no queuedrunning notification.

Set a signing secret first

A signing secret is required

SpicyAPI refuses to deliver a callback without a webhook signing secret. New accounts receive one automatically; if yours has been cleared, restore it in the console before submitting callback-enabled jobs.

Keep the secret on your server and verify every callback with the code below. Poll recordInfo for any task whose callback was missed while the secret was unavailable.

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
HeaderNotes
X-Webhook-TimestampUnix seconds for this delivery. Used for replay protection
X-Webhook-SignatureHMAC-SHA256, base64-encoded
X-Webhook-Payload-VersionBody shape version. New integrations default to 2; historical deliveries may still use 1. Always branch on this header. See The payload
User-AgentAlways 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.

succeeded
{
  "code": 200,
  "msg": "success",
  "request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "kie/z-image-spicy",
    "state": "succeeded",
    "input": { "prompt": "a folded paper lantern, hard side light" },
    "output": {
      "assets": [
        {
          "key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
          "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.

As with recordInfo, data.output carries no artefact URLs — only object keys. Exchange one via /common/download-url. Money fields such as cost are decimal strings with no $.

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:

failed
{
  "code": 200,
  "msg": "success",
  "request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "kie/z-image-spicy",
    "state": "failed",
    "errorCode": "upstream_failed",
    "errorMessage": "生成失败,费用已退回",
    "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.

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 from the console reuse the same record and the same bytes, so they carry the same request_id.
  • 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 recordInfo response 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.

A payload is encoded and stored at enqueue time, and a retry replays those exact bytes. 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

A migration setting 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 .:

  1. the task ID, selected by X-Webhook-Payload-Version: top-level task_id for v1, data.taskId for v2 (never request_id)
  2. the value of X-Webhook-Timestamp
  3. 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

Node.js / Express
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.
app.post('/hooks/spicy', express.raw({ type: 'application/json' }), (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);
}
Python / Flask
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)
Test your verifier with curl
# 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"

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

RetryDelay after the previous attempt
110 seconds
230 seconds
32 minutes
410 minutes
530 minutes
62 hours
76 hours

The early attempts are dense, covering "you are restarting"; the later ones stretch into hours, covering "it broke today and gets fixed tomorrow morning". After roughly ten hours automatic retries stop, but the delivery record is kept and you can redeliver it manually from the console.

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

  • http and https only.
  • The hostname must not resolve to a private address (loopback, RFC 1918, link-local, cloud metadata). This is rejected at task creation with 400.
  • We re-check the resolved IP at connection time as well, which closes the DNS-rebinding path.

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 (ngrok, Cloudflare Tunnel) for a public address, or poll while developing locally.

On this page