Wunderly

Docs

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 POST with a JSON body: {id, type, created_at, workspace_id, form_id, data}.
  • Answer with any 2xx within 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 test event.
  • 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

Webhook events
TypeWhenWhat data holds
response.completedSomeone finished a form.The answers, the estimate, their email, the consent they gave.
lead.unfinishedSomeone 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.recoveredSomeone finished after the one reminder reached them, counted the way Recovered is.Who, the reminder, and the amount, with its definition.
reminder.sentThe one reminder went out.The reminder, its kind, and who it went to.
reminder.deliveredTheir mail server accepted it.The same as reminder.sent.
invitee.repliedA named guest answered their personal link.The guest, their name and email, the session.
lead.deletedSomeone 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.