Skip to content

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.

FieldRules
Endpoint URLhttps:// only, public host. Loopback, private and reserved IP ranges are rejected when you save
Payload shapeSnipForm 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.

POST /hooks/snipform HTTP/1.1
Host: your-endpoint.example.com
Content-Type: application/json
User-Agent: SnipForm-Webhooks/1
X-SnipForm-Event: form_submit
X-SnipForm-Event-Id: 01J5X4M9ZK7Q8R2T3V6W8Y0ABC
X-SnipForm-Delivery: 01J5X4M9ZK7Q8R2T3V6W8Y0ABC
X-SnipForm-Signature: t=1755777600,v1=5d41402abc4b2a76b9719d911017c592e3b0c44298fc1c149afbf4c8996fb924
HeaderValue
X-SnipForm-EventThe trigger type: form_submit, event, page_view, acquisition, identify or contact
X-SnipForm-Event-IdThe envelope id
X-SnipForm-DeliveryThe 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-Signaturet={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.

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_body
v1 = 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']);
}
// Laravel
Route::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();
});

A tolerance of a few minutes is safe: retries are new requests with a fresh t and a fresh signature over the same body.

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.

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": "email", "name": "email", "label": "Email", "value": "[email protected]" },
{ "type": "textarea", "name": "message", "label": "Message", "value": "We'd like a walkthrough." }
],
"submitted_ts": 1755770400
}
}
}
KeyTypeNotes
idULID stringUnique per delivery. Your idempotency key
versionint1
occurred_atISO 8601 UTCWhen the run was processed
property_idstringThe 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
testtruePresent only on test sends from the builder. Absent on real deliveries

The signal that tripped the run. null on test sends.

KeyNotes
typeSame vocabulary as trigger.event_type
nameEvent name for event and contact (created/updated) triggers; null otherwise
valueThe event’s value as sent (string), or null
metaArray of {key, value} pairs
page_path, page_urlWhere the signal happened
form_id, form_submit_idSet for form submissions
created_tsUnix seconds

The session the signal belongs to. Money is integer cents.

KeyNotes
idSession id. Reusable with the API
urlDeep link to the session in the dashboard
entry_ts, entry_pathWhen and where the session started
channel_category, channel_nameAttribution, e.g. paid_search / Google Ads. See Channels
contact_idSet once the session is identified
countryCountry name
acquisition_value_cents, acquisition_currency_codeRevenue on the session, if any

{id, name} when the automation has a conversion condition and it was met; otherwise null.

Present only when the trigger is a form submission and the submission could be loaded; otherwise null.

KeyNotes
fieldsObject keyed by field label, valued by the answer. Multi-value answers are strings joined with ,
raw_fieldsArray of {type, name, label, value} in form order, with the field’s name attribute. Use this when labels are not stable
country, city, geo, ipWhere the submission came from. ip is the submitter’s IP address; treat it as personal data on your side
is_botBot classification of the submitting client
is_flaggedtrue when the spam scorer flagged but did not reject the submission
submitted_tsUnix seconds

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": { "Name": "Jane Doe", "Email": "[email protected]" },
"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.

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.