spicyapiDocumentation
Développer avec l’API
Contenu principal

Webhooks

Distinguer les charges utiles v1 et v2, choisir le bon identifiant de tâche et vérifier les signatures avec des exemples Node.js, Python et curl.

Un webhook, ou callback, est une requête que nous envoyons à votre serveur lorsqu’une tâche se termine, pour vous éviter de la consulter sans cesse. Fournissez callBackUrl à createTask : nous envoyons un POST lorsque la tâche atteint un état final. Pour une tâche réussie, nous attendons que ses fichiers soient téléchargeables ; le callback contient donc des liens utilisables. Les livraisons échouées suivent le calendrier de nouvelles tentatives ci-dessous.

Les callbacks sont envoyés uniquement pour les états finaux. Aucune notification ne signale le passage de queued à running.

Avant de commencer

  • Votre serveur doit avoir une adresse HTTPS publique. Nos serveurs doivent pouvoir atteindre l’URL sur Internet et elle doit commencer par https://. http://localhost et les adresses de votre réseau domestique ou professionnel ne fonctionnent pas. En développement local, utilisez le polling avec recordInfo, ou un tunnel, comme ngrok ou Cloudflare Tunnel, qui fournit une adresse HTTPS publique temporaire à votre serveur. Les règles complètes figurent dans Contraintes de l’URL de callback.
  • Deux modes de réception coexistent. Ajouter callBackUrl à une tâche est le plus rapide ; enregistrer un point de terminaison sur le compte couvre toutes les tâches sans répéter l’URL. Voir Quel mode choisir.
  • Aucun bouton « Envoyer un événement de test » n’est disponible. Testez d’abord votre vérification de signature avec le script curl ci-dessous, puis créez une tâche réelle peu coûteuse avec callBackUrl, facturée comme toute autre tâche. Suivez Console → Webhooks → Livraisons de webhooks, où chaque livraison apparaît avec son statut.

Quel mode choisir

callBackUrl sur une tâchePoint de terminaison du compte
ConfigurationUn champ pour chaque createTaskUne seule fois via l’API ; aucune interface console pour le moment
Événements livrésÉtat final de cette tâcheTous les types d’événements auxquels vous souscrivez, pour toutes les tâches
En-têtesX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
CorpsEnveloppe {code, msg, data}, comme recordInfoObjet événement sans enveloppe, avec id, type et data.object
Secret de signatureUn par compte, dans Clé de signature des webhooksUn par point de terminaison, affiché une seule fois à sa création ou rotation
Rotation sans interruptionNon : l’ancien secret cesse immédiatement de vérifier les signaturesOui : les deux signatures sont envoyées pendant le délai de transition

Aucun mode ne remplace l’autre. Une tâche contenant callBackUrl et appartenant à un compte avec un abonnement correspondant produit deux livraisons, une dans chaque format. C’est une configuration normale.

Pour une nouvelle intégration sans préférence particulière, enregistrez un point de terminaison de compte : l’URL est écrite une fois, son secret peut être renouvelé sans bascule simultanée de tous les systèmes et la charge utile correspond au format déjà reconnu par la plupart des bibliothèques de webhooks.

Sauf indication contraire, la suite de cette page décrit la livraison callBackUrl. La section Points de terminaison du compte précise les différences.

Configurer d’abord un secret de signature

Chaque livraison est signée avec le secret de signature de votre compte, permettant à votre serveur de vérifier que la requête vient de SpicyAPI. Toutes les clés API du compte partagent ce secret.

SpicyAPI n’affiche le secret qu’une seule fois

Un secret est généralement déjà configuré sur les nouveaux comptes, mais sa valeur ne vous a jamais été montrée. Vous devez donc en créer un que vous pouvez récupérer :

  1. Ouvrez Console → Webhooks.
  2. Dans la carte Clé de signature des webhooks, choisissez Générer si elle indique Non définie, ou Renouveler si elle indique Configurée.
  3. Copiez immédiatement la valeur dans SPICY_WEBHOOK_SECRET sur votre serveur, puis choisissez Je l’ai enregistrée. Elle ne peut plus être affichée ; si vous la perdez, renouvelez-la de nouveau.

La rotation est immédiate : l’ancien secret cesse aussitôt de vérifier les signatures et les livraisons encore en cours de relance sont signées avec le nouveau. Actualisez tous vos récepteurs immédiatement après la rotation.

Sans secret, une livraison ne peut pas être signée : chaque tentative est considérée comme échouée et suit le calendrier de relance. Consultez recordInfo pour toute tâche dont le callback a été manqué pendant cette indisponibilité.

Commencer avec le vérificateur du SDK

Pour un nouveau récepteur v2, utilisez verifyWebhook depuis @spicyapi/sdk/webhooks. Il contrôle la signature du corps brut et la fenêtre d’horodatage de cinq minutes, puis renvoie taskId et deliveryId. Ce helper utilise le module crypto de Node.js : gardez-le côté serveur.

L’exemple utilise Express et PostgreSQL, avec npm install @spicyapi/sdk express pg. Configurez la base de données via les variables d’environnement PG* habituelles et donnez à SPICY_WEBHOOK_SECRET le secret obtenu dans la console. Exposez la route derrière HTTPS. Le corps contient l’intégralité de votre input, prompt compris ; cet exemple accepte donc jusqu’à 10 MB. Adaptez votre limite à votre usage. Créez une seule fois ces deux tables applicatives :

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

Enregistrez chaque taskId reçu lors de vos propres envois dans spicy_tasks. Le récepteur accepte uniquement les événements de ces tâches ; si un callback exceptionnellement rapide arrive avant l’insertion, sa réponse non-2xx permet une nouvelle livraison. Dans une application partagée, appliquez également votre règle d’appartenance des tâches au tenant.

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

Enregistrez cette route à corps brut avant un middleware global express.json(). Ne renvoyez 2xx qu’après validation de l’écriture dans la boîte de réception, puis laissez votre worker traiter l’événement enregistré en arrière-plan. La clé primaire rend les doublons sans effet. Vérifiez data.state et data.settled avant de considérer le résultat comme réussi ou le coût comme définitif. Si une URL de média enregistrée a expiré, consultez de nouveau la tâche pendant que le résultat est encore conservé.

Cet exemple de nouvelle intégration accepte la v2. Pour les anciens récepteurs v1, conservez la vérification tenant compte de la version ci-dessous ; le même helper du SDK accepte payloadVersion: 1, mais la v1 n’a pas de deliveryId.

La requête

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
En-têteRemarques
X-Webhook-TimestampSecondes Unix de cette livraison, utilisées contre le rejeu
X-Webhook-SignatureHMAC-SHA256 encodé en base64
X-Webhook-Payload-VersionVersion du format du corps. Les nouvelles intégrations utilisent 2 par défaut ; des livraisons historiques peuvent encore utiliser 1. Branchez toujours votre logique sur cet en-tête. Voir La charge utile
User-AgentToujours SpicyAPI-Webhook/1

Nous ne suivons pas les redirections : une réponse 3xx compte comme une livraison échouée. Le délai d’expiration est de 15 secondes.

La charge utile

La v2 correspond à recordInfo ; la v1 conserve l’ancien format plat

Un callback de version 2 a le même format que GET /jobs/recordInfo : enveloppe {code, msg, data, request_id} et mêmes champs camelCase dans data. La version 1 n’a pas d’enveloppe et place les champs snake_case de la tâche au premier niveau.

Construisez les nouvelles intégrations pour la v2, mais lisez X-Webhook-Payload-Version à l’entrée du vérificateur : l’identifiant est dans task_id au premier niveau pour la v1 et dans data.taskId pour la v2.

Les exemples ci-dessous utilisent MODEL_ID_FROM_CATALOG pour représenter l’identifiant exact sélectionné à l’origine dans le catalogue authentifié actuel. Ce n’est pas un identifiant à envoyer tel quel.

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

L’exemple ci-dessus est en v2. La référence des champs data se trouve dans Tâches asynchrones ; elle s’applique à recordInfo et aux callbacks v2 et n’est donc pas répétée ici.

Comme dans recordInfo, les fichiers prêts dans data.output.assets[] incluent url, expiresAt et les métadonnées. Téléchargez directement l’URL sans identifiants API ; aucun ticket de téléchargement n’est nécessaire. Les champs monétaires, comme cost, sont des chaînes décimales sans $.

Un callback par tâche, envoyé lorsque le résultat est prêt

Chaque tâche produit un événement de callback. Celui d’une tâche échouée est construit dès l’échec. Celui d’une tâche réussie est construit après copie des fichiers dans notre stockage, pour que chaque fichier possède normalement une url. Les relances renvoient cet événement identique ; seuls les liens temporaires des médias sont actualisés.

Un élément peut encore changer après l’envoi, sans déclencher un nouveau callback :

  • data.settled vaut false : les frais définitifs n’étaient pas encore déterminés. Consultez plus tard recordInfo pour obtenir le cost final.

Dans le cas rare où un fichier ne peut absolument pas être stocké, le callback arrive tout de même : ce fichier porte unavailable: true sans url. Si aucun fichier de la tâche n’a pu être stocké, celle-ci n’est pas facturée : cost vaut "0".

Un callback avec state: "succeeded", settled: true et une url pour chaque fichier est complet : vous pouvez l’utiliser sans autre consultation.

En v2, code vaut toujours 200, même pour une tâche échouée

N’utilisez jamais code pour déterminer la réussite d’une tâche

Le code de l’enveloppe indique si la livraison est correctement formée, pas si la génération a fonctionné. Le callback d’une tâche échouée contient aussi 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"
  }
}

Un code utilisant if (body.code === 200) markSucceeded() marque toutes les tâches échouées comme réussies, sans signe apparent de dysfonctionnement. La réussite ou l’échec se lit dans data.state ; le motif, dans data.errorCode et data.errorMessage. Un callback n’a aucun en-tête de requête permettant de choisir une langue : errorMessage suit donc le réglage du compte et utilise l’anglais par défaut. Voir Langue des messages d’erreur.

Cela correspond à recordInfo, qui répond aussi 200 pour une tâche échouée. Cette cohérence est volontaire : deux interprétations différentes recréeraient le problème « mêmes données, deux façons de les lire » que ce format vise à résoudre.

En v2, request_id identifie la livraison : utilisez-le comme clé d’idempotence

Le request_id de l’enveloppe ressemble à whd_…. Il identifie cet enregistrement de livraison, pas la tâche :

  • Il ne change pas lors des relances. Les relances automatiques et manuelles réutilisent le même événement métier et le même request_id. Seuls les champs temporaires url et expiresAt des médias sont actualisés avant chaque envoi ; ne dédupliquez pas la v2 à partir d’un hash du corps.
  • Pour savoir si vous avez déjà traité ce callback, créez simplement un index unique dans votre base et renvoyez 2xx en cas de conflit.
  • Le même emplacement dans une réponse recordInfo contient l’identifiant de requête HTTP, req_…. Dans les deux cas, il identifie cette livraison précise.

En v2, la tâche figure dans data.taskId : cela répond à « quelle tâche ? ». request_id répond à « quelle livraison ? ». La v1 n’a pas de request_id ; dédupliquez-la avec une clé dérivée de task_id, de l’état et de l’empreinte du corps.

L’en-tête de version correspond aux octets envoyés

X-Webhook-Payload-Version indique la version utilisée pour construire ce corps, pas la version actuellement configurée.

La charge utile métier et sa version sont figées lors de la mise en file. Une relance actualise uniquement les URL temporaires et signe le corps exact envoyé ; elle ne remplace jamais le contenu par l’état actuel de la tâche. L’en-tête doit suivre ce corps : autrement, une relance restée en file pendant plusieurs heures arriverait avec une version contredisant son propre contenu, alors que votre logique s’appuie sur cet en-tête.

La v1 est une couche de compatibilité figée ; ne construisez pas dessus

La version est un réglage de compatibilité global géré par SpicyAPI ; vous ne pouvez pas la choisir et les nouvelles livraisons utilisent la version 2. Ce réglage peut maintenir le format historique 1 : snake_case, aucune enveloppe, champs plats au premier niveau et absence de input, settled et completedAt. Il existe pour éviter de casser les anciennes intégrations lors de l’arrivée du nouveau format.

Aucun nouveau champ n’est ajouté à la v1. Toutes les évolutions concernent uniquement la v2. Après confirmation qu’aucune intégration n’utilise plus la v1, elle sera supprimée avec ce réglage.

Construisez les nouvelles intégrations pour la v2. Des livraisons historiques peuvent encore être relancées en v1 : ne déduisez donc pas la version de la structure JSON. Lisez X-Webhook-Payload-Version et adaptez à la fois la vérification de signature et le parsing, comme ci-dessous.

La signature

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

Trois parties reliées par . :

  1. L’identifiant de tâche, choisi selon X-Webhook-Payload-Version : task_id au premier niveau en v1, data.taskId en v2, jamais request_id.
  2. La valeur de X-Webhook-Timestamp.
  3. L’empreinte SHA-256 du corps brut de la requête, en hexadécimal minuscule.

La signature doit inclure l’empreinte du corps, et le vérificateur doit réellement la calculer

Si vous signez uniquement l’identifiant de tâche et l’horodatage, une personne ayant intercepté une livraison légitime peut associer ces deux valeurs à son propre corps et rejouer la requête sur votre point de terminaison. Votre vérification selon l’algorithme publié accepterait la signature.

Cela reviendrait à valider un faux message « tâche terminée, voici le fichier », à partir duquel vous pourriez facturer un client ou livrer un résultat. L’empreinte n’est pas facultative.

Calculez l’empreinte sur les octets bruts

Ne désérialisez pas puis ne resérialisez pas le corps avant le calcul. L’ordre des clés, les espaces et l’échappement Unicode diffèrent entre bibliothèques JSON ; les octets obtenus ne seront presque jamais ceux signés, et la vérification échouera.

Pour accéder au corps brut : express.raw() dans Express, rawBody dans Fastify, request.get_data() dans Flask, request.body dans Django et await request.body() dans FastAPI.

Code de vérification

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)
}

Tester votre vérificateur avec curl

Enregistrez l’exemple v2 réussi de La charge utile dans payload.json, configurez SPICY_WEBHOOK_SECRET avec le même secret que votre récepteur et remplacez l’URL finale par votre point de terminaison. Lancez ensuite :

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"

Résultat attendu : votre point de terminaison renvoie un statut 2xx. Modifiez ensuite un caractère de SPICY_WEBHOOK_SECRET et relancez : il doit renvoyer 401. S’il renvoie encore 2xx, le récepteur ne contrôle pas les signatures. Le récepteur SDK ci-dessus renvoie aussi 404 pour un identifiant absent de spicy_tasks ; insérez donc d’abord le taskId d’exemple ou testez le code simple de vérification.

Points de terminaison du compte

Un point de terminaison est une URL enregistrée sur votre compte, avec la liste des événements souhaités. Tous les événements correspondants lui sont ensuite envoyés ; vous n’avez plus besoin de fournir une URL sur chaque tâche.

Enregistrer un point de terminaison

Via l’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"
  }'

Aucune interface console pour cette configuration pour le moment

Les points de terminaison sont actuellement configurables uniquement par API. Console → Webhooks gère le secret de signature du compte utilisé par callBackUrl et liste les livraisons, sans page d’enregistrement des points de terminaison. Ces opérations utilisent les quatre appels présentés ici, avec un cookie de session console : le même identifiant de connexion que votre navigateur, pas une clé sk-spicy-.

La réponse contient secret, au format whsec_…. Il est affiché une seule fois. Aucun appel ultérieur ne le renvoie, ni la liste ni les détails. Si vous le perdez, la seule option est de le renouveler.

Un compte peut enregistrer 16 points de terminaison. L’URL doit être une adresse HTTPS publique, vérifiée à l’enregistrement, puis à la livraison selon l’adresse IP réellement résolue.

Événements disponibles

ÉvénementDéclenchement
task.completedRegroupe les quatre événements ci-dessous. Choisissez-le sauf si vous avez besoin de les distinguer
task.succeededUne tâche est terminée
task.failedUne tâche a échoué ; la réservation est normalement libérée. Un refus de contenu peut être facturé lorsque la page du modèle indique explicitement cette exception
task.canceledConservé pour l’historique ; une tâche acceptée aujourd’hui ne peut pas être annulée
task.expiredUne tâche a dépassé son échéance ; toute réservation du solde est libérée

GET /console/v1/webhook-events renvoie cette liste à l’exécution, pour construire un sélecteur sans la coder en dur.

Choisissez task.completed plutôt que trois événements sur quatre

Énumérer succeeded, failed et canceled en oubliant expired est une erreur fréquente : votre code attend alors certaines tâches sans fin. task.completed évite cette omission.

Les noms d’événements inconnus sont refusés à l’enregistrement, pas ignorés silencieusement. Une faute doit être détectée à la configuration, pas six semaines plus tard quand un type d’événement n’est jamais arrivé. Les jokers, comme "*", sont refusés pour la même raison.

La livraison

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 contient le même enregistrement que recordInfo, mais sous forme d’instantané pris à la création de l’événement, pas d’état actuel. Même si vous traitez la livraison trois jours plus tard, vous lisez les valeurs de cet instant.

settled indique si cost est définitif

Lors d’un règlement différé, la tâche atteint un état final avant la clôture de la facturation : l’événement contient alors settled: false et cost conserve l’estimation. Aucun événement correctif ne suit. Si le montant exact vous importe, consultez recordInfo lorsque settled vaut false.

Vérifier la signature

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

Ce format suit la spécification Standard Webhooks : une bibliothèque conçue pour Stripe ou Replicate peut fonctionner en changeant seulement les noms d’en-têtes. Deux différences par rapport à la signature callBackUrl ci-dessus empêchent silencieusement toute vérification si vous les interprétez mal :

  1. Le corps lui-même est signé, pas son empreinte. Il n’y a aucune étape sha256 interne ici.
  2. La clé HMAC correspond aux octets obtenus en décodant en base64 la partie suivant whsec_, pas à cette chaîne. Utiliser la chaîne est l’erreur la plus courante : aucune erreur n’est levée, mais la signature est toujours incorrecte.
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 reste identique lors des relances d’un message : utilisez-le comme clé d’idempotence. webhook-timestamp est actualisé à chaque tentative, car il protège contre le rejeu, pas contre les doublons.

Renouveler un secret sans interruption

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

Pendant le délai de transition, chaque livraison comporte deux signatures séparées par un espace dans le même en-tête ; il suffit que l’une soit valide. C’est le rôle de la boucle .some(…) ci-dessus. L’ordre sûr est donc : déployer un récepteur acceptant les deux → renouveler → installer le nouveau secret → laisser la fenêtre se fermer. Aucune interruption n’est nécessaire.

Le délai par défaut est de 24 heures, avec un maximum de 7 jours. Omettez entièrement le corps pour utiliser la valeur par défaut.

Lorsqu’un point de terminaison ne répond plus correctement

Les livraisons sont relancées selon le calendrier ci-dessous. En complément :

  • Une réponse 410 Gone désactive immédiatement le point de terminaison et abandonne ses messages en file. Réessayer une adresse dont le propriétaire indique qu’elle n’existe plus est inutile. GET /console/v1/webhook-endpoints/{id} renvoie alors status: "disabled" avec disabledReason: "gone_410".
  • Tout autre échec incrémente consecutiveFailures, également renvoyé par cet appel. Aucune désactivation automatique n’est appliquée actuellement.

Un point de terminaison en panne reste abonné

La désactivation automatique après plusieurs jours d’échecs continus n’est pas encore implémentée. Un point de terminaison indisponible, mais répondant autre chose que 410, continue à recevoir chaque événement et à consommer toutes ses relances. Surveillez vous-même consecutiveFailures et utilisez PATCH avec {"enabled": false} pendant la réparation.

balance.low et webhook.endpoint.disabled ne sont pas non plus disponibles actuellement, ce qui explique leur absence du tableau.

Réactiver un point de terminaison remet ses compteurs d’échec à zéro : une configuration réparée ne conserve pas les anciens échecs lors du prochain incident.

Supprimer un point de terminaison

La suppression arrête les futures livraisons. Les messages déjà en file gardent leur historique, pour que vous puissiez voir leur résultat final ; sans le point de terminaison, ils ne peuvent toutefois plus être signés et finissent en échec, au lieu de disparaître du journal de livraison.

Quelle réponse renvoyer

Tout statut 2xx signifie reçu. Tout autre statut, échec de connexion ou dépassement de 15 secondes compte comme un échec et déclenche une relance.

Accusez réception avant de traiter : placez l’identifiant de tâche dans votre propre file et répondez. Télécharger, transcoder ou notifier pendant la requête de callback dépasse simplement le délai de livraison et provoque un nouvel envoi, alors que votre application a déjà commencé le traitement.

Calendrier des relances

RelanceDélai après la tentative précédente
110 secondes
230 secondes
32 minutes
410 minutes
530 minutes
62 heures
76 heures

Cela représente huit tentatives automatiques, en comptant la première livraison. Les délais prévus totalisent 8 heures, 42 minutes et 40 secondes ; la durée des requêtes et la planification peuvent allonger le temps écoulé. Après épuisement des relances, les tentatives automatiques s’arrêtent et la livraison apparaît comme échouée dans Console → Webhooks → Livraisons de webhooks. Réessayer la remet une fois en file, dans la limite de 6 relances manuelles par minute. Vous ne pouvez pas renvoyer manuellement une livraison encore en relance automatique, déjà livrée ou dont le contenu a été supprimé selon vos réglages de conservation : utilisez recordInfo dans ces cas.

Chaque relance reçoit une nouvelle signature

L’horodatage correspond au moment de cette livraison, pas à la première tentative. Sinon, une relance envoyée six heures plus tard porterait un horodatage vieux de six heures, refusé par toute fenêtre de protection contre le rejeu correctement implémentée ; la relance échouerait précisément lorsque vous en avez besoin.

En pratique, ne mettez pas les horodatages en cache et ne les comparez pas entre livraisons. Vérifiez chacun par rapport à l’heure actuelle.

Idempotence

Un callback d’une même tâche peut arriver plusieurs fois : relances, doublons réseau et renvoi manuel. Ne supposez jamais une livraison unique.

En v2, dédupliquez sur le request_id de l’enveloppe, au format whd_…. Il identifie l’enregistrement de livraison et reste identique pendant les relances, exactement ce qu’il faut pour une clé d’idempotence. La mise en œuvre la plus simple est un index unique dans votre base ; en cas de conflit, renvoyez 2xx et passez à la suite.

La v1 n’a pas de request_id. Si vous la recevez encore, dérivez une clé d’idempotence de task_id, de l’état et de l’empreinte du corps brut. Ne dédupliquez pas uniquement sur l’identifiant de tâche : cela pourrait supprimer un futur enregistrement distinct d’état final pour la même tâche.

Contraintes de l’URL de callback

  • Uniquement https. Les URL http:// sont refusées, car le corps contient votre prompt et des liens signés de résultats.
  • Aucun port explicite autre que 443 ou 80 ; le port par défaut d’une URL https:// convient.
  • Aucun nom d’utilisateur ni mot de passe dans l’URL.
  • Le nom d’hôte doit être résolu et toutes les adresses obtenues doivent être publiques. Les adresses de boucle locale, réseaux privés, liens locaux et services de métadonnées cloud sont refusées.
  • Nous vérifions aussi l’IP résolue à la connexion, afin de fermer la possibilité de DNS rebinding.

Une valeur callBackUrl invalide lors de la création, du streaming ou d’un devis renvoie HTTP 400, avec code: 400, msg: "Invalid callback URL" et un request_id. La réponse ne reprend ni l’URL envoyée, ni le nom d’hôte, ni l’IP, ni les détails DNS.

Ces deux contrôles empêchent l’utilisation de nos serveurs comme relais vers un réseau privé. En conséquence, http://localhost:3000 ne fonctionne pas en développement local : utilisez un tunnel, comme ngrok ou Cloudflare Tunnel, pour obtenir une adresse HTTPS publique, ou faites du polling pendant le développement.

Sur cette page