Send a Webhook
The webhook action POSTs a JSON envelope to an HTTPS endpoint you own. Every delivery is signed with the automation’s secret, retried on failure, and carries a delivery id you can use to dedupe.
Webhook actions are available on paid plans.
Configuration
Section titled “Configuration”| Field | Rules |
|---|---|
| Endpoint URL | https:// only, public host. Loopback, private and reserved IP ranges are rejected when you save |
| Payload shape | SnipForm envelope (default, recommended) or v1 form payload for endpoints built against the old per-form webhooks |
When you save an automation that has a webhook action, its signing secret (whsec_ followed by 40 random characters) is shown once. Store it with your endpoint’s configuration.
The request
Section titled “The request”POST /hooks/snipform HTTP/1.1Host: your-endpoint.example.comContent-Type: application/jsonUser-Agent: SnipForm-Webhooks/1X-SnipForm-Event: form_submitX-SnipForm-Event-Id: 01J5X4M9ZK7Q8R2T3V6W8Y0ABCX-SnipForm-Delivery: 01J5X4M9ZK7Q8R2T3V6W8Y0ABCX-SnipForm-Signature: t=1755777600,v1=5d41402abc4b2a76b9719d911017c592e3b0c44298fc1c149afbf4c8996fb924| Header | Value |
|---|---|
X-SnipForm-Event | The trigger type: form_submit, event, page_view, acquisition, identify or contact |
X-SnipForm-Event-Id | The envelope id |
X-SnipForm-Delivery | The envelope id (same value today; kept separate so a future re-send of the same event under a new delivery can be told apart) |
X-SnipForm-Signature | t={unix seconds},v1={hex HMAC} |
Your endpoint has 10 seconds to respond. Any 2xx counts as delivered; anything else, or a timeout, is a failure.
Verifying the signature
Section titled “Verifying the signature”The signature is Stripe-style: an HMAC-SHA256 of the string "{t}.{raw body}" using the automation secret as the key, hex-encoded.
signed_payload = t + "." + raw_request_bodyv1 = hex( HMAC_SHA256( key = secret, message = signed_payload ) )Compute it over the raw bytes you received, before any JSON parsing, and compare in constant time.
function verifySnipFormSignature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool{ $parts = []; foreach (explode(',', $header) as $pair) { [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); $parts[$key] = $value; } if (empty($parts['t']) || empty($parts['v1'])) { return false; } if (abs(time() - (int) $parts['t']) > $toleranceSeconds) { return false; }
$expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);
return hash_equals($expected, $parts['v1']);}
// LaravelRoute::post('/hooks/snipform', function (Request $request) { $ok = verifySnipFormSignature( $request->getContent(), (string) $request->header('X-SnipForm-Signature'), config('services.snipform.webhook_secret'), ); if (! $ok) { abort(401); }
$envelope = $request->json()->all(); // ... handle, ideally by queueing on $envelope['id']
return response()->noContent();});import crypto from 'node:crypto';
export function verifySnipFormSignature(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( header.split(',').map((pair) => pair.split('=', 2)), ); if (!parts.t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest('hex');
const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express: keep the raw bodyapp.post('/hooks/snipform', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifySnipFormSignature( req.body.toString('utf8'), req.get('X-SnipForm-Signature') ?? '', process.env.SNIPFORM_WEBHOOK_SECRET, ); if (!ok) return res.sendStatus(401);
const envelope = JSON.parse(req.body); // ... handle res.sendStatus(204);});A tolerance of a few minutes is safe: retries are new requests with a fresh t and a fresh signature over the same body.
Retries and idempotency
Section titled “Retries and idempotency”A failed delivery is retried after 2 minutes and again after 15 minutes (three attempts in total). Every attempt sends identical body bytes; only the timestamp and signature change. Use the envelope id (also in X-SnipForm-Delivery) as your idempotency key so a slow first attempt that eventually succeeded on your side is not processed twice.
After three failures the delivery is marked failed in the run log. Repeated failures auto-disable the automation; see Retries and failure.
The envelope
Section titled “The envelope”Version 1. Keys within a version are never removed or renamed; a breaking change would bump version.
{ "id": "01J5X4M9ZK7Q8R2T3V6W8Y0ABC", "version": 1, "occurred_at": "2026-08-21T10:00:00+00:00", "property_id": "64f1c2e9a1b2c3d4e5f60700", "automation": { "id": "66c0a1b2c3d4e5f607080910", "name": "New demo request" }, "trigger": { "event_type": "form_submit", "event_value": null }, "data": { "event": { "id": "01J5X4M8AB...", "type": "form_submit", "name": null, "value": null, "meta": [], "page_path": "/contact", "page_url": "https://example.com/contact", "form_id": "64f1c2e9a1b2c3d4e5f60711", "form_submit_id": "66c0a1b2c3d4e5f607080999", "created_ts": 1755770400 }, "session": { "id": "b6f3d2c1e0a9...", "url": "https://app.snipform.io/property/.../sessions/b6f3d2c1e0a9...", "entry_ts": 1755769800, "entry_path": "/pricing", "channel_category": "paid_search", "channel_name": "Google Ads", "contact_id": null, "country": "United States", "acquisition_value_cents": null, "acquisition_currency_code": null }, "conversion": null, "form_submit": { "id": "66c0a1b2c3d4e5f607080999", "form": { "id": "64f1c2e9a1b2c3d4e5f60711", "name": "Demo request" }, "fields": { "Name": "Jane Doe", "Email": "[email protected]", "Message": "We'd like a walkthrough." }, "country": "United States", "city": "Austin", "ip": "203.0.113.42", "geo": { "lat": 30.27, "lon": -97.74 }, "is_bot": false, "is_flagged": false, "raw_fields": [ { "type": "text", "name": "name", "label": "Name", "value": "Jane Doe" }, { "type": "textarea", "name": "message", "label": "Message", "value": "We'd like a walkthrough." } ], "submitted_ts": 1755770400 } }}Top level
Section titled “Top level”| Key | Type | Notes |
|---|---|---|
id | ULID string | Unique per delivery. Your idempotency key |
version | int | 1 |
occurred_at | ISO 8601 UTC | When the run was processed |
property_id | string | The SnipForm property |
automation | {id, name} | name falls back to the trigger label if the automation is unnamed |
trigger | {event_type, event_value} | What the automation subscribes to. See Triggers |
test | true | Present only on test sends from the builder. Absent on real deliveries |
data.event
Section titled “data.event”The signal that tripped the run. null on test sends.
| Key | Notes |
|---|---|
type | Same vocabulary as trigger.event_type |
name | Event name for event and contact (created/updated) triggers; null otherwise |
value | The event’s value as sent (string), or null |
meta | Array of {key, value} pairs |
page_path, page_url | Where the signal happened |
form_id, form_submit_id | Set for form submissions |
created_ts | Unix seconds |
data.session
Section titled “data.session”The session the signal belongs to. Money is integer cents.
| Key | Notes |
|---|---|
id | Session id. Reusable with the API |
url | Deep link to the session in the dashboard |
entry_ts, entry_path | When and where the session started |
channel_category, channel_name | Attribution, e.g. paid_search / Google Ads. See Channels |
contact_id | Set once the session is identified |
country | Country name |
acquisition_value_cents, acquisition_currency_code | Revenue on the session, if any |
data.conversion
Section titled “data.conversion”{id, name} when the automation has a conversion condition and it was met; otherwise null.
data.form_submit
Section titled “data.form_submit”Present only when the trigger is a form submission and the submission could be loaded; otherwise null.
| Key | Notes |
|---|---|
fields | Object keyed by field label, valued by the answer. Multi-value answers are strings joined with , |
raw_fields | Array of {type, name, label, value} in form order, with the field’s name attribute. Use this when labels are not stable |
country, city, geo, ip | Where the submission came from. ip is the submitter’s IP address; treat it as personal data on your side |
is_bot | Bot classification of the submitting client |
is_flagged | true when the spam scorer flagged but did not reject the submission |
submitted_ts | Unix seconds |
Legacy v1 form payload
Section titled “Legacy v1 form payload”Choose v1 form payload on the action to receive the body the pre-automations per-form webhook sent, byte-for-byte:
{ "triggered_ts": 1755770405, "form_id": "64f1c2e9a1b2c3d4e5f60711", "form": "Demo request", "ip": "203.0.113.42", "location": "United States", "city": "Austin", "geo": { "lat": 30.27, "lon": -97.74 }, "is_bot": "no", "data_full": [ { "type": "text", "name": "name", "label": "Name", "value": "Jane Doe" } ]}The headers and signature are the same as for the envelope. The v1 shape only carries form data, so it is only useful on the “A form is submitted” trigger; on any other trigger most keys are null.
Test deliveries
Section titled “Test deliveries”The builder’s Test button sends the envelope with "test": true, a sample session and, for form triggers, a sample submission (Jane Doe, [email protected]). It is signed and delivered exactly like a real run, so you can verify your endpoint end to end without generating traffic.