# Webhooks

> Signed webhook payloads, verifying the HMAC-SHA256 signature, the retry schedule and delivery logs.

Webhooks push each accepted submission to your server as it arrives. Deliveries are signed with the [Standard Webhooks](https://www.standardwebhooks.com) scheme, retried for about three days, and logged attempt by attempt in the dashboard. Ready-made handlers are in [Receiving webhooks](https://ikiform.com/docs/guides/webhook-handlers.md).

## Add an endpoint

1. In the dashboard, open **Webhooks** and choose **Add endpoint**.
2. Enter an HTTPS URL and pick one form, or all forms in the workspace.
3. Copy the signing secret. It starts with `whsec_` and is shown in full only once.
4. Send a test event to check your handler.

### Endpoint URLs

- `https:` only, on the default port 443, with no username or password in the URL, up to 2,048 characters.
- A public hostname. IP addresses, `localhost`, and names ending in `.local`, `.internal`, `.intranet`, `.lan`, `.home.arpa` or `.onion` are refused, as are ikiform.com, workers.dev and pages.dev.
- The hostname must resolve only to public addresses. Private, loopback, link-local and carrier-grade NAT ranges are refused.
- The URL is checked when you save it and again before every delivery. An endpoint that starts resolving to a private address is disabled.
- A workspace can have up to 3 endpoints. Enterprise has no limit.

## Events

| Type | Sent when | Retried |
| --- | --- | --- |
| `submission.created` | A submission is accepted, or a spam submission is marked as not spam | Yes |
| `endpoint.test` | You choose **Send test** on the endpoint page | No, one attempt |

Spam never triggers a webhook. Neither do submissions held because the workspace is over its monthly limit.

## The request

Each delivery is a `POST` with a JSON body and three signature headers, sent from Ikiform's delivery service with the user agent `Ikiform-Webhooks/1.0`.

`Headers`:

```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 | Value |
| --- | --- |
| `webhook-id` | The delivery id. The same on every retry of the same delivery, so use it to ignore duplicates |
| `webhook-timestamp` | Unix seconds when this attempt was signed. New on every attempt |
| `webhook-signature` | `v1,` plus a base64 HMAC-SHA256. Several space-separated values while a secret rotates |

### 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."
  }
}
```

**Truncated** (`Over 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
  }
}
```

| Field | Description |
| --- | --- |
| `id` | The delivery id, the same as `webhook-id` |
| `type` | `submission.created` or `endpoint.test` |
| `timestamp` | When the delivery was created, in ISO 8601 UTC. The same on every retry |
| `api_version` | The payload version the endpoint was created with, currently `2026-10-17` |
| `data.submission_id` | The submission's id. Fetch it with the [REST API](https://ikiform.com/docs/api.md#get-submission) |
| `data.form` | The form's `id` and `name` |
| `data.fields` | The stored values. With fields defined, these are the validated, coerced values |
| `data.meta` | The same keys as the REST API's `meta`. Every key is present and any value can be `null`. The visitor's IP and its hash are never sent |
| `data.meta.user_agent` | The browser's user agent, up to 512 characters |
| `data.meta.origin`, `referrer` | Where the post came from, when the browser sent them |
| `data.meta.country` | Two-letter country code, from Cloudflare |
| `data.meta.subject`, `reply_to` | From the `_subject` and `_replyto` fields |
| `data.created_at` | When the submission was stored |
| `data.truncated` | `true` when the body would be over 256 KB, in which case `fields` is empty |

Any `meta` value can be `null`. Treat field values as untrusted input, just like a form post to your own server.

## Verify the signature

The signature is an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`, then base64-encoded and sent after `v1,` in `webhook-signature`.

- Use the raw request body exactly as received. Parsing and re-serializing JSON changes the bytes.
- The header can hold several space-separated signatures. Accept the request if any `v1` signature matches.
- Compare in constant time, and reject timestamps more than 5 minutes from now.

### With the SDK

`verifyWebhook` does all of the above with Web Crypto and returns the parsed event, or throws `WebhookVerificationError` with a `reason`. It accepts a `Headers` object or a plain record such as Node's `req.headers`.

`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;
  }
}
```

### Without the SDK

Any Standard Webhooks library works too. By hand it takes a few lines:

**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
```

### Signature verifier

The interactive verifier on this page checks a secret, the three webhook headers and a raw body with the SDK's `verifyWebhook`, entirely in the browser. It can also generate a correctly signed example delivery. Nothing you paste leaves the page.

## Responding

| Your response | What Ikiform does |
| --- | --- |
| Any `2xx` within 15 seconds | Marks the delivery succeeded |
| `410 Gone` | Fails the delivery and disables the endpoint right away |
| `429` or `503` with `Retry-After` | Retries after that delay, but no later than the next step of the schedule |
| A redirect | Not followed. Counts as a failure |
| Any other status, a timeout or a network error | Retries on the schedule below |

Do slow work after responding, for example in a queue. The first 4 KB of your response body is saved in the delivery log.

## Retries

A failed attempt is retried on this schedule, each delay with up to 20% random jitter either way. After the 9th attempt, about 3 d 6 h after the first, the delivery is marked failed.

| Attempt | Delay after the previous attempt | Time since the first attempt |
| --- | --- | --- |
| 1 | Immediately | 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 d | 1 d 18 h |
| 9 | 1 d 12 h | 3 d 6 h |

### Disabled endpoints

If every delivery to an endpoint fails for 5 days, the endpoint is disabled and workspace owners get an email. A `410 Gone` response or a destination that becomes private disables it immediately. Turn it back on from the endpoint page once it is fixed.

## Delivery logs

Each endpoint page lists its deliveries with their status. Expand one to see every attempt: when it ran, whether it was automatic, manual or a test, the status code, the duration, and the first 4 KB of your response or the error. Retry any delivery from there. Attempts are kept for 30 days and deliveries for 90 days.

## Rotating secrets

**Roll signing secret** creates a new secret. For the next 24 hours every delivery is signed with both the old and the new secret, so `webhook-signature` holds two values and you can deploy the new secret without dropping events.
