Send your leads anywhere
Every response, and every person who didn't finish, can go to your CRM or spreadsheet the moment it happens. Webhooks, the API and Zapier are part of Pro and Agency.
Webhooks
In Wunderly, open Integrations, press Add endpoint, paste the address that should receive events, and choose all forms or some. You'll see the endpoint's signing secret once: keep it with the code that checks signatures.
- Each event is a
POSTwith a JSON body:{id, type, created_at, workspace_id, form_id, data}. - Answer with any
2xxwithin 10 seconds. Anything else, or no answer, is a failure. - After a failure we try again at 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours. A retry carries the same
id, so store the ids you've handled and skip repeats. - Ten failures in a row pause the endpoint and we email you. The last 100 deliveries stay on the Integrations page, where Retry sends any of them again and Send test event sends a
testevent. - Headers:
Wunderly-Signature,Wunderly-Event-Id,Wunderly-Event-Type.
Check the signature
Every delivery carries Wunderly-Signature: t=1760000000,v1=5257a8…. t is when we sent it, in Unix seconds; v1 is the hex HMAC-SHA256 of t, a dot, and the raw body, keyed with your endpoint's secret. Compute it from the body exactly as it arrived (before any JSON parsing), compare in constant time, and refuse anything older than five minutes.
Node
// Check a Wunderly webhook before trusting it: the raw request body,
// the Wunderly-Signature header, and your endpoint's signing secret.
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
export function verifyWunderly(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((p) => p.split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const hex = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const expected = Buffer.from(hex);
const given = Buffer.from(parts.v1);
return (
expected.length === given.length && timingSafeEqual(expected, given)
);
}
// Try it on a saved delivery: node verify.mjs body.json "t=...,v1=..."
// with the secret in WUNDERLY_WEBHOOK_SECRET.
const script = process.argv[1];
if (script && import.meta.url === pathToFileURL(script).href) {
const body = readFileSync(process.argv[2], "utf8");
const secret = process.env.WUNDERLY_WEBHOOK_SECRET;
const ok = verifyWunderly(body, process.argv[3], secret);
console.log(ok ? "verified" : "rejected");
}
Python
# Check a Wunderly webhook before trusting it: the raw request body,
# the Wunderly-Signature header, and your endpoint's signing secret.
import hashlib
import hmac
import os
import sys
import time
def verify_wunderly(
raw_body: bytes, header: str, secret: str, tolerance: int = 300
) -> bool:
parts = dict(
p.split("=", 1) for p in (header or "").split(",") if "=" in p
)
t = parts.get("t", "")
v1 = parts.get("v1")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > tolerance:
return False
message = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256)
return hmac.compare_digest(expected.hexdigest(), v1)
# Try it on a saved delivery: python verify.py body.json "t=...,v1=..."
# with the secret in WUNDERLY_WEBHOOK_SECRET.
if __name__ == "__main__":
with open(sys.argv[1], "rb") as f:
body = f.read()
secret = os.environ["WUNDERLY_WEBHOOK_SECRET"]
ok = verify_wunderly(body, sys.argv[2], secret)
print("verified" if ok else "rejected")
PHP
<?php
// Check a Wunderly webhook before trusting it: the raw request body,
// the Wunderly-Signature header, and your endpoint's signing secret.
// In a handler the body is file_get_contents('php://input') and the
// header is $_SERVER['HTTP_WUNDERLY_SIGNATURE'].
function verify_wunderly(
string $rawBody,
?string $header,
string $secret,
int $tolerance = 300
): bool {
$parts = [];
foreach (explode(',', $header ?? '') as $part) {
$kv = explode('=', $part, 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$message = $parts['t'] . '.' . $rawBody;
$expected = hash_hmac('sha256', $message, $secret);
return hash_equals($expected, $parts['v1']);
}
// Try it on a saved delivery: php verify.php body.json "t=...,v1=..."
// with the secret in WUNDERLY_WEBHOOK_SECRET.
$script = $argv[0] ?? null;
if (PHP_SAPI === 'cli' && $script && realpath($script) === __FILE__) {
$body = file_get_contents($argv[1]);
$secret = getenv('WUNDERLY_WEBHOOK_SECRET');
$ok = verify_wunderly($body, $argv[2], $secret);
echo $ok ? "verified\n" : "rejected\n";
}
Events
| Type | When | What data holds |
|---|---|---|
response.completed | Someone finished a form. | The answers, the estimate, their email, the consent they gave. |
lead.unfinished | Someone stopped partway and left an email we can reach. Sent once, 30 minutes after we mark them gone (an hour after their last answer), and only if they haven't come back. | Their partial answers, the estimate they saw, the question they stopped at, their consent. |
lead.recovered | Someone finished after the one reminder reached them, counted the way Recovered is. | Who, the reminder, and the amount, with its definition. |
reminder.sent | The one reminder went out. | The reminder, its kind, and who it went to. |
reminder.delivered | Their mail server accepted it. | The same as reminder.sent. |
invitee.replied | A named guest answered their personal link. | The guest, their name and email, the session. |
lead.deleted | Someone used "Delete my data". Sent once to each endpoint; nothing about that person is sent after it. | Ids only: session_ids, invitee_ids, response_ids. Delete what you hold under them. |
Every number carries the id of its definition, like "definition": "estimate", so a report built from them can link to how we count. Someone who unsubscribed still finished the form, so the event still goes; their address doesn't, anywhere in it.
An unfinished lead
{
"id": "evt_lead_unfinished_ses_8f2kq0a1b2c3",
"type": "lead.unfinished",
"created_at": "2026-10-06T15:42:00.000Z",
"workspace_id": "ws_northwoods",
"form_id": "frm_deckquote",
"data": {
"session_id": "ses_8f2kq0a1b2c3",
"form": {
"id": "frm_deckquote",
"title": "Deck quote"
},
"started_at": "2026-10-06T14:37:00.000Z",
"last_seen_at": "2026-10-06T14:42:00.000Z",
"email": "dana@example.com",
"answers": [
{
"block_id": "sqft",
"label": "How big is the deck?",
"type": "number",
"value": 320,
"text": "320 sq ft"
},
{
"block_id": "boards",
"label": "Which boards?",
"type": "single_choice",
"value": "cedar",
"text": "Cedar"
}
],
"estimate": {
"value": 4650,
"currency": "USD",
"sentence": "320 sq ft of cedar with stairs",
"definition": "estimate"
},
"stopped_at": {
"block_id": "stairs",
"label": "Stairs down to the yard?"
},
"consent": "implied",
"channel": "embed"
}
}A finished response
{
"id": "evt_response_completed_ses_8f2kq0a1b2c3",
"type": "response.completed",
"created_at": "2026-10-06T15:42:00.000Z",
"workspace_id": "ws_northwoods",
"form_id": "frm_deckquote",
"data": {
"session_id": "ses_8f2kq0a1b2c3",
"response_id": "rsp_3m9x2c1v0b8n",
"form": {
"id": "frm_deckquote",
"title": "Deck quote"
},
"submitted_at": "2026-10-06T15:42:00.000Z",
"email": "dana@example.com",
"invitee_id": null,
"answers": [
{
"block_id": "sqft",
"label": "How big is the deck?",
"type": "number",
"value": 320,
"text": "320 sq ft"
},
{
"block_id": "boards",
"label": "Which boards?",
"type": "single_choice",
"value": "cedar",
"text": "Cedar"
},
{
"block_id": "stairs",
"label": "Stairs down to the yard?",
"type": "yes_no",
"value": true,
"text": "Yes"
}
],
"estimate": {
"value": 4650,
"currency": "USD",
"sentence": "320 sq ft of cedar with stairs",
"definition": "estimate"
},
"consent": "implied",
"channel": "embed"
}
}You decide what happens to the people in these events, as the controller of their data; the data processing terms cover what we do with it. When someone deletes their data, you get lead.deleted: delete it on your side too.
Zapier
Wunderly's Zapier app is built and waiting on Zapier's review. Once it's listed it has four triggers (New Response, New Unfinished Lead, Lead Came Back, Guest Replied) and one action (Add Guest). It connects with an API key you make on the Integrations page.
Wunderly to Google Sheets
New Response → Create Spreadsheet Row. Map the submitted time, the email, the estimate's value and each answer's text to columns.
Wunderly to HubSpot
New Unfinished Lead → Create or Update Contact. Map the email, and put the estimate and the question they stopped at in a note.
Wunderly to Gmail
Lead Came Back → Send Email to yourself: "{email} came back", with the amount and the form's title.