---
title: "Documentation — PageWeave"
---

> For the full documentation index, see [PageWeave Documentation](https://pageweave.dev/docs) or fetch [llms.txt](https://pageweave.dev/llms.txt).

# Webhooks

Webhooks deliver form submissions to your own endpoint as signed HTTPS POSTs — no polling. Add an endpoint on the website's **Webhooks** page (or create one with the `create_webhook` MCP tool) and every matching submission is POSTed to your URL as JSON.

Respond with any **2xx** status within **10 seconds** to acknowledge a delivery. Anything else — a 3xx included; redirects are never followed — counts as a failed delivery and is retried on the schedule below. Acknowledge quickly and process asynchronously.

## Events

| Event | Fires when |
| --- | --- |
| `form.submission.created` | A form submission is accepted. Submissions flagged as spam are delivered too, marked in `spam`; bot-discarded submissions never fire. |

Delivery payload:

```json
{
  "event": "form.submission.created",
  "timestamp": "2026-10-18T09:15:00Z",
  "data": {
    "submission_id": "b3f1c2d4-16a6-4f6e-9f8e-2d3c4b5a6c7d",
    "form_id": "def-456",
    "form_name": "Contact",
    "fields": { "name": "Alice", "email": "alice@example.com" },
    "files": [
      {
        "field": "attachment",
        "filename": "brief.pdf",
        "size": 24821,
        "content_type": "application/pdf",
        "checksum_md5": "9e107d9d372bb6826bd81d3542a419d6",
        "url": "https://pageweave.dev/f/..."
      }
    ],
    "submitted_at": "2026-10-18T09:14:58Z",
    "spam": { "verdict": "ham", "score": 0, "signals": {} }
  }
}
```

- `timestamp` is the delivery attempt time (refreshed on every retry); `data.submitted_at` is when the visitor submitted.
- `files` appears for submissions with uploads. Each `url` is a permanent capability link — anyone holding it can download while the submission exists, so treat it as a secret.
- `spam.verdict` is `ham` or `quarantined` — automations that don't want spam can filter on it.

## Adding an endpoint

Website dashboard → **Webhooks** → add your endpoint URL, pick the events, and optionally restrict it to specific forms. Endpoints created via MCP start **paused**: reveal the signing secret on the endpoint page, wire up verification, then enable it (`update_webhook active: true`).

## Verifying signatures

Every request carries `X-Webhook-Signature: sha256=<hex>` — the HMAC-SHA256 of the **raw request body** under the endpoint's signing secret — plus `X-Webhook-Event` and `Content-Type: application/json`.

1. Compute HMAC-SHA256 over the raw body bytes *before* your framework parses JSON. Re-serializing the parsed payload changes whitespace and key order and breaks verification — the classic integration failure.
2. Compare with a constant-time function, never `==`.
3. Rotating the secret on the dashboard invalidates the old one immediately — update your verifier first, then rotate.

```ruby
# Ruby
body = request.raw_post # raw bytes — verify before JSON.parse
expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("WEBHOOK_SECRET"), body)}"
signature = request.headers["X-Webhook-Signature"].to_s
authorized = ActiveSupport::SecurityUtils.secure_compare(expected, signature)
```

```js
// Node
const crypto = require("crypto");

function verify(rawBody, signature) {
  const expected =
    "sha256=" + crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(rawBody).digest("hex");
  return (
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}
```

## Retries

A failed delivery is retried at increasing intervals after each failure:

| Attempt | Delay after previous failure |
| --- | --- |
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |

Seven attempts span roughly 19 hours, with small random jitter. `404` and `410` responses are permanent — the route is gone, so these are not retried — but they still count toward auto-disable.

## Auto-disable and recovery

Consecutive failed deliveries are counted per endpoint: **5 in a row** when the cause is permanent (404, 410, a blocked private address), or **20 in a row** for everything else. Crossing the threshold disables the endpoint and emails everyone with website management access — endpoint URL, last response, failure count, and a link to re-enable it. A single successful delivery resets the count.

- **Redeliver** any logged delivery from the endpoint page — an honest new attempt with the same payload.
- **Resume** re-enables the endpoint and resets the failure count, so the first new failure doesn't re-disable it.
- Delivery history (request payload, response code and body) is kept for **30 days**.

## Idempotency and ordering

Delivery is at-least-once: retries and manual redeliveries can deliver the same event more than once. Deduplicate on `data.submission_id`. Deliveries are not guaranteed to arrive in order — treat each event independently.

## Testing

**Send test event** on the endpoint page posts a sample `form.submission.created` to your URL and records the attempt in the delivery log — verify your signature check and response code before going live.

## Troubleshooting

- **Signature never verifies** — verify the raw body byte-for-byte (parse-then-reserialize is the usual culprit), confirm you're using that endpoint's secret, and compare with a constant-time function.
- **Events arrive but stay marked failed** — any non-2xx counts as a failure; return 2xx as soon as the event is durably queued.
- **Timeouts** — respond within 10 seconds: enqueue the payload and acknowledge, then process it.
- **Nothing arrives at all** — the endpoint may be paused or auto-disabled; the endpoint page shows its state, and failed deliveries from the last 30 days are redeliverable.
