spicyapiDokumentacja
Budowanie z API
Treść główna

Webhooki

Rozróżniaj treści webhooków v1 i v2, wybieraj właściwe ID zadania i sprawdzaj podpisy przy użyciu przykładów Node.js, Python i curl.

Webhook (callback) to żądanie, które my wysyłamy na Twój serwer po zakończeniu zadania, aby nie trzeba było wielokrotnie pytać o wynik. Przekaż callBackUrl do createTask, a wyślemy POST, gdy zadanie osiągnie stan końcowy. Dla udanego zadania czekamy, aż pliki wyników będą gotowe do pobrania, dlatego callback zawiera działające linki. Nieudane dostarczenia są ponawiane według harmonogramu niżej.

Callbacki są wysyłane tylko dla stanów końcowych. Nie ma powiadomienia o przejściu queued → running.

Zanim zaczniesz

  • Serwer musi mieć publiczny adres HTTPS. Nasze serwery muszą mieć dostęp do adresu przez internet, a adres musi zaczynać się od https://. http://localhost i adresy wewnątrz sieci biurowej lub domowej nie zadziałają. Podczas pracy na własnym komputerze odpytuj zamiast tego recordInfo albo uruchom tunel (na przykład ngrok lub Cloudflare Tunnel), który udostępni lokalny serwer pod tymczasowym publicznym adresem HTTPS. Pełne zasady opisuje sekcja Ograniczenia adresu callbacku.
  • Są dwa współistniejące sposoby odbierania zdarzeń. Najszybciej jest przekazać callBackUrl w pojedynczym zadaniu; rejestracja endpointu konta obejmuje wszystkie zadania bez powtarzania adresu. Zobacz Którą opcję wybrać.
  • Nie ma przycisku „Wyślij zdarzenie testowe”. Najpierw sprawdź weryfikację podpisu poniższym skryptem curl, potem utwórz jedno tanie rzeczywiste zadanie z callBackUrl — płatne tak jak każde inne — i sprawdź Konsola → Webhooki → Dostarczenia webhooków, gdzie każde dostarczenie ma swój status.

Którą opcję wybrać

callBackUrl w zadaniuEndpoint konta
Gdzie ustawićJedno pole w każdym createTaskRaz przez API (jeszcze bez ekranu w konsoli)
Co dostarczaStan końcowy tego jednego zadaniaKażdy subskrybowany rodzaj zdarzenia dla wszystkich zadań
NagłówkiX-Webhook-*webhook-id / webhook-timestamp / webhook-signature
TreśćStruktura {code, msg, data}, taka jak w recordInfoSam obiekt zdarzenia z id, type i data.object
Sekret podpisuJeden na konto, w sekcji Klucz podpisywania webhookówJeden na endpoint, pokazany raz przy utworzeniu lub rotacji
Rotacja klucza bez przerwy w działaniuNie — stary sekret od razu przestaje weryfikować podpisyTak — w okresie przejściowym wysyłane są oba podpisy

Żadna opcja nie zastępuje drugiej. Zadanie z callBackUrl, należące jednocześnie do konta z pasującą subskrypcją, powoduje dwa dostarczenia, po jednym w każdej strukturze. To prawidłowa konfiguracja, nie błąd.

Jeżeli wdrażasz integrację teraz i nie masz wyraźnego powodu wyboru jednej opcji, zarejestruj endpoint konta: adres podajesz raz, sekret można zmieniać bez jednoczesnego przełączenia wszystkich odbiorców, a treść ma strukturę znaną większości bibliotek webhooków.

Dalsza część strony opisuje dostarczenie przez callBackUrl, chyba że sekcja wskazuje inaczej. Różnice opisują Endpointy konta.

Najpierw ustaw sekret podpisu

Każde dostarczenie jest podpisywane sekretem podpisu konta, aby Twój serwer mógł potwierdzić pochodzenie ze SpicyAPI. Wszystkie klucze API konta mają wspólny sekret.

SpicyAPI pokazuje sekret tylko raz

Konta zwykle mają już skonfigurowany sekret, ale jego wartość nie została Ci pokazana, dlatego musisz utworzyć taki, który możesz zobaczyć:

  1. Otwórz Konsola → Webhooki.
  2. Na karcie Klucz podpisywania webhooków kliknij Generuj (jeżeli widzisz Nie ustawiono) lub Zmień klucz (jeżeli widzisz Skonfigurowano).
  3. Skopiuj wartość bezpośrednio do SPICY_WEBHOOK_SECRET na serwerze, a następnie kliknij Zapisano. Nie można jej wyświetlić ponownie; po utracie trzeba ponownie zmienić klucz.

Rotacja działa natychmiast: stary sekret od razu przestaje weryfikować podpisy, a nadal ponawiane dostarczenia są podpisywane nowym. Zaktualizuj wszystkie odbiorniki bezpośrednio po rotacji.

Bez sekretu nie można podpisać dostarczenia: każda próba jest traktowana jako nieudana i podlega harmonogramowi ponowień. Dla zadań, których callback został pominięty przy niedostępnym sekrecie, odpytaj recordInfo.

Zacznij od weryfikatora SDK

Dla nowego odbiornika v2 użyj verifyWebhook z @spicyapi/sdk/webhooks. Sprawdza podpis surowej treści i pięciominutowe okno czasu, a następnie zwraca taskId i deliveryId. Ta funkcja korzysta z modułu crypto w Node.js; używaj jej na serwerze.

Poniższy przykład używa Express i PostgreSQL (npm install @spicyapi/sdk express pg). Skonfiguruj bazę przez standardowe zmienne środowiskowe PG* i ustaw SPICY_WEBHOOK_SECRET na sekret podpisu z konsoli. Udostępnij trasę przez HTTPS. Treść zawiera pełne input (w tym prompt), dlatego przykład dopuszcza do 10 MB; dobierz własny limit odpowiednio. Utwórz te dwie tabele aplikacji jednorazowo:

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

Zapisuj każde taskId zwrócone przy wysyłaniu własnych zadań w spicy_tasks. Odbiornik przyjmuje zdarzenia tylko dla tych zadań; jeżeli wyjątkowo szybki callback dotrze przed zapisem, odpowiedź spoza 2xx umożliwi ponowienie dostarczenia. We wspólnej aplikacji zastosuj też regułę własności zadań danego najemcy.

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

Zarejestruj tę trasę obsługującą surową treść przed globalnym middleware express.json(). Zwracaj 2xx dopiero po zatwierdzeniu zapisu w tabeli odbiorczej, a zapisane zdarzenie przetwarzaj w procesie działającym w tle. Klucz główny sprawia, że powtórne dostarczenia nie wykonują dodatkowej operacji. Sprawdź data.state i data.settled, zanim uznasz wynik za udany lub koszt za ostateczny. Jeżeli zapisany adres pliku wygasł, odpytaj zadanie ponownie, dopóki wynik jest przechowywany.

Ten przykład nowej integracji przyjmuje v2. Dla historycznych odbiorników v1 zachowaj opisaną niżej weryfikację uwzględniającą wersję; ta sama funkcja SDK obsługuje payloadVersion: 1, ale v1 nie ma deliveryId.

Żądanie

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
NagłówekUwagi
X-Webhook-TimestampCzas Unix w sekundach dla tego dostarczenia. Służy do ochrony przed odtworzeniem żądania
X-Webhook-SignatureHMAC-SHA256 zakodowany w base64
X-Webhook-Payload-VersionWersja struktury treści. Nowe integracje domyślnie używają 2; historyczne dostarczenia mogą nadal używać 1. Zawsze kieruj logikę według tego nagłówka. Zobacz Treść
User-AgentZawsze SpicyAPI-Webhook/1

Nie podążamy za przekierowaniami — 3xx z endpointu oznacza nieudane dostarczenie. Limit czasu wynosi 15 sekund.

Treść

v2 odpowiada recordInfo; v1 to starsza płaska struktura

Callback wersji 2 ma tę samą strukturę co GET /jobs/recordInfo: otoczkę {code, msg, data, request_id} i te same pola camelCase wewnątrz data. Wersja 1 nie ma otoczki i umieszcza pola zadania snake_case na najwyższym poziomie.

Twórz nowe integracje dla v2, lecz odczytuj X-Webhook-Payload-Version na wejściu weryfikatora: dla v1 pobieraj ID zadania z najwyższego poziomu task_id, a dla v2 z data.taskId.

Poniższe przykładowe dane używają MODEL_ID_FROM_CATALOG jako symbolu dokładnego ID modelu pierwotnie wybranego z aktualnego katalogu po uwierzytelnieniu. Nie jest to dosłowny identyfikator.

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

Powyższy przykład to v2. Opis pól data znajduje się w Zadaniach asynchronicznych; obowiązuje zarówno dla recordInfo, jak i callbacków v2, dlatego nie powtarzamy go tutaj.

Tak jak w recordInfo, gotowe data.output.assets[] zawierają url, expiresAt i metadane mediów. Pobierz plik bezpośrednio z adresu bez danych uwierzytelniających API; nie potrzebujesz dodatkowego żądania o dostęp do pobrania. Pola kwot, takie jak cost, są dziesiętnymi ciągami znaków bez $.

Jeden callback na zadanie, wysyłany po gotowości wyniku

Każde zadanie ma jedno zdarzenie callbacku. Zdarzenie zadania zakończonego niepowodzeniem powstaje od razu przy niepowodzeniu. Zdarzenie udanego zadania powstaje po skopiowaniu plików do naszego magazynu, dlatego każdy plik zwykle ma url. Ponowienia wysyłają to samo zdarzenie; odświeżane są tylko tymczasowe linki do plików.

Jedna rzecz może się później zmienić i nie powoduje kolejnego callbacku:

  • data.settled jest false → ostateczna opłata nie była jeszcze ustalona. Odczytaj później recordInfo, aby uzyskać końcowe cost.

W rzadkim przypadku, gdy pliku wyniku nie można w ogóle zapisać, callback nadal jest wysyłany: ten plik ma unavailable: true i nie ma url. Jeżeli nie można zapisać żadnego pliku zadania, nie naliczamy opłaty (cost wynosi "0").

Callback z state: "succeeded", settled: true i url przy każdym pliku jest kompletny: możesz z niego korzystać bez kolejnego zapytania.

code w v2 zawsze wynosi 200, również dla nieudanych zadań

Nigdy nie oceniaj powodzenia zadania na podstawie code

code otoczki opisuje prawidłową strukturę dostarczenia, a nie powodzenie generowania. Callback nieudanego zadania także zawiera 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"
  }
}

Kod sprawdzający if (body.code === 200) markSucceeded() oznacza każde nieudane zadanie jako udane, a sam mechanizm nie wygląda na uszkodzony. Powodzenie lub niepowodzenie określa data.state; powód znajduje się w data.errorCode i data.errorMessage. Callback nie ma nagłówka żądania służącego do wyboru języka, dlatego errorMessage korzysta z ustawienia konta i domyślnie jest po angielsku; zobacz Język komunikatów o błędach.

To odpowiada recordInfo, które również zwraca 200 przy opisywaniu nieudanego zadania. Celowo obie metody są zgodne — różnica przywróciłaby problem „te same dane, dwa sposoby odczytu”, który ta struktura miała usunąć.

request_id w v2 identyfikuje dostarczenie — używaj go jako klucza idempotencji

request_id w otoczce ma postać whd_…. Identyfikuje rekord tego dostarczenia, nie zadanie:

  • Nie zmienia się przy ponowieniach. Automatyczne ponowienia i ręczne ponowne dostarczenia wykorzystują to samo zdarzenie biznesowe i request_id. Przed każdym wysłaniem odświeżamy tylko tymczasowe pola plików url i expiresAt; nie deduplikuj v2 przy użyciu skrótu treści.
  • Dzięki temu sprawdzenie „czy ten callback był już obsłużony?” jest proste: utwórz unikalny indeks w swojej bazie i zwracaj 2xx przy konflikcie.
  • To samo pole w odpowiedzi recordInfo zawiera ID żądania HTTP (req_…). Znaczenie w obu miejscach jest takie samo: identyfikator tego jednego dostarczenia.

W v2 zadanie znajduje się w data.taskId — odpowiada na pytanie „które zadanie”. request_id odpowiada „które dostarczenie”. v1 nie ma request_id; deduplikuj je kluczem wyprowadzonym z task_id, stanu i skrótu treści.

Nagłówek wersji odpowiada wysłanym bajtom

X-Webhook-Payload-Version podaje wersję, w której zbudowano tę treść, a nie wersję aktualnie ustawioną na platformie.

Treść biznesowa i jej wersja są ustalane przy dodaniu do kolejki. Ponowienie odświeża wyłącznie tymczasowe adresy mediów i podpisuje dokładnie wysyłaną treść; nigdy nie podstawia aktualnego rekordu zadania. Nagłówek musi im towarzyszyć: inaczej ponowienie oczekujące w kolejce przez kilka godzin dotarłoby z numerem wersji sprzecznym z własną treścią — a to według nagłówka kierujesz logikę.

v1 to zamrożona warstwa zgodności. Nie twórz dla niej nowych integracji

Wersja treści jest przełącznikiem zgodności całej platformy obsługiwanym przez SpicyAPI; nie możesz jej wybierać, a nowe dostarczenia używają wersji 2. Przełącznik może wymusić 1, czyli starszą strukturę: snake_case, bez otoczki, pola płasko na najwyższym poziomie oraz brak input, settled i completedAt. Istnieje wyłącznie po to, aby integracje starszej struktury nie przestały działać w dniu wdrożenia nowej.

v1 nie otrzymuje nowych pól. Wszystkie dalsze rozszerzenia trafiają tylko do v2. Po potwierdzeniu, że nikt nie używa już v1, usuniemy ją wraz z ustawieniem.

Twórz nowe integracje dla v2. Historyczne dostarczenia nadal mogą być ponawiane jako v1, dlatego nie wnioskuj o wersji ze struktury JSON. Odczytuj X-Webhook-Payload-Version i stosuj pokazane niżej rozgałęzienie zarówno przy weryfikacji podpisu, jak i odczycie treści.

Podpis

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

Trzy części połączone przez .:

  1. ID zadania, wybrane według X-Webhook-Payload-Version: najwyższy poziom task_id dla v1, data.taskId dla v2 (nigdy request_id)
  2. wartość X-Webhook-Timestamp
  3. skrót SHA-256 surowej treści żądania, zapisany szesnastkowo małymi literami

Skrót treści musi należeć do podpisu — a weryfikator musi go obliczać

Jeżeli podpis obejmuje tylko ID zadania i znacznik czasu, każdy, kto przechwyci prawidłowe dostarczenie, może połączyć te wartości z własną treścią i ponownie wysłać je do Twojego endpointu. Sprawdzisz opublikowany algorytm, a podpis okaże się prawidłowy.

Byłoby to równoznaczne z potwierdzeniem przez nas fałszywego komunikatu „zadanie gotowe, plik jest tutaj”, na podstawie którego obciążysz klienta lub dostarczysz wynik. Skrót nie jest opcjonalny.

Obliczaj skrót surowych bajtów

Nie deserializuj i nie serializuj treści ponownie przed obliczeniem skrótu. Kolejność kluczy, odstępy i zapis znaków Unicode różnią się między bibliotekami JSON; uzyskane bajty niemal na pewno nie będą tymi, które podpisaliśmy, a weryfikacja zawsze się nie powiedzie.

Dostęp do surowej treści: express.raw() w Express, rawBody w Fastify, request.get_data() w Flask, request.body w Django, await request.body() w FastAPI.

Kod weryfikacji

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

Przetestuj weryfikator przez curl

Zapisz przykład v2 dla zadania zakończonego powodzeniem z sekcji Treść jako payload.json, ustaw SPICY_WEBHOOK_SECRET na ten sam sekret co w odbiorniku i zmień adres na końcu na własny endpoint. Następnie uruchom:

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"

Oczekiwany wynik: endpoint zwraca status 2xx. Teraz zmień jeden znak SPICY_WEBHOOK_SECRET i uruchom ponownie — musi zwrócić 401. Jeżeli nadal daje 2xx, odbiornik nie sprawdza podpisów. (Odbiornik SDK powyżej zwraca również 404 dla ID zadania nieobecnego w spicy_tasks; najpierw dodaj tam przykładowe taskId lub testuj zwykły kod weryfikacji.)

Endpointy konta

Endpoint to adres zarejestrowany na koncie wraz z listą żądanych zdarzeń. Po jego utworzeniu każde pasujące zdarzenie jest do niego dostarczane — nie musisz już podawać adresu przy każdym zadaniu.

Rejestracja

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

Nie ma jeszcze ekranu w konsoli

Endpointy są obecnie dostępne tylko przez API. Konsola → Webhooki zarządza sekretem podpisu całego konta używanym przez callBackUrl i pokazuje dostarczenia; nie ma strony rejestracji endpointów. Wszystkie opisane tutaj operacje wykonujesz czterema pokazanymi wywołaniami, do których potrzebujesz ciasteczka sesji konsoli — tych samych danych uwierzytelniających co przeglądarka, a nie klucza sk-spicy-.

Odpowiedź zawiera secret w postaci whsec_…. Jest pokazywany dokładnie raz. Żadne późniejsze wywołanie go nie zwraca — ani lista, ani szczegóły. Jeżeli go utracisz, pozostaje rotacja.

Konto może mieć 16 endpointów. Adres musi być publicznym HTTPS, sprawdzanym przy zapisie i ponownie podczas dostarczenia względem adresu, na który rzeczywiście wskazuje.

Zdarzenia do subskrypcji

ZdarzenieKiedy jest wysyłane
task.completedSkrót dla wszystkich czterech poniższych. Subskrybuj go, chyba że naprawdę potrzebujesz je rozróżniać
task.succeededZadanie zakończyło się
task.failedZadanie nie powiodło się; rezerwacja jest zwykle zwalniana. Odmowy dotyczące treści mogą być płatne, jeżeli strona modelu wyraźnie opisuje ten wyjątek
task.canceledZachowane dla rekordów historycznych — obecnie przyjętych zadań nie można anulować
task.expiredZadanie przekroczyło termin; rezerwacja na saldzie zostaje zwolniona

GET /console/v1/webhook-events zwraca tę listę podczas działania, więc możesz stworzyć wybór zdarzeń bez zapisywania listy na stałe.

Subskrybuj task.completed zamiast trzech z czterech zdarzeń

Wyliczenie succeeded, failed i canceled przy pominięciu expired jest częstym błędem. Jego objaw to zadanie, na które kod czeka bez końca. task.completed nie może mieć takiej luki.

Nieznane nazwy zdarzeń są odrzucane przy zapisie, a nie po cichu ignorowane — literówka powinna dać błąd przy rejestracji, nie sześć tygodni później, gdy zauważysz brak jednego rodzaju zdarzeń. Z tego samego powodu nie przyjmujemy symboli wieloznacznych ("*").

Dostarczenie

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 to ten sam rekord zadania co w recordInfo i jego zapis z chwili utworzenia zdarzenia, a nie bieżący stan. Przetwarzając dostarczenie trzy dni później, nadal odczytujesz wartości z tamtej chwili.

settled wskazuje, czy cost jest ostateczne

Przy opóźnionym rozliczeniu zadanie osiąga stan końcowy przed zakończeniem rozliczeń, dlatego zdarzenie jest wysyłane z settled: false, a cost nadal zawiera wycenę. Nie następuje zdarzenie korygujące. Jeżeli potrzebujesz dokładnej kwoty, odczytaj recordInfo, gdy settled jest false.

Weryfikacja

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

To rozwiązanie stosuje specyfikację Standard Webhooks, dlatego biblioteka napisana dla Stripe lub Replicate działa po zmianie nazw nagłówków. Dwie różnice względem podpisu callBackUrl opisanego wyżej mogą bez widocznego błędu sprawić, że podpis nigdy nie przejdzie weryfikacji:

  1. Podpisywana jest sama treść, nie jej skrót. Nie ma wewnętrznego kroku sha256.
  2. Kluczem HMAC są bajty otrzymane po zdekodowaniu base64 części po whsec_, a nie sam ciąg znaków. Używanie ciągu znaków to najczęstszy błąd; nic nie zgłasza wyjątku, lecz podpis zawsze jest nieprawidłowy.
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 pozostaje takie samo przy ponowieniach jednej wiadomości — używaj go jako klucza idempotencji. webhook-timestamp jest odświeżany przy każdej próbie, ponieważ chroni przed odtworzeniem żądania, nie przed duplikacją.

Rotacja sekretu bez przerwy w działaniu

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

W okresie przejściowym każde dostarczenie zawiera dwa podpisy, oddzielone spacją w jednym nagłówku; wystarczy prawidłowa weryfikacja dowolnego z nich. Do tego służy pokazana wyżej pętla .some(…). Bezpieczna kolejność to: wdrożenie odbiornika przyjmującego oba → rotacja → ustawienie nowego sekretu → zakończenie okresu przejściowego. Nie trzeba zatrzymywać usługi.

Domyślny okres to 24 godziny, a maksymalny 7 dni. Pomiń całą treść żądania, aby użyć wartości domyślnej.

Gdy endpoint przestaje działać

Dostarczenia są ponawiane zgodnie z harmonogramem niżej. Dodatkowo:

  • Odpowiedź 410 Gone natychmiast wyłącza endpoint i usuwa jego wiadomości z kolejki. Dalsze próby pod adresem, którego właściciel poinformował o usunięciu, nikomu nie pomagają. GET /console/v1/webhook-endpoints/{id} zwraca wtedy status: "disabled" i disabledReason: "gone_410".
  • Każde inne niepowodzenie zwiększa consecutiveFailures endpointu, również zwracane przez to wywołanie. Obecnie nic nie jest automatycznie wyłączane w takim przypadku.

Niedziałający endpoint pozostaje subskrybowany

Automatyczne wyłączanie po wielu dniach niepowodzeń nie jest jeszcze wdrożone. Niedostępny endpoint, który odpowiada czymś innym niż 410, nadal otrzymuje każde zdarzenie i wykorzystuje dla niego całą liczbę ponowień. Samodzielnie sprawdzaj consecutiveFailures i wyłącz endpoint przez PATCH z {"enabled": false} na czas naprawy.

balance.low i webhook.endpoint.disabled również nie są jeszcze dostępne, dlatego nie ma ich w tabeli wyżej.

Ponowne włączenie endpointu zeruje liczniki niepowodzeń, aby naprawiony endpoint nie przenosił starych błędów do kolejnego problemu.

Usuwanie endpointu

Usunięcie zatrzymuje przyszłe dostarczenia. Wiadomości już w kolejce zachowują historię — nadal zobaczysz ich zakończenie — lecz bez endpointu nie można ich podpisać. Kończą więc jako nieudane, zamiast znikać z logu dostarczeń.

Jak odpowiadać

Dowolne 2xx oznacza odebranie. Każdy inny status, błąd połączenia lub czas ponad 15 sekund oznacza niepowodzenie i powoduje ponowienie.

Potwierdź odbiór przed wykonaniem pracy — dodaj ID zadania do własnej kolejki i zwróć odpowiedź. Pobieranie, transkodowanie i wysyłanie powiadomień wewnątrz żądania callbacku tylko powoduje przekroczenie czasu oraz ponowne dostarczenie, mimo że część pracy już wykonano.

Harmonogram ponowień

PonowienieOpóźnienie od poprzedniej próby
110 sekund
230 sekund
32 minuty
410 minut
530 minut
62 godziny
76 godzin

Łącznie jest osiem prób automatycznych, wliczając pierwsze dostarczenie. Suma zaplanowanych opóźnień to 8 godzin, 42 minuty i 40 sekund; czas żądań i harmonogram wykonania mogą wydłużyć rzeczywisty czas. Po wyczerpaniu prób automatyczne ponowienia kończą się, a dostarczenie ma stan Niepowodzenie w sekcji Konsola → Webhooki → Dostarczenia webhooków. Opcja Ponów dodaje je jeszcze raz do kolejki (do 6 ręcznych ponowień na minutę). Nie można ręcznie wysłać dostarczenia nadal ponawianego automatycznie, już dostarczonego lub takiego, którego treść usunięto zgodnie z ustawieniami przechowywania — w tych przypadkach użyj recordInfo.

Każde ponowienie otrzymuje nowy podpis

Znacznik czasu odpowiada chwili tego dostarczenia, nie pierwszej próbie. Inaczej ponowienie wysłane sześć godzin później miałoby sześciogodzinny znacznik i zostałoby odrzucone przez każde prawidłowo wdrożone okno ochrony przed odtworzeniem — mechanizm ponowień zawiódłby właśnie wtedy, gdy go potrzebujesz.

Dla Ciebie oznacza to: nie zapisuj znaczników do ponownego użycia ani nie porównuj ich między dostarczeniami. Każdy sprawdzaj względem bieżącego czasu.

Idempotencja

Callback tego samego zadania może dotrzeć więcej niż raz: przy ponowieniach, duplikatach sieciowych i ręcznym ponownym dostarczeniu. Nigdy nie zakładaj dostarczenia dokładnie raz.

Dla v2 deduplikuj według request_id otoczki (whd_…). Identyfikuje rekord dostarczenia i pozostaje takie samo przy ponowieniach, zgodnie z wymaganiem dla klucza idempotencji. Najprostsza implementacja to unikalny indeks w Twojej bazie — przy konflikcie zwróć 2xx i zakończ obsługę.

v1 nie ma request_id. Jeżeli nadal otrzymujesz v1, wyprowadź klucz idempotencji z task_id, stanu i skrótu surowej treści. Nie deduplikuj wyłącznie po ID zadania, ponieważ mogłoby to w przyszłości pominąć oddzielny rekord stanu końcowego tego samego zadania.

Ograniczenia adresu callbacku

  • Wyłącznie https. Zwykłe adresy http:// są odrzucane, ponieważ treść callbacku zawiera prompt i podpisane linki wyników.
  • Brak jawnego portu innego niż 443 lub 80; domyślny port adresu https:// jest prawidłowy.
  • Nie umieszczaj nazwy użytkownika ani hasła w adresie URL.
  • Nazwa hosta musi się rozwiązywać, a każdy uzyskany adres musi być publiczny. Odrzucamy pętlę zwrotną, sieci prywatne, adresy link-local i adresy metadanych chmury.
  • Ponownie sprawdzamy rozwiązane IP podczas połączenia, co blokuje atak DNS rebinding.

Nieprawidłowe callBackUrl podczas tworzenia, strumieniowania lub wyceny zwraca HTTP 400 z biznesowym code: 400, msg: "Invalid callback URL" i request_id. Odpowiedź nie powtarza podanego URL, hosta, IP ani diagnostyki DNS.

Obie kontrole zapobiegają wykorzystaniu naszych serwerów jako punktu wejścia do sieci prywatnej. Konsekwencja jest taka, że http://localhost:3000 nie działa podczas pracy lokalnej — użyj tunelu (na przykład ngrok lub Cloudflare Tunnel), aby uzyskać publiczny adres HTTPS, albo odpytuj podczas pracy lokalnej.

Na tej stronie