# Webhooks

> Signierte Webhook-Payloads, Prüfung der HMAC-SHA256-Signatur, der Zeitplan für erneute Versuche und Zustellungsprotokolle.

Webhooks schicken jede angenommene Einsendung an deinen Server, sobald sie eintrifft. Zustellungen werden nach dem [Standard Webhooks](https://www.standardwebhooks.com)-Schema signiert, etwa drei Tage lang erneut versucht und im Dashboard Versuch für Versuch protokolliert. Fertige Handler findest du unter [Webhooks empfangen](https://ikiform.com/de/docs/guides/webhook-handlers.md).

## Füge einen Endpunkt hinzu

1. Öffne im Dashboard **Webhooks** und wähle **Add endpoint**.
2. Gib eine HTTPS-URL ein und wähle ein Formular oder alle Formulare im Workspace.
3. Kopiere das Signatur-Secret. Es beginnt mit `whsec_` und wird nur einmal vollständig angezeigt.
4. Sende ein Test-Event, um deinen Handler zu prüfen.

### Endpunkt-URLs

- Nur `https:`, auf dem Standardport 443, ohne Benutzername oder Passwort in der URL, bis zu 2.048 Zeichen.
- Ein öffentlicher Hostname. IP-Adressen, `localhost` und Namen, die auf `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa` oder `.onion` enden, werden abgelehnt, ebenso ikiform.com, workers.dev und pages.dev.
- Der Hostname darf nur zu öffentlichen Adressen auflösen. Private, Loopback-, Link-Local- und Carrier-Grade-NAT-Bereiche werden abgelehnt.
- Die URL wird beim Speichern und erneut vor jeder Zustellung geprüft. Ein Endpunkt, der plötzlich zu einer privaten Adresse auflöst, wird deaktiviert.
- Ein Workspace kann bis zu 3 Endpunkte haben. Enterprise hat kein Limit.

## Events

| Typ | Gesendet, wenn | Erneut versucht |
| --- | --- | --- |
| `submission.created` | Eine Einsendung angenommen wird oder eine Spam-Einsendung als kein Spam markiert wird | Ja |
| `endpoint.test` | Du auf der Endpunkt-Seite **Send test** wählst | Nein, ein Versuch |

Spam löst nie einen Webhook aus. Ebenso wenig Einsendungen, die zurückgehalten werden, weil der Workspace über seinem monatlichen Limit liegt.

## Der Request

Jede Zustellung ist ein `POST` mit einem JSON-Body und drei Signatur-Headern, gesendet vom Zustelldienst von Ikiform mit dem User-Agent `Ikiform-Webhooks/1.0`.

`Header`:

```http
POST /webhooks/ikiform HTTP/1.1
content-type: application/json
user-agent: Ikiform-Webhooks/1.0
webhook-id: dlv_01k7v2d4n8r2p6s0w3y5b7c9e1
webhook-timestamp: 1792300000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| Header | Wert |
| --- | --- |
| `webhook-id` | Die Zustellungs-ID. Bei jedem erneuten Versuch derselben Zustellung gleich, nutze sie also, um Duplikate zu ignorieren |
| `webhook-timestamp` | Unix-Sekunden, zu denen dieser Versuch signiert wurde. Bei jedem Versuch neu |
| `webhook-signature` | `v1,` plus ein Base64-HMAC-SHA256. Mehrere durch Leerzeichen getrennte Werte, während ein Secret rotiert wird |

### Payloads

**submission.created** (`Body`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": { "name": "Ada", "email": "ada@example.com", "message": "Hello" },
    "meta": {
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
      "origin": "https://example.com",
      "referrer": "https://example.com/contact",
      "country": "GB",
      "subject": null,
      "reply_to": "ada@example.com"
    },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": false
  }
}
```

**endpoint.test** (`Body`)

```json
{
  "id": "dlv_01k7v2f0a6c8e2g4j6m8p0r2t4",
  "type": "endpoint.test",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "endpoint_id": "whk_01k7v2e1b3d5f7h9k1m3n5q7s9",
    "message": "This is a test event from Ikiform."
  }
}
```

**Gekürzt** (`Über 256 KB`)

```json
{
  "id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "type": "submission.created",
  "timestamp": "2026-10-17T12:00:00.000Z",
  "api_version": "2026-10-17",
  "data": {
    "submission_id": "sub_01k7v2c9t3e8m5q4x6z0a1b2c3",
    "form": { "id": "frm_xxxxxxxxxxxxxxxx", "name": "Contact" },
    "fields": {},
    "meta": { "origin": "https://example.com", "country": "GB" },
    "created_at": "2026-10-17T12:00:00.000Z",
    "truncated": true
  }
}
```

| Feld | Beschreibung |
| --- | --- |
| `id` | Die Zustellungs-ID, gleich wie `webhook-id` |
| `type` | `submission.created` oder `endpoint.test` |
| `timestamp` | Wann die Zustellung erstellt wurde, in ISO 8601 UTC. Bei jedem erneuten Versuch gleich |
| `api_version` | Die Payload-Version, mit der der Endpunkt erstellt wurde, derzeit `2026-10-17` |
| `data.submission_id` | Die ID der Einsendung. Rufe sie mit der [REST API](https://ikiform.com/de/docs/api.md#get-submission) ab |
| `data.form` | `id` und `name` des Formulars |
| `data.fields` | Die gespeicherten Werte. Wenn Felder definiert sind, sind das die validierten, umgewandelten Werte |
| `data.meta` | Dieselben Schlüssel wie `meta` in der REST API. Jeder Schlüssel ist vorhanden, und jeder Wert kann `null` sein. Die IP des Besuchers und ihr Hash werden nie gesendet |
| `data.meta.user_agent` | Der User-Agent des Browsers, bis zu 512 Zeichen |
| `data.meta.origin`, `referrer` | Woher der Post kam, sofern der Browser sie gesendet hat |
| `data.meta.country` | Zweibuchstabiger Ländercode, von Cloudflare |
| `data.meta.subject`, `reply_to` | Aus den Feldern `_subject` und `_replyto` |
| `data.created_at` | Wann die Einsendung gespeichert wurde |
| `data.truncated` | `true`, wenn der Body über 256 KB groß wäre; in diesem Fall ist `fields` leer |

Jeder `meta`-Wert kann `null` sein. Behandle Feldwerte als nicht vertrauenswürdige Eingaben, genau wie einen Formular-Post an deinen eigenen Server.

## Prüfe die Signatur

Die Signatur ist ein HMAC-SHA256 von `{webhook-id}.{webhook-timestamp}.{raw body}`, mit dem Base64-dekodierten Teil des Secrets nach `whsec_` als Schlüssel, dann Base64-kodiert und nach `v1,` in `webhook-signature` gesendet.

- Verwende den rohen Request-Body genau so, wie er empfangen wurde. Parsen und erneutes Serialisieren von JSON verändert die Bytes.
- Der Header kann mehrere durch Leerzeichen getrennte Signaturen enthalten. Akzeptiere den Request, wenn irgendeine `v1`-Signatur passt.
- Vergleiche in konstanter Zeit und lehne Zeitstempel ab, die mehr als 5 Minuten von jetzt abweichen.

### Mit dem SDK

`verifyWebhook` erledigt all das mit Web Crypto und gibt das geparste Event zurück oder wirft `WebhookVerificationError` mit einem `reason`. Es akzeptiert ein `Headers`-Objekt oder ein einfaches Record wie `req.headers` in Node.

`app/api/webhooks/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type === "submission.created") {
      console.log(event.data.fields);
    }
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(error.reason, { status: 400 });
    }
    throw error;
  }
}
```

### Ohne das SDK

Jede Standard-Webhooks-Bibliothek funktioniert ebenfalls. Von Hand sind es nur ein paar Zeilen:

**Node** (`verify.js`)

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.from(process.env.IKIFORM_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

export function verify(body, headers) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

**Python** (`verify.py`)

```python
import base64
import hashlib
import hmac
import os
import time

secret = base64.b64decode(os.environ["IKIFORM_WEBHOOK_SECRET"].removeprefix("whsec_"))


def verify(body: bytes, headers) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(secret, content, hashlib.sha256).digest()).decode()
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Signaturprüfer

Der interaktive Prüfer auf dieser Seite prüft ein Secret, die drei Webhook-Header und einen rohen Body mit `verifyWebhook` aus dem SDK, komplett im Browser. Er kann auch eine korrekt signierte Beispiel-Zustellung generieren. Nichts, was du einfügst, verlässt die Seite.

## Antworten

| Deine Antwort | Was Ikiform macht |
| --- | --- |
| Ein beliebiger `2xx` innerhalb von 15 Sekunden | Markiert die Zustellung als erfolgreich |
| `410 Gone` | Lässt die Zustellung fehlschlagen und deaktiviert den Endpunkt sofort |
| `429` oder `503` mit `Retry-After` | Versucht es nach dieser Verzögerung erneut, aber nicht später als beim nächsten Schritt im Zeitplan |
| Eine Weiterleitung | Wird nicht befolgt. Zählt als Fehlschlag |
| Jeder andere Status, ein Timeout oder ein Netzwerkfehler | Erneute Versuche nach dem Zeitplan unten |

Erledige langsame Arbeit nach dem Antworten, zum Beispiel in einer Queue. Die ersten 4 KB deines Response-Bodys werden im Zustellungsprotokoll gespeichert.

## Erneute Versuche

Ein fehlgeschlagener Versuch wird nach diesem Zeitplan erneut versucht, jede Verzögerung mit bis zu 20 % zufälligem Jitter in beide Richtungen. Nach dem 9. Versuch, etwa 3 Tage und 6 Stunden nach dem ersten, wird die Zustellung als fehlgeschlagen markiert.

| Versuch | Verzögerung nach dem vorherigen Versuch | Zeit seit dem ersten Versuch |
| --- | --- | --- |
| 1 | Sofort | 0 |
| 2 | 5 s | 5 s |
| 3 | 5 min | 5 min |
| 4 | 30 min | 35 min |
| 5 | 2 h | 2 h 35 min |
| 6 | 5 h | 7 h 35 min |
| 7 | 10 h | 17 h 35 min |
| 8 | 1 Tag | 1 Tag 18 h |
| 9 | 1 Tag 12 h | 3 Tage 6 h |

### Deaktivierte Endpunkte

Wenn 5 Tage lang jede Zustellung an einen Endpunkt fehlschlägt, wird der Endpunkt deaktiviert und die Workspace-Inhaber bekommen eine E-Mail. Eine `410 Gone`-Antwort oder ein Ziel, das privat wird, deaktiviert ihn sofort. Schalte ihn auf der Endpunkt-Seite wieder ein, sobald er repariert ist.

## Zustellungsprotokolle

Jede Endpunkt-Seite listet ihre Zustellungen mit ihrem Status auf. Klapp eine auf, um jeden Versuch zu sehen: wann er lief, ob er automatisch, manuell oder ein Test war, den Statuscode, die Dauer und die ersten 4 KB deiner Antwort oder den Fehler. Von dort kannst du jede Zustellung erneut senden. Versuche werden 30 Tage und Zustellungen 90 Tage lang aufbewahrt.

## Secrets rotieren

**Roll signing secret** erstellt ein neues Secret. In den nächsten 24 Stunden wird jede Zustellung sowohl mit dem alten als auch mit dem neuen Secret signiert, sodass `webhook-signature` zwei Werte enthält und du das neue Secret deployen kannst, ohne Events zu verlieren.
