Webhooks
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, quota checks, and storage:
{
"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,
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, CAPTCHA tokens, and upload control
metadata.
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:
FormPlume-Signature: t=1719840000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
FormPlume-Event-Id: evt_4f9a2b1c8e3d7f60
FormPlume-Timestamp: 1719840000t 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:
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);
});
}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.