# Webhooks

Part of the sendm8 docs: https://sendm8.com/docs#webhooks. Index for AI assistants: https://sendm8.com/llms.txt

Add a webhook in **Form settings → Channels → Webhook** and we’ll `POST` every submission to your URL as JSON. It needs to be `https` and publicly reachable: private IP ranges and bare IP addresses are blocked. You’ll get a signing secret when you add it.

### The request

`Headers`

```http
POST /hooks/sendm8 HTTP/1.1
Content-Type: application/json
User-Agent: sendm8-webhooks
X-Sendm8-Signature: t=1789400527,v1=5967b4fd69839aeb4fabdbcee4b69e8e2f3f690d2602b67034f1b30382e1b79e
```

`Body`

```json
{
  "event": "submission.created",
  "id": "01K53N7XQ2R8M4DE7Q2K9F4X3M",
  "tracking": "SM8 7Q2K 9F4X 3M",
  "created_at": "2026-09-14T15:42:07Z",
  "form": { "id": "k3x9q2m7ab", "name": "Studio contact" },
  "data": {
    "name": "Alex Moreno",
    "email": "alex@moreno.dev",
    "message": "Hiya! Are you free for a quick project in October?"
  },
  "files": [],
  "spam": { "score": 0.04, "verdict": "ok" }
}
```

### Checking the signature

Anyone can post to a public URL, so check that a request really came from us. `X-Sendm8-Signature` holds a Unix timestamp `t` and a signature `v1`. The signature is an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw request body, using your signing secret.

**verify.js**

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

const FIVE_MINUTES = 5 * 60;

// rawBody must be the exact bytes we sent, before any JSON parsing.
export function verifySendm8(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;

  // Ignore old deliveries, so a captured request can't be replayed later
  if (Math.abs(Date.now() / 1000 - t) > FIVE_MINUTES) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Using it: `worker.js`**

```js
// Example: a fetch-style handler (Cloudflare Workers with nodejs_compat, Bun, Deno)
export default {
  async fetch(request, env) {
    const raw = await request.text();
    const header = request.headers.get("X-Sendm8-Signature") ?? "";

    if (!verifySendm8(raw, header, env.SENDM8_WEBHOOK_SECRET)) {
      return new Response("Bad signature", { status: 401 });
    }

    const submission = JSON.parse(raw);
    // ...do something with submission.data
    return new Response(null, { status: 204 });
  },
};
```

> **Delivery and retries**
>
> Reply with any `2xx` within 5 seconds. Do slow work after you’ve answered. If we get an error or a timeout, we try again after 1m, 5m, 30m, 2h, 12h: 5 more attempts in all. Each retry has a fresh timestamp and signature, so it’s worth using the submission `id` to ignore duplicates.
