# Webhooks Send signed webhook events to your endpoint on every form submission, verify the HMAC signature, and rely on automatic retries with full delivery history. Configure a webhook URL on a form and Form Plume posts to it every time a submission is accepted. Use it to push new leads into your API, CRM adapter, automation tool, worker, or internal database without polling the dashboard. ## Payload Webhook requests send a JSON event. `submission.created` is fired after a submission passes validation, [spam checks](/docs/spam-protection), quota checks, and storage: ```json { "id": "evt_4f9a2b1c8e3d7f60", "type": "submission.created", "created_at": "2026-07-01T14:32:05Z", "data": { "submission_id": "b3e1c9a0-...", "form_id": "1a2b3c4d-...", "form_name": "Contact", "submitter_email": "ava@example.com", "fields": { "name": "Ava Example", "email": "ava@example.com", "message": "Can we book a demo?" }, "submitted_at": "2026-07-01T14:32:05Z", "test": false } } ``` Use `data.submission_id` to look up the full submission, [attachments](/docs/submissions/attachments), [metadata](/docs/submissions/metadata), delivery history, and any later status changes from the dashboard or the API. `data.fields` contains the accepted form fields after Form Plume has removed reserved control fields such as [`_gotcha`](/docs/forms/special-fields), [CAPTCHA tokens](/docs/spam-protection/captcha), and [upload control metadata](/docs/forms/file-uploads). `test` is `true` for events fired from the dashboard's "send test event" button. Most people just log those and skip further processing. ## Verifying the signature Every request carries signature and event headers so you can confirm it actually came from Form Plume, group retries in your logs, and reject replayed requests: ```text FormPlume-Signature: t=1719840000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd FormPlume-Event-Id: evt_4f9a2b1c8e3d7f60 FormPlume-Timestamp: 1719840000 ``` `t` is a Unix timestamp. `v1` is `HMAC-SHA256(secret, "{t}.{raw_body}")` in hex. Reject timestamps more than **5 minutes** away from your current server time, then recompute the signature yourself and compare: ```js import { createHmac, timingSafeEqual } from "node:crypto"; function isValid(rawBody, signatureHeader, secret) { const parts = signatureHeader.split(",").map((part) => part.trim()); const timestamp = parts.find((part) => part.startsWith("t="))?.split("=")[1]; const signatures = parts .filter((part) => part.startsWith("v1=")) .map((part) => part.split("=")[1]); if (!timestamp || signatures.length === 0) { return false; } const timestampSeconds = Number(timestamp); if (!Number.isFinite(timestampSeconds)) { return false; } const ageMs = Math.abs(Date.now() - timestampSeconds * 1000); if (ageMs > 5 * 60 * 1000) { return false; } const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); const b = Buffer.from(expected); return signatures.some((signature) => { const a = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); }); } ``` ```python import hashlib import hmac import time def is_valid(raw_body: bytes, signature_header: str, secret: str) -> bool: parts = [part.strip() for part in signature_header.split(",")] timestamp = next((part.split("=")[1] for part in parts if part.startswith("t=")), None) signatures = [part.split("=")[1] for part in parts if part.startswith("v1=")] if timestamp is None or not signatures: return False age_seconds = abs(time.time() - int(timestamp)) if age_seconds > 5 * 60: return False expected = hmac.new( secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256, ).hexdigest() return any(hmac.compare_digest(signature, expected) for signature in signatures) ``` > Always compare with a constant-time function, `timingSafeEqual`, > `hmac.compare_digest`, or your language's equivalent. > > A naive `===` leaks timing information an attacker can use to guess the > signature byte by byte. Rotating your webhook secret is safe to do without a delivery gap: the header carries two `v1` values for **24 hours** (current and previous), so a receiver checking against either one keeps working through the rotation. ## Testing and delivery history Use the dashboard's test action after you create or edit an endpoint. Test events use the same signature headers, payload shape, timeout, and delivery history as real submissions, with `data.test` set to `true`. For every delivery attempt, Form Plume records: - The delivery status - HTTP status code, when the endpoint responded - Response time - Error message, when the request failed - A short response excerpt for debugging You can redeliver an individual failed event or redeliver failed events for an endpoint after fixing your receiver. ## Retries Form Plume sends the first attempt immediately. If your endpoint doesn't respond with a `2xx` status, or doesn't respond within **10 seconds**, Form Plume makes up to seven more attempts with exponential backoff. A small random jitter is added to each delay, so retries from many failing webhooks don't all hit your endpoint at the same instant: | Attempt | Delay before this attempt | | --- | --- | | 1 | Immediate | | 2 | Up to 30 seconds | | 3 | Up to 1 minute | | ... | The maximum delay doubles after each failure | | 8 | Up to 32 minutes, then no more automatic attempts | Nothing is ever lost regardless of delivery: every submission is stored and visible in the dashboard whether or not your webhook endpoint ever came back online.