PageWeave / Docs

Platform

Markdown

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:

{
  "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
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)
// 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.