spicyapiDokumentation
Mit der API entwickeln
Hauptinhalt

Webhooks

v1- und v2-Nutzdaten unterscheiden, die richtige Task-ID wählen und Signaturen mit ausführbaren Beispielen für Node.js, Python und curl prüfen.

Ein Webhook, auch Callback, ist eine Anfrage, die wir bei Taskabschluss an Ihren Server senden, damit Sie nicht laufend nachfragen müssen. Übergeben Sie callBackUrl an createTask. Beim Erreichen eines Endzustands senden wir einen POST. Bei erfolgreichen Tasks warten wir, bis die Ergebnisdateien herunterladbar sind, damit der Callback funktionierende Links enthält. Fehlgeschlagene Zustellungen folgen dem unten genannten Wiederholungsplan.

Callbacks werden nur bei Endzuständen ausgelöst. Für queued → running gibt es keine Meldung.

Vor dem Start

  • Ihr Server braucht eine öffentliche HTTPS-Adresse. Unsere Server müssen sie über das Internet erreichen; sie muss mit https:// beginnen. http://localhost und Adressen in Ihrem Büro- oder Heimnetz funktionieren nicht. Lokal nutzen Sie stattdessen Polling mit recordInfo oder ein Tunnelwerkzeug wie ngrok oder Cloudflare Tunnel für eine temporäre öffentliche HTTPS-Adresse. Die vollständigen Regeln stehen unter Callback-URL-Beschränkungen.
  • Es gibt zwei nebeneinander nutzbare Empfangswege. callBackUrl je Task ist der schnellste Einstieg. Ein registrierter Kontoendpunkt deckt jeden Task ab, ohne die URL wiederholt zu senden. Siehe Welchen Weg wählen?.
  • Es gibt keine Schaltfläche zum Senden eines Testereignisses. Prüfen Sie zuerst die Signaturprüfung mit dem curl-Skript unten. Erstellen Sie dann einen günstigen echten Task mit callBackUrl, der wie jeder Task berechnet wird. Unter Konsole → Webhooks → Webhook-Zustellungen erscheint jede Zustellung mit Status.

Welchen Weg wählen?

callBackUrl je TaskKontoendpunkt
EinrichtungEin Feld je createTaskEinmal über API; noch keine Konsolenoberfläche
Zugestellte EreignisseEndzustand dieses TasksAlle abonnierten Ereignisarten für jeden Task
HeaderX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
Body{code, msg, data}-Struktur wie bei recordInfoEreignisobjekt ohne Hülle mit id, type und data.object
SignaturgeheimnisEines je Konto unter Webhook-SignaturschlüsselEines je Endpunkt, einmalig bei Erstellung oder Rotation angezeigt
Rotation ohne AusfallNein; altes Geheimnis wird sofort ungültigJa; während Übergangsfrist werden beide Signaturen gesendet

Keiner ersetzt den anderen. Ein Task mit callBackUrl in einem Konto mit passendem Abonnement erzeugt zwei Zustellungen in den jeweiligen Strukturen. Das ist eine normale Konfiguration und kein Fehler.

Beginnen Sie heute ohne zwingenden Grund für einen der Wege, registrieren Sie einen Kontoendpunkt: Sie tragen die URL einmal ein, können das Geheimnis ohne gleichzeitige Umstellung aller Empfänger rotieren und erhalten die von vielen Webhookbibliotheken bekannte Struktur.

Der Rest beschreibt callBackUrl, sofern ein Abschnitt nichts anderes sagt. Kontoendpunkte erläutert die Unterschiede.

Zuerst Signaturgeheimnis einrichten

Jede Zustellung wird mit dem Signaturgeheimnis Ihres Kontos signiert, damit Ihr Server die Herkunft von SpicyAPI nachweisen kann. Alle API-Keys des Kontos teilen ein Geheimnis.

SpicyAPI zeigt das Geheimnis nur einmal

Konten haben meist bereits ein eingerichtetes Geheimnis. Sein Wert wurde Ihnen jedoch noch nie gezeigt. Erstellen Sie daher eines, dessen Wert Sie sehen können:

  1. Öffnen Sie Konsole → Webhooks.
  2. Klicken Sie auf der Karte Webhook-Signaturschlüssel auf Generieren, wenn Nicht eingerichtet erscheint, oder auf Rotieren, wenn Eingerichtet erscheint.
  3. Kopieren Sie den Wert direkt nach SPICY_WEBHOOK_SECRET auf Ihrem Server und klicken Sie Gespeichert. Er lässt sich nicht erneut anzeigen; bei Verlust rotieren Sie nochmals.

Rotation gilt sofort. Das alte Geheimnis kann keine Zustellung mehr verifizieren; auch noch wiederholte Zustellungen werden mit dem neuen signiert. Aktualisieren Sie alle Empfänger direkt danach.

Ohne Geheimnis lässt sich eine Zustellung nicht signieren. Jeder Versuch gilt als fehlgeschlagen und folgt dem Wiederholungsplan. Fragen Sie mit recordInfo Tasks ab, deren Callback in dieser Zeit verloren ging.

Mit der SDK-Prüfung beginnen

Für einen neuen v2-Empfänger verwenden Sie verifyWebhook aus @spicyapi/sdk/webhooks. Es prüft die Signatur des unveränderten Bodys und das fünfminütige Zeitfenster und liefert taskId sowie deliveryId. Der Helfer verwendet Node.js-Kryptografie und gehört auf Ihren Server.

Das Beispiel nutzt Express und PostgreSQL mit npm install @spicyapi/sdk express pg. Konfigurieren Sie die Datenbank über die üblichen PG*-Umgebungsvariablen und setzen Sie SPICY_WEBHOOK_SECRET auf das Geheimnis aus der Konsole. Stellen Sie die Route hinter HTTPS bereit. Der Body enthält Ihr vollständiges input einschließlich Prompt; das Beispiel erlaubt daher bis zu 10 MB. Wählen Sie Ihre eigene passende Grenze. Erstellen Sie diese beiden Anwendungstabellen einmal:

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()
);

Speichern Sie jede aus eigenen Einreichungen erhaltene taskId in spicy_tasks. Der Empfänger akzeptiert nur Ereignisse dieser Tasks. Trifft ein ungewöhnlich schneller Callback vor dem Einfügen ein, ermöglicht seine Nicht-2xx-Antwort eine Wiederholung. In einer gemeinsam genutzten Anwendung wenden Sie zusätzlich die Taskzuordnung Ihres Mandanten an.

receiver.mjs
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");

Registrieren Sie diese Raw-Body-Route vor globaler express.json()-Middleware. Antworten Sie erst nach Commit des Eingangseintrags mit 2xx. Ihr Hintergrundworker verarbeitet das gespeicherte Ereignis danach. Der Primärschlüssel macht doppelte Zustellungen wirkungslos. Prüfen Sie data.state und data.settled, bevor Sie ein Ergebnis als erfolgreich oder Kosten als endgültig behandeln. Ist eine gespeicherte Medien-URL abgelaufen, fragen Sie den Task während seiner Ergebnisaufbewahrung erneut ab.

Dieses Beispiel für neue Integrationen akzeptiert v2. Bei historischen v1-Empfängern behalten Sie die versionsabhängige Prüfung unten. Derselbe SDK-Helfer unterstützt payloadVersion: 1; v1 hat jedoch keine deliveryId.

Die Anfrage

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
HeaderHinweise
X-Webhook-TimestampUnixsekunden dieser Zustellung, zum Schutz vor Wiederholung abgefangener Anfragen
X-Webhook-SignatureBase64-kodiertes HMAC-SHA256
X-Webhook-Payload-VersionVersion der Bodystruktur. Neue Integrationen verwenden standardmäßig 2; historische Zustellungen können 1 nutzen. Logik immer anhand dieses Headers wählen; siehe Nutzdaten
User-AgentImmer SpicyAPI-Webhook/1

Wir folgen keinen Weiterleitungen. Ein 3xx Ihres Endpunkts gilt als fehlgeschlagene Zustellung. Der Timeout beträgt 15 Sekunden.

Die Nutzdaten

v2 entspricht recordInfo; v1 ist die alte flache Struktur

Ein Callback der Version 2 hat dieselbe Struktur wie GET /jobs/recordInfo: die Hülle {code, msg, data, request_id} mit denselben camelCase-Feldern in data. Version 1 hat keine Hülle und legt snake_case-Taskfelder auf die oberste Ebene.

Bauen Sie neue Integrationen für v2. Die Prüfung muss aber X-Webhook-Payload-Version lesen: Task-ID für v1 aus task_id auf oberster Ebene, für v2 aus data.taskId.

MODEL_ID_FROM_CATALOG in den Beispieldaten steht für die exakte ursprünglich aus dem authentifizierten Livekatalog gewählte Modell-ID. Es ist keine wörtlich zu sendende ID.

succeeded
{
  "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"
  }
}

Das Beispiel ist v2. Die Feldreferenz für data steht unter Asynchrone Tasks. Sie gilt für recordInfo und v2-Callbacks und wird hier nicht wiederholt.

Wie bei recordInfo enthalten fertige data.output.assets[] die Felder url, expiresAt und Medienmetadaten. Laden Sie die URL direkt ohne API-Zugangsdaten; ein Downloadticket ist nicht nötig. Geldfelder wie cost sind dezimale Zeichenfolgen ohne $.

Ein Callback je Task, sobald er bereit ist

Jeder Task erhält ein Callbackereignis. Bei Fehler wird es sofort erstellt. Bei Erfolg entsteht es, sobald Ergebnisdateien in unseren Speicher kopiert wurden, sodass jede Datei normalerweise eine url hat. Wiederholungen senden dasselbe Ereignis; nur temporäre Medienlinks werden erneuert.

Eine Sache kann sich danach ändern, ohne einen weiteren Callback auszulösen:

  • data.settled gleich false: Endgültige Kosten waren noch nicht festgelegt. Lesen Sie später recordInfo für endgültiges cost.

Kann in seltenen Fällen eine Ergebnisdatei gar nicht gespeichert werden, kommt der Callback dennoch. Diese Datei hat unavailable: true und keine url. Konnten keine Taskdateien gespeichert werden, kostet der Task nichts: cost ist "0".

Ein Callback mit state: "succeeded", settled: true und einer url je Datei ist vollständig. Sie können ihn ohne weitere Abfrage verwenden.

v2-code ist immer 200, auch bei Taskfehlern

Entscheiden Sie den Taskerfolg niemals anhand von code

code der Hülle beschreibt, ob diese Zustellung korrekt aufgebaut ist, nicht den Generierungserfolg. Auch ein Callback für einen fehlgeschlagenen Task trägt 200:

failed
{
  "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 wie if (body.code === 200) markSucceeded() markiert jeden fehlgeschlagenen Task als Erfolg, ohne dass offensichtlich etwas kaputt wirkt. Erfolg oder Fehler steht in data.state; der Grund in data.errorCode und data.errorMessage. Ein Callback hat keinen Anfrageheader zur Sprachauswahl. errorMessage folgt deshalb der Kontoeinstellung, standardmäßig Englisch; siehe Fehlermeldungssprache.

Dies entspricht recordInfo, das für einen fehlgeschlagenen Task ebenfalls 200 liefert. Beide Wege stimmen bewusst überein. Ein Unterschied würde genau das Problem „dieselben Daten, zwei Lesarten“ zurückbringen, das diese Struktur beseitigt.

v2-request_id ist die Zustellungs-ID und Ihr Idempotenzschlüssel

request_id sieht wie whd_… aus. Es kennzeichnet diesen Zustellungsdatensatz, nicht den Task:

  • Es bleibt über Wiederholungen gleich. Automatische Wiederholungen und manuelle Neuzustellung nutzen dasselbe Geschäftsereignis und request_id. Nur temporäre Medienfelder url und expiresAt werden vor jedem Senden erneuert. Deduplizieren Sie v2 nicht anhand eines Bodyhashs.
  • „Habe ich diesen Callback schon verarbeitet?“ wird damit einfach: Setzen Sie einen eindeutigen Datenbankindex darauf und liefern Sie bei Konflikt 2xx.
  • Dasselbe Feld in einer recordInfo-Antwort enthält die HTTP-Anfrage-ID req_…. In beiden Fällen bezeichnet es diese einzelne Zustellung.

Der Task steht bei v2 in data.taskId und beantwortet „welcher Task?“. request_id beantwortet „welche Zustellung?“. v1 hat kein request_id; deduplizieren Sie mit einem Schlüssel aus task_id, Zustand und Bodyhash.

Der Versionsheader folgt dem tatsächlichen Body

X-Webhook-Payload-Version meldet, mit welcher Version der Body erstellt wurde, nicht die aktuell konfigurierte Version.

Geschäftsnutzdaten und Version werden beim Einstellen in die Warteschlange festgelegt. Eine Wiederholung erneuert nur temporäre Medien-URLs und signiert genau den gesendeten Body. Sie ersetzt ihn nie durch den neuesten Taskzustand. Der Header muss dazu passen. Sonst käme eine stundenlang wartende Wiederholung mit einer Versionsnummer, die dem Inhalt widerspricht, obwohl gerade dieser Header Ihre Verarbeitung steuert.

v1 ist eine eingefrorene Kompatibilitätsschicht, keine Grundlage für neue Integrationen

Die Nutzdatenversion ist ein plattformweiter Kompatibilitätsschalter von SpicyAPI. Sie können sie nicht wählen; neue Zustellungen nutzen Version 2. Der Schalter kann die alte Struktur auf 1 festlegen: snake_case, keine Hülle, flache Felder auf oberster Ebene und fehlendes input, settled und completedAt. Er verhindert ausschließlich, dass bestehende Integrationen bei Einführung der neuen Struktur brechen.

v1 erhält keine neuen Felder. Alle künftigen Erweiterungen gelten nur für v2. Sobald niemand mehr v1 nutzt, werden es und die Einstellung entfernt.

Bauen Sie neue Integrationen gegen v2. Historische Zustellungen können weiter als v1 wiederholt werden. Leiten Sie die Version daher nicht aus der JSON-Struktur ab. Lesen Sie X-Webhook-Payload-Version und unterscheiden Sie wie unten bei Signaturprüfung und Verarbeitung.

Die Signatur

signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) )

Drei durch . verbundene Teile:

  1. Task-ID, gewählt nach X-Webhook-Payload-Version: task_id auf oberster Ebene bei v1, data.taskId bei v2, niemals request_id
  2. Wert von X-Webhook-Timestamp
  3. SHA-256-Hash des unveränderten Anfragebodys, als kleingeschriebene Hexadezimalzeichenfolge

Der Bodyhash gehört in die Signatur und muss tatsächlich berechnet werden

Signieren Sie nur Task-ID und Zeitstempel, kann jemand nach Abfangen einer legitimen Zustellung diese Werte mit einem eigenen Body verbinden und erneut an Ihren Endpunkt senden. Ihre Prüfung nach dem veröffentlichten Algorithmus würde die Signatur bestätigen.

Damit würden wir ein gefälschtes „Task fertig, Datei hier“ bestätigen, nach dem Sie etwa einen Kunden belasten oder ein Ergebnis ausliefern. Der Hash ist nicht optional.

Hashen Sie die unveränderten Bytes

Deserialisieren und serialisieren Sie nicht zuerst. Schlüsselreihenfolge, Leerraum und Unicode-Escaping unterscheiden sich zwischen JSON-Bibliotheken. Die resultierenden Bytes sind meist nicht die signierten; die Prüfung schlägt jedes Mal fehl.

Zugriff auf den unveränderten Body: express.raw() in Express, rawBody in Fastify, request.get_data() in Flask, request.body in Django, await request.body() in FastAPI.

Prüfcode

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.
// 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);
}
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)
Go / net/http
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)
}

Prüfung mit curl testen

Speichern Sie das erfolgreiche v2-Beispiel aus Nutzdaten als payload.json. Setzen Sie SPICY_WEBHOOK_SECRET auf dasselbe Geheimnis wie Ihr Empfänger und ändern Sie die URL unten zu Ihrem Endpunkt. Führen Sie dann aus:

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"

Erwartet wird ein 2xx-Status. Ändern Sie nun ein Zeichen von SPICY_WEBHOOK_SECRET und führen Sie es erneut aus: Die Antwort muss 401 sein. Bleibt sie 2xx, prüft Ihr Empfänger keine Signaturen. Der SDK-Empfänger oben liefert auch 404 für unbekannte Task-IDs in spicy_tasks. Fügen Sie zuerst die Beispiel-taskId ein oder testen Sie gegen den einfachen Prüfcode.

Kontoendpunkte

Ein Endpunkt ist eine im Konto registrierte URL mit abonnierten Ereignisarten. Danach wird jedes passende Ereignis dorthin gesendet. Sie müssen keine URL mehr je Task übergeben.

Endpunkt registrieren

Über die 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"
  }'

Hierfür gibt es noch keine Konsolenoberfläche

Endpunkte sind derzeit nur über API verfügbar. Konsole → Webhooks verwaltet das kontoweite Signaturgeheimnis für callBackUrl und zeigt Zustellungen. Eine Endpunktregistrierung gibt es dort nicht. Die Einrichtung erfolgt mit den vier gezeigten Aufrufen. Sie benötigen ein Konsolensitzungs-Cookie, also dieselben Zugangsdaten wie Ihr Browser, keinen sk-spicy--Key.

Die Antwort enthält secret, etwa whsec_…. Es wird genau einmal gezeigt. Kein späterer Aufruf gibt es zurück, weder Liste noch Detailansicht. Bei Verlust bleibt nur Rotation.

Ein Konto kann 16 Endpunkte haben. Die URL muss öffentliches HTTPS nutzen. Beim Speichern und bei Zustellung wird sie anhand ihrer tatsächlich aufgelösten Adresse geprüft.

Abonnierbare Ereignisse

EreignisAuslösung
task.completedSammelereignis für die vier folgenden; abonnieren Sie dies, sofern Sie sie nicht gezielt unterscheiden müssen
task.succeededTask abgeschlossen
task.failedTask fehlgeschlagen; die Reservierung wird normalerweise freigegeben. Inhaltsablehnungen können berechnet werden, wenn die Modellseite diese Ausnahme ausdrücklich nennt
task.canceledFür historische Datensätze; heute angenommene Tasks sind nicht abbrechbar
task.expiredTaskfrist überschritten; Reservierung des Guthabens wird freigegeben

GET /console/v1/webhook-events liefert diese Liste zur Laufzeit, sodass Ihre Auswahl ohne fest codierte Werte auskommt.

task.completed abonnieren statt drei von vier Ereignissen aufzuzählen

Ein häufiger Fehler ist, succeeded, failed und canceled anzugeben und expired zu vergessen. Dann wartet Ihr Code für immer auf einen Task. Bei task.completed entsteht diese Lücke nicht.

Unbekannte Ereignisnamen werden beim Speichern abgelehnt, nicht still ignoriert. Ein Tippfehler soll bei Registrierung auffallen, nicht sechs Wochen später durch fehlende Ereignisse. Wildcards wie "*" sind aus demselben Grund nicht akzeptiert.

Zustellung

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 ist derselbe Taskdatensatz wie bei recordInfo, aber eine Momentaufnahme bei Ereigniserstellung, kein aktueller Zustand. Verarbeiten Sie die Zustellung drei Tage später, lesen Sie dennoch die damaligen Werte.

settled zeigt, ob cost endgültig ist

Bei verzögerter Abrechnung erreicht der Task vor deren Abschluss den Endzustand. Das Ereignis trägt daher settled: false; cost enthält noch die Schätzung. Es folgt kein korrigierendes Ereignis. Ist der exakte Betrag wichtig, lesen Sie bei settled gleich false später recordInfo.

Signaturprüfung

signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )

Dies folgt der Spezifikation Standard Webhooks. Eine für Stripe oder Replicate geschriebene Bibliothek funktioniert daher mit angepassten Headernamen. Zwei Unterschiede zur callBackUrl-Signatur oben führen bei Verwechslung still zu immer ungültigen Signaturen:

  1. Der Body selbst wird signiert, nicht sein Hash. Es gibt hier keinen inneren sha256-Schritt.
  2. Der HMAC-Key sind die Bytes nach Base64-Dekodierung des Teils hinter whsec_, nicht die Zeichenfolge selbst. Diese zu verwenden ist der häufigste Fehler: Es entsteht keine Fehlermeldung, die Signatur ist nur immer falsch.
Node.js
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 bleibt bei Wiederholungen einer Nachricht gleich: Verwenden Sie es als Idempotenzschlüssel. webhook-timestamp wird je Versuch erneuert, weil es gegen abgefangene Wiederholungen schützt, nicht gegen doppelte Zustellung.

Geheimnis ohne Ausfall rotieren

curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
  -H 'Content-Type: application/json' \
  -d '{"gracePeriodSeconds": 86400}'

Während der Übergangsfrist enthält jede Zustellung zwei durch Leerzeichen getrennte Signaturen in einem Header. Eine gültige genügt. Dafür dient die .some(…)-Schleife oben. Sicherer Ablauf: Empfänger für beide Signaturen bereitstellen → rotieren → neues Geheimnis einsetzen → Frist auslaufen lassen. Kein Ausfall ist nötig.

Standardfrist ist 24 Stunden, höchstens 7 Tage. Lassen Sie für den Standard den Body vollständig weg.

Wenn ein Endpunkt ausfällt

Zustellungen werden nach dem folgenden Plan wiederholt. Zusätzlich gilt:

  • 410 Gone deaktiviert den Endpunkt sofort und entfernt seine wartenden Nachrichten. Wiederholungen an eine vom Eigentümer als nicht mehr vorhanden gemeldete Adresse helfen niemandem. GET /console/v1/webhook-endpoints/{id} liefert dann status: "disabled" mit disabledReason: "gone_410".
  • Jeder andere Fehler erhöht consecutiveFailures, das derselbe Aufruf zurückgibt. Derzeit erfolgt keine andere automatische Deaktivierung.

Ein defekter Endpunkt bleibt abonniert

Automatische Deaktivierung nach tagelangem Ausfall ist noch nicht implementiert. Ein ausgefallener Endpunkt mit anderer Antwort als 410 erhält weiterhin jedes Ereignis und verbraucht jedes Mal das ganze Wiederholungsbudget. Prüfen Sie consecutiveFailures selbst und setzen Sie während der Reparatur mit PATCH den Wert {"enabled": false}.

Auch balance.low und webhook.endpoint.disabled sind noch nicht verfügbar und fehlen deshalb in der Tabelle.

Erneute Aktivierung leert Fehlerzähler, damit ein reparierter Endpunkt keine alten Fehler in das nächste Problem übernimmt.

Endpunkt löschen

Löschen stoppt zukünftige Zustellungen. Bereits wartende Nachrichten behalten ihren Verlauf, sodass Sie ihren Ausgang sehen. Ohne Endpunkt lassen sie sich jedoch nicht mehr signieren. Sie enden als fehlgeschlagen, statt aus dem Zustellungslog zu verschwinden.

Welche Antwort senden?

Jedes 2xx bestätigt Empfang. Andere Statuswerte, Verbindungsfehler oder mehr als 15 Sekunden gelten als Fehler und lösen Wiederholung aus.

Bestätigen Sie vor der eigentlichen Verarbeitung: Stellen Sie die Task-ID in Ihre eigene Warteschlange und antworten Sie. Download, Transkodierung und Benachrichtigung während der Callbackanfrage verursachen Timeout und Neuzustellung, obwohl Sie bereits teilweise verarbeitet haben.

Wiederholungsplan

WiederholungAbstand zum vorherigen Versuch
110 Sekunden
230 Sekunden
32 Minuten
410 Minuten
530 Minuten
62 Stunden
76 Stunden

Einschließlich erster Zustellung gibt es acht automatische Versuche. Die geplanten Abstände summieren sich auf 8 Stunden, 42 Minuten und 40 Sekunden; Anfragedauer und Planung können die verstrichene Zeit verlängern. Nach Ausschöpfen stoppen automatische Wiederholungen. Die Zustellung erscheint unter Konsole → Webhooks → Webhook-Zustellungen als fehlgeschlagen. Erneut versuchen stellt sie dort nochmals ein, bis zu 6 manuelle Versuche je Minute. Noch automatisch wiederholte, bereits zugestellte oder nach Aufbewahrung gelöschte Zustellungen lassen sich nicht manuell neu senden; nutzen Sie dafür recordInfo.

Jede Wiederholung wird neu signiert

Der Zeitstempel stammt vom Moment dieser Zustellung, nicht vom ersten Versuch. Sonst käme eine sechs Stunden spätere Wiederholung mit sechs Stunden altem Zeitstempel an und würde von jedem korrekten Wiederholungsfenster abgelehnt. Der Mechanismus würde gerade im benötigten Moment scheitern.

Für Sie bedeutet das: Zeitstempel verschiedener Zustellungen nicht zwischenspeichern oder vergleichen. Prüfen Sie jeden gegen die aktuelle Zeit.

Idempotenz

Ein Callback für denselben Task kann mehrfach ankommen, durch Wiederholungen, Netzwerkduplikate oder manuelle Neuzustellung. Nehmen Sie niemals genau einmalige Zustellung an.

Deduplizieren Sie v2 mit request_id der Hülle, etwa whd_…. Es kennzeichnet den Zustellungsdatensatz und bleibt über Wiederholungen gleich, wie ein Idempotenzschlüssel. Am einfachsten ist ein eindeutiger Datenbankindex darauf. Bei Konflikt liefern Sie 2xx und fahren fort.

v1 hat kein request_id. Leiten Sie dort einen Idempotenzschlüssel aus task_id, Zustand und Hash des unveränderten Bodys ab. Verwenden Sie nicht allein die Task-ID; sonst könnte ein zukünftiger eigener Endzustandsdatensatz desselben Tasks unterdrückt werden.

Beschränkungen der Callback-URL

  • Nur https. Einfaches http:// wird abgelehnt, weil der Body Prompt und signierte Ergebnislinks enthält.
  • Kein ausdrücklich gesetzter Port außer 443 oder 80; Standardport einer https://-URL ist zulässig.
  • Kein Benutzername oder Passwort in der URL.
  • Hostname muss auflösbar sein und jede aufgelöste Adresse muss öffentlich sein. Loopback, private Netze, linklokale und Cloud-Metadaten-Adressen werden abgelehnt.
  • Die aufgelöste IP wird auch beim Verbindungsaufbau geprüft. Das verhindert DNS-Rebinding.

Ungültiges callBackUrl bei Erstellung, Streaming oder Preisabfrage liefert HTTP 400 mit Geschäftscode code: 400, msg: "Invalid callback URL" und request_id. Die Antwort gibt weder gesendete URL noch Hostname, IP oder DNS-Diagnosen wieder.

Beide Prüfungen verhindern, dass unsere Server als Sprungpunkt in private Netze verwendet werden. Deshalb funktioniert http://localhost:3000 bei lokaler Entwicklung nicht. Verwenden Sie einen Tunnel wie ngrok oder Cloudflare Tunnel für öffentliches HTTPS oder pollen Sie lokal.

Auf dieser Seite