spicyapiDocumentazione
Sviluppare con l’API
Contenuto principale

Webhook

Distingui i payload webhook v1 e v2, scegli l'ID dell'attività corretto e verifica le firme con esempi funzionanti in Node.js, Python e curl.

Un webhook, o callback, è una richiesta che noi inviamo al tuo server quando un'attività termina, così non devi continuare a interrogarla. Passa callBackUrl a createTask e inviamo una richiesta POST quando l'attività raggiunge uno stato terminale. Per un'attività riuscita aspettiamo che i file del risultato siano pronti da scaricare, così la callback contiene link funzionanti. Le consegne fallite seguono il programma di nuovi tentativi descritto sotto.

Le callback vengono inviate soltanto per gli stati terminali. Non c'è una notifica per il passaggio queued → running.

Prima di iniziare

  • Il server deve avere un indirizzo HTTPS pubblico. I nostri server devono poter raggiungere l'URL su internet, e deve iniziare con https://. http://localhost e gli indirizzi nella rete dell'ufficio o di casa non funzionano. Durante lo sviluppo sul tuo computer, usa il polling con recordInfo oppure un tunnel, come ngrok o Cloudflare Tunnel, che dia al server locale un indirizzo HTTPS pubblico temporaneo. Le regole complete sono in Vincoli dell'URL di callback.
  • Ci sono due modi per ricevere gli eventi, e possono coesistere. Passare callBackUrl per una singola attività è il più rapido; registrare un endpoint sull'account copre tutte le attività senza ripetere l'URL. Consulta Quale usare.
  • Non c'è un pulsante «Invia evento di prova». Prova prima la verifica della firma con lo script curl sotto, poi crea una vera attività economica con callBackUrl, addebitata come qualsiasi altra, e controlla Console → Webhook → Consegne dei webhook, dove ogni consegna compare con il suo stato.

Quale usare

callBackUrl su un'attivitàEndpoint a livello di account
Dove si impostaUn campo su ogni createTaskUna volta tramite API, senza ancora una schermata nella console
Che cosa consegnaStato terminale di quella singola attivitàTutti i tipi di evento a cui ti iscrivi, per ogni attività
HeaderX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
CorpoStruttura {code, msg, data}, come recordInfoOggetto evento senza involucro, con id, type e data.object
Segreto di firmaUno per account, in Chiave di firma webhookUno per endpoint, mostrato una volta alla creazione o alla rotazione
Rotazione senza interruzioniNo: il vecchio segreto smette immediatamente di verificare le firmeSì: durante il periodo di transizione vengono inviate entrambe le firme

Nessuno dei due sostituisce l'altro. Un'attività che include callBackUrl e appartiene a un account con una sottoscrizione corrispondente produce due consegne, una in ciascuna struttura. È una configurazione normale, non un errore.

Se stai integrando oggi e non hai un motivo specifico per preferire l'uno o l'altro, registra un endpoint a livello di account: scrivi l'URL una volta, puoi ruotare il segreto senza un cambio simultaneo obbligatorio e il payload ha la struttura già compresa dalla maggior parte delle librerie webhook.

Il resto della pagina descrive la consegna callBackUrl, salvo indicazione diversa. Endpoint a livello di account spiega le differenze.

Imposta prima un segreto di firma

Ogni consegna viene firmata con il segreto di firma del tuo account, così il server può verificare che la richiesta provenga da SpicyAPI. Un solo segreto è condiviso da tutte le chiavi API dell'account.

SpicyAPI mostra il segreto una sola volta

Gli account normalmente hanno già un segreto configurato, ma il valore non ti è mai stato mostrato, quindi devi crearne uno che puoi vedere:

  1. Apri Console → Webhook.
  2. Nella scheda Chiave di firma webhook, fai clic su Genera se indica Non impostata, oppure su Ruota se indica Configurata.
  3. Copia subito il valore in SPICY_WEBHOOK_SECRET sul server, poi fai clic su L'ho salvato. Non può essere mostrato di nuovo; se lo perdi, devi ruotarlo ancora.

La rotazione è immediata: il vecchio segreto smette subito di verificare le firme e le consegne ancora in fase di retry vengono firmate con quello nuovo. Aggiorna tutti i destinatari subito dopo la rotazione.

Senza segreto una consegna non può essere firmata: ogni tentativo conta come fallito e segue il programma di retry. Interroga recordInfo per le attività di cui hai perso la callback mentre il segreto non era disponibile.

Parti dal verificatore dell'SDK

Per un nuovo ricevitore v2, usa verifyWebhook da @spicyapi/sdk/webhooks. Verifica la firma del corpo grezzo e la finestra di cinque minuti del timestamp e restituisce taskId e deliveryId. Questo helper usa la crittografia di Node.js; tienilo sul server.

L'esempio seguente usa Express e PostgreSQL (npm install @spicyapi/sdk express pg). Configura il database tramite le consuete variabili d'ambiente PG* e imposta SPICY_WEBHOOK_SECRET al segreto di firma della console. Esponi la route dietro HTTPS. Il corpo contiene l'intero input, incluso il prompt, quindi l'esempio consente fino a 10 MB; adegua il tuo limite. Crea queste due tabelle applicative una sola volta:

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

Salva ogni taskId restituito dalle attività inviate da te in spicy_tasks. Il ricevitore accetta eventi soltanto per quelle attività; se una callback particolarmente rapida arriva prima dell'inserimento, la risposta non-2xx consente un nuovo tentativo di consegna. In un'applicazione condivisa, applica anche la regola di appartenenza delle attività al 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");

Registra questa route per il corpo grezzo prima di un middleware globale express.json(). Restituisci 2xx soltanto dopo il commit della scrittura nella tabella di ricezione; poi lascia che il worker in background elabori l'evento salvato. La chiave primaria rende le consegne duplicate prive di effetti. Verifica data.state e data.settled prima di considerare riuscito un risultato o definitivo il costo. Se un URL multimediale salvato è scaduto, interroga di nuovo l'attività mentre il risultato è ancora conservato.

Questo esempio per nuove integrazioni accetta v2. Per i ricevitori storici v1, mantieni la verifica basata sulla versione descritta sotto; lo stesso helper dell'SDK supporta payloadVersion: 1, ma v1 non ha deliveryId.

La richiesta

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
HeaderNote
X-Webhook-TimestampSecondi Unix di questa consegna. Usati contro la riproduzione delle richieste
X-Webhook-SignatureHMAC-SHA256 codificato in base64
X-Webhook-Payload-VersionVersione della struttura del corpo. Le nuove integrazioni usano 2 per impostazione predefinita; le consegne storiche possono ancora usare 1. Gestisci sempre la versione tramite questo header. Consulta Il payload
User-AgentSempre SpicyAPI-Webhook/1

Non seguiamo i reindirizzamenti: un 3xx dal tuo endpoint conta come consegna fallita. Il timeout è di 15 secondi.

Il payload

v2 corrisponde a recordInfo; v1 è la vecchia struttura piatta

Una callback versione 2 ha la stessa struttura di GET /jobs/recordInfo: l'involucro {code, msg, data, request_id} e gli stessi campi camelCase dentro data. La versione 1 non ha involucro e colloca i campi snake_case dell'attività al primo livello.

Costruisci le nuove integrazioni per v2, ma fai leggere X-Webhook-Payload-Version al punto di ingresso della verifica: prendi l'ID dal campo di primo livello task_id per v1 e da data.taskId per v2.

Gli esempi sotto usano MODEL_ID_FROM_CATALOG per rappresentare l'ID esatto originariamente selezionato dal catalogo autenticato corrente. Non è un ID letterale.

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'esempio sopra è v2. Il riferimento dei campi di data si trova in Attività asincrone; si applica sia a recordInfo sia alle callback v2, quindi non viene ripetuto qui.

Come in recordInfo, i file pronti di data.output.assets[] includono url, expiresAt e metadati multimediali. Recupera direttamente l'URL senza credenziali API; non serve richiedere un ticket di download. I campi monetari come cost sono stringhe decimali senza $.

Una callback per attività, inviata quando è pronta

Ogni attività riceve un evento di callback. L'evento di un'attività fallita viene costruito al momento del fallimento. Quello di un'attività riuscita viene costruito dopo la copia dei file nella nostra archiviazione, così ogni file normalmente ha url. I retry reinviano lo stesso evento; vengono aggiornati soltanto i link multimediali temporanei.

Un elemento può ancora cambiare in seguito, senza provocare un'altra callback:

  • data.settled è false: l'addebito finale non era ancora stato determinato. Leggi recordInfo più tardi per il cost finale.

Nel raro caso in cui un file non possa essere archiviato affatto, la callback arriva comunque: quel file ha unavailable: true e non ha url. Se nessun file dell'attività ha potuto essere archiviato, l'attività non viene addebitata: cost è "0".

Una callback con state: "succeeded", settled: true e un url per ogni file è completa: puoi usarla senza un'altra interrogazione.

In v2, code è sempre 200, anche per attività fallite

Non usare mai code per decidere se un'attività è riuscita

Il code della risposta descrive se questa consegna è strutturalmente valida, non se la generazione ha funzionato. Anche una callback per un'attività fallita contiene 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"
  }
}

Il codice che legge if (body.code === 200) markSucceeded() segna ogni attività fallita come riuscita, senza mostrare mai segni evidenti di malfunzionamento. Successo o fallimento si trovano in data.state; il motivo in data.errorCode e data.errorMessage. Una callback non ha un header di richiesta da cui ricavare la lingua, quindi errorMessage segue l'impostazione linguistica dell'account e usa l'inglese come valore predefinito; consulta Lingua dei messaggi di errore.

Questo corrisponde a recordInfo, che risponde anch'esso 200 quando l'attività descritta è fallita. I due canali sono intenzionalmente coerenti: una differenza ricreerebbe proprio il problema «stessi dati, due modi di leggerli» che questa struttura elimina.

In v2, request_id è l'ID della consegna: usalo come chiave di idempotenza

Il request_id della risposta ha un formato come whd_…. Identifica questo record di consegna, non l'attività:

  • Non cambia tra i retry. I nuovi tentativi automatici e i reinvii manuali riutilizzano lo stesso evento applicativo e request_id. Prima di ogni invio vengono aggiornati soltanto i campi temporanei url ed expiresAt; non deduplicare v2 tramite un hash del corpo.
  • Verificare «ho già gestito questa callback?» diventa un'operazione semplice: crea un indice univoco nel tuo database e restituisci 2xx in caso di conflitto.
  • Lo stesso campo in una risposta di recordInfo contiene l'ID della richiesta HTTP, req_…. Il significato è lo stesso in entrambi i casi: identificatore di questa singola consegna.

In v2 l'attività si trova in data.taskId, che risponde a «quale attività». request_id risponde a «quale consegna». v1 non ha request_id; deduplicala con una chiave derivata da task_id, stato e digest del corpo.

L'header di versione segue i byte

X-Webhook-Payload-Version indica la versione con cui è stato costruito questo corpo, non la versione attualmente configurata.

Il payload applicativo e la versione vengono fissati all'accodamento. Un retry aggiorna soltanto gli URL multimediali temporanei e firma l'esatto corpo che invia; non lo sostituisce mai con lo stato più recente dell'attività. L'header deve accompagnarli: altrimenti un retry rimasto in coda per ore arriverebbe con un numero di versione incompatibile con il proprio contenuto, proprio l'header da cui dipende la gestione nel tuo codice.

v1 è un livello di compatibilità congelato: non usarlo per nuove integrazioni

La versione del payload è un'impostazione di compatibilità dell'intera piattaforma gestita da SpicyAPI; non puoi sceglierla e le nuove consegne usano la versione 2. Tale impostazione può fissare il payload a 1, la vecchia struttura: snake_case, senza involucro, campi piatti al primo livello e assenza di input, settled e completedAt. Esiste per un motivo: evitare che le integrazioni costruite sulla vecchia struttura si interrompano all'arrivo della nuova.

v1 non riceve nuovi campi. Da ora in poi le aggiunte riguardano soltanto v2. Quando avremo confermato che nessuno usa più v1, verrà rimosso insieme all'impostazione.

Costruisci le nuove integrazioni su v2. Le consegne storiche possono ancora essere ritentate come v1, quindi non dedurre la versione dalla struttura JSON. Leggi X-Webhook-Payload-Version e gestisci entrambe le versioni come mostrato sotto, sia per la verifica della firma sia per l'interpretazione dei dati.

La firma

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

Tre parti unite da .:

  1. ID dell'attività, scelto in base a X-Webhook-Payload-Version: task_id al primo livello per v1, data.taskId per v2, mai request_id
  2. Valore di X-Webhook-Timestamp
  3. Digest SHA-256 del corpo grezzo della richiesta, in esadecimale minuscolo

Il digest del corpo deve essere incluso nella firma, e il verificatore deve calcolarlo davvero

Se firmi soltanto ID dell'attività e timestamp, chiunque abbia intercettato una consegna legittima può associare quei due valori a un corpo di propria scelta e reinviarlo al tuo endpoint. La verifica secondo l'algoritmo pubblicato approverebbe la firma.

Questo equivarrebbe ad avallare un falso «attività completata, file disponibile qui», su cui agiresti addebitando un cliente o consegnando un risultato. Il digest non è facoltativo.

Calcola il digest dei byte grezzi

Non deserializzare e poi serializzare nuovamente. Ordine delle chiavi, spazi ed escape Unicode differiscono tra librerie JSON; i byte ottenuti quasi certamente non sarebbero quelli firmati e la verifica fallirebbe sempre.

Come accedere al corpo grezzo: express.raw() in Express, rawBody in Fastify, request.get_data() in Flask, request.body in Django, await request.body() in FastAPI.

Codice di verifica

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

Prova il verificatore con curl

Salva l'esempio v2 succeeded da Il payload come payload.json, imposta SPICY_WEBHOOK_SECRET allo stesso segreto usato dal ricevitore e cambia l'URL in fondo con il tuo endpoint. Poi esegui:

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"

Risultato atteso: l'endpoint restituisce uno stato 2xx. Ora cambia un carattere di SPICY_WEBHOOK_SECRET ed esegui di nuovo: deve restituire 401. Se restituisce ancora 2xx, il ricevitore non sta controllando le firme. Il ricevitore SDK sopra risponde anche 404 per un ID assente in spicy_tasks; inserisci prima lì il taskId dell'esempio oppure prova con il semplice codice di verifica.

Endpoint a livello di account

Un endpoint è un URL registrato sull'account insieme all'elenco degli eventi desiderati. Una volta creato, riceve ogni evento corrispondente: non devi più passare un URL per ogni attività.

Registrarne uno

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

Non c'è ancora una schermata della console dedicata

Attualmente gli endpoint sono gestibili soltanto tramite API. Console → Webhook gestisce il segreto di firma dell'account usato da callBackUrl ed elenca le consegne; non ha una pagina per registrare endpoint. Tutte le operazioni di questa sezione vengono eseguite con le quattro chiamate mostrate qui e richiedono un cookie di sessione della console, la stessa credenziale del browser, non una chiave sk-spicy-.

La risposta contiene secret, con un formato come whsec_…. Viene mostrato una sola volta. Nessuna chiamata successiva lo restituisce, né l'elenco né i dettagli. Se lo perdi, puoi soltanto ruotarlo.

Un account può avere 16 endpoint. L'URL deve essere un indirizzo HTTPS pubblico, verificato al salvataggio e di nuovo alla consegna rispetto all'indirizzo in cui effettivamente si risolve.

Eventi disponibili per la sottoscrizione

EventoQuando viene emesso
task.completedForma abbreviata per tutti e quattro gli eventi sotto. Iscriviti a questo, salvo che ti serva davvero distinguerli
task.succeededUn'attività è terminata
task.failedUn'attività è fallita; normalmente la riserva viene liberata. I rifiuti di contenuto possono essere addebitati quando la pagina del modello dichiara espressamente questa eccezione
task.canceledConservato per i record storici: le attività accettate oggi non possono essere annullate
task.expiredUn'attività ha superato la scadenza; l'eventuale riserva sul saldo viene liberata

GET /console/v1/webhook-events restituisce questo elenco a runtime, così puoi costruire un selettore senza fissarlo nel codice.

Iscriviti a task.completed invece di elencare tre eventi su quattro

Elencare succeeded, failed e canceled dimenticando expired è l'errore comune, e il sintomo è un'attività che il codice aspetta per sempre. task.completed evita questa lacuna.

I nomi di evento sconosciuti vengono rifiutati al salvataggio, non ignorati in silenzio: un refuso deve fallire alla registrazione, non sei settimane dopo, quando scopri che un tipo di evento non è mai arrivato. Per lo stesso motivo non sono accettati caratteri jolly ("*").

La consegna

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 è lo stesso record di attività restituito da recordInfo ed è una fotografia al momento della creazione dell'evento, non lo stato attuale. Se elabori una consegna con tre giorni di ritardo, leggi comunque i valori di quel momento.

settled indica se cost è definitivo

Nel percorso di regolamento ritardato, un'attività raggiunge lo stato terminale prima della conclusione della fatturazione, quindi l'evento viene inviato con settled: false e cost ancora uguale alla stima. Non segue un evento di correzione. Se ti serve l'importo esatto, leggi recordInfo quando settled è false.

Verifica

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

Questo segue la specifica Standard Webhooks, quindi una libreria scritta per Stripe o Replicate funziona cambiando soltanto i nomi degli header. Due differenze rispetto alla firma callBackUrl sopra producono silenziosamente una firma che non viene mai verificata, se gestite male:

  1. Viene firmato il corpo stesso, non il suo digest. Qui non c'è un passaggio interno sha256.
  2. La chiave HMAC è la sequenza di byte ottenuta decodificando in base64 ciò che segue whsec_, non quella stringa. Usare la stringa è l'errore più comune: nulla genera errori, ma la firma è sempre sbagliata.
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 resta uguale tra i retry di un messaggio: usalo come chiave di idempotenza. webhook-timestamp viene aggiornato a ogni tentativo, perché protegge dalla riproduzione delle richieste, non dalla duplicazione.

Ruotare un segreto senza interruzioni

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

Durante il periodo di transizione ogni consegna contiene due firme, separate da uno spazio nello stesso header, ed è sufficiente verificarne una. È il motivo del ciclo .some(…) sopra. L'ordine sicuro è: distribuisci un ricevitore che le accetta entrambe → ruota → configura il nuovo segreto → lascia terminare la finestra. Non serve interrompere il servizio.

La finestra predefinita è 24 ore e quella massima 7 giorni. Ometti completamente il corpo per usare il valore predefinito.

Quando un endpoint smette di funzionare

Le consegne vengono ritentate secondo il programma sotto. Inoltre:

  • Una risposta 410 Gone disabilita immediatamente l'endpoint ed elimina i suoi messaggi in coda. Continuare a ritentare un indirizzo che il proprietario dichiara inesistente non aiuta nessuno. GET /console/v1/webhook-endpoints/{id} riporta allora status: "disabled" con disabledReason: "gone_410".
  • Ogni altro errore incrementa consecutiveFailures sull'endpoint, restituito dalla stessa chiamata. Attualmente nulla viene disabilitato automaticamente.

Un endpoint guasto resta sottoscritto

La disabilitazione automatica dopo giorni di errori continuativi non è ancora implementata. Un endpoint non funzionante che risponde con uno stato diverso da 410 continua a ricevere tutti gli eventi e a consumare tutti i retry per ciascuno. Interroga tu consecutiveFailures e invia un PATCH all'endpoint con {"enabled": false} mentre lo ripari.

Anche balance.low e webhook.endpoint.disabled non sono ancora disponibili: per questo non compaiono nella tabella sopra.

Riabilitare un endpoint azzera i contatori degli errori, così uno appena riparato non porta con sé i vecchi errori alla successiva interruzione.

Eliminarne uno

L'eliminazione interrompe le consegne future. I messaggi già in coda mantengono la cronologia, così puoi ancora vederne l'esito, ma senza l'endpoint non possono più essere firmati: terminano come falliti invece di scomparire dai log di consegna.

Che cosa rispondere

Qualsiasi 2xx significa ricevuto. Qualsiasi altro stato, un errore di connessione o più di 15 secondi contano come fallimento e provocano un nuovo tentativo.

Conferma la ricezione prima di fare il lavoro: inserisci l'ID dell'attività nella tua coda e restituisci la risposta. Scaricare, transcodificare e notificare dentro la richiesta di callback ne provoca soltanto il timeout e il reinvio, mentre dal tuo lato l'elaborazione è già parzialmente avvenuta.

Programma dei nuovi tentativi

RetryAttesa dopo il tentativo precedente
110 secondi
230 secondi
32 minuti
410 minuti
530 minuti
62 ore
76 ore

Ci sono otto tentativi automatici, inclusa la prima consegna. Le attese programmate sommano 8 ore, 42 minuti e 40 secondi; durata delle richieste e pianificazione possono allungare il tempo trascorso. Dopo l'esaurimento dei retry, i tentativi automatici terminano e la consegna compare come fallita in Console → Webhook → Consegne dei webhook. Da lì, Riprova la accoda un'altra volta, fino a 6 retry manuali al minuto. Non puoi reinviare manualmente una consegna ancora ritentata automaticamente, una già consegnata o una i cui contenuti sono stati rimossi secondo le impostazioni di conservazione: per questi casi usa recordInfo.

Ogni retry viene firmato di nuovo

Il timestamp è quello del momento di quella consegna, non del primo tentativo. Altrimenti un retry inviato sei ore dopo arriverebbe con un timestamp vecchio di sei ore e verrebbe rifiutato da qualsiasi finestra di protezione correttamente implementata: il meccanismo di retry fallirebbe proprio quando serve.

Per te significa: non memorizzare in cache né confrontare i timestamp tra consegne. Verifica ciascuno rispetto all'ora corrente.

Idempotenza

Una callback per la stessa attività può arrivare più volte: retry, duplicati di rete e reinvii manuali. Non presumere mai una consegna esattamente una volta.

Per v2, deduplica sul request_id della risposta, whd_…. Identifica il record di consegna e resta uguale tra i retry, proprio come deve fare una chiave di idempotenza. L'implementazione più semplice è un indice univoco nel tuo database: in caso di conflitto, restituisci 2xx e prosegui.

v1 non ha request_id. Se la ricevi ancora, ricava una chiave di idempotenza da task_id, stato e digest del corpo grezzo. Non deduplicare soltanto sull'ID dell'attività, perché in futuro potresti sopprimere un record distinto di stato terminale per la stessa attività.

Vincoli dell'URL di callback

  • Soltanto https. Gli URL http:// in chiaro vengono rifiutati perché il corpo della callback contiene il prompt e i link firmati ai risultati.
  • Nessuna porta esplicita diversa da 443 o 80; la porta predefinita di un URL https:// va bene.
  • Non inserire nome utente o password nell'URL.
  • Il nome host deve risolversi e ogni indirizzo risolto deve essere pubblico. Vengono rifiutati loopback, reti private, indirizzi link-local e indirizzi dei metadati cloud.
  • Ricontrolliamo l'IP risolto anche al momento della connessione, bloccando il percorso di DNS rebinding.

I valori non validi di callBackUrl nella creazione delle attività, nello streaming o nei preventivi restituiscono HTTP 400 con code: 400 applicativo, msg: "Invalid callback URL" e un request_id. La risposta non ripete l'URL inviato, il nome host, l'indirizzo IP o la diagnostica DNS.

Entrambi i controlli impediscono di usare i nostri server come punto di accesso a una rete privata. Di conseguenza http://localhost:3000 non funziona nello sviluppo locale: usa un tunnel, per esempio ngrok o Cloudflare Tunnel, per un indirizzo HTTPS pubblico, oppure usa il polling durante lo sviluppo locale.

In questa pagina