Webhook delivery¶
For production workloads, register a URL on your project and Kreuzberg Cloud POSTs the result to it as soon as a job reaches a terminal status — no poller, no keep-alive. Polling is for prototyping; webhooks are for everything else.
Set up¶
Open the dashboard and add a webhook to your project:
- URL — must be
https://. Should respond2xxwithin 30 s. - Events — pick from
job.completed,job.failed,job.cancelled. - Secret — leave blank to auto-generate, or paste 32+ bytes of random. Saved once; we hash-store it.
Payload¶
We POST JSON like this:
{
"event_id": "01HZQ...",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"project_id": "1cbb9d72-660a-4df2-ba3d-66d83b6afaff",
"status": "completed",
"error_message": null,
"timestamp": 1747038551,
"attempt_count": 1
}
Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
kreuzberg-webhook/<version> |
X-Webhook-Signature |
sha256=<hex> — present when a secret is configured |
X-Idempotency-Key |
the event_id — only set on inline (per-job) webhooks; use it to deduplicate retries |
The timestamp field is Unix seconds. Webhook URLs must be https://
(plaintext http:// is rejected except for localhost in dev). The endpoint
must respond within 30 s.
After the POST, GET /v1/jobs/{job_id} with your API key to fetch the
extracted text. Webhook payloads are intentionally small.
Verify the signature¶
X-Webhook-Signature is HMAC-SHA256 of the raw request body with your
webhook secret, hex-encoded, prefixed with sha256=. Verify before trusting
the payload.
import hmac
import hashlib
from fastapi import FastAPI, Header, HTTPException, Request
SECRET = b"..." # your webhook secret
app = FastAPI()
@app.post("/webhooks/kreuzberg")
async def receive(request: Request,
x_webhook_signature: str = Header(...)):
body = await request.body()
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(x_webhook_signature, expected):
raise HTTPException(401, "bad signature")
# ... fetch GET /v1/jobs/{job_id} and process
return {"ok": True}
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.KREUZBERG_WEBHOOK_SECRET!;
const app = express();
app.post("/webhooks/kreuzberg", express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("x-webhook-signature") ?? "";
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
// ... fetch GET /v1/jobs/{job_id} and process
res.json({ ok: true });
});
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
var secret = []byte("...")
func receive(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
sig := r.Header.Get("X-Webhook-Signature")
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// ... fetch GET /v1/jobs/{job_id} and process
w.WriteHeader(http.StatusOK)
}
Retries¶
At least once delivery. If your endpoint returns non-2xx or times out:
- Up to 5 attempts total.
- Backoff 5 s → 30 s → 5 min, then dead-letter.
4xxother than429is treated as permanent — we stop retrying.2xx,429,5xx, and connection errors are retried until the cap.
Use the event_id (also in X-Idempotency-Key) to deduplicate — the same
event may arrive more than once if your endpoint responds slowly.
Testing¶
The dashboard has a Send test button that fires a synthetic payload at your URL with a real signature — verify your handler before going live.
Inline webhooks (per-job)¶
For one-off deliveries, pass an inline webhook config on POST /v1/extract
instead of registering a project-level webhook. The body shape is documented
in the REST API reference.