Skip to content

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 respond 2xx within 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.

Python (FastAPI)
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}
TypeScript (Node / Express)
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 });
  });
Go (net/http)
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.
  • 4xx other than 429 is 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.

Edit this page on GitHub