Webhooks

Configure an Event Webhook URL (and optional signing secret) in Funnel settings → Advanced. The platform POSTs JSON to your HTTPS endpoint when something meaningful happens on the live published funnel — similar in spirit to FunnelFox webhooks (outbound POST, verify a secret header, respond quickly, expect possible duplicates without retries on our side).

Security

  • URL: https:// only. Localhost and private IPs are rejected when we call your URL (SSRF guard).
  • Signing: If you set a Webhook signing secret in funnel settings, every request includes header W2A-Webhook-Secret with exactly that value. Compare it on your server before trusting the body (like FunnelFox’s Fox-Secret-Key).
  • The stored secret is never shown again in the dashboard; you can rotate it or remove it from the same form.
  • Workspace safety: payment events are matched to the same workspace as your funnel before they update Subscribers or trigger purchase.completed.

Delivery

ItemBehavior
MethodPOST
Content-Typeapplication/json
Timeout10 seconds — return 2xx quickly
RetriesNone — if your endpoint errors, we do not retry

Payload shape (api_version: "1")

All events use one envelope:

{
  "api_version": "1",
  "id": "evt_…",
  "type": "funnel.step_viewed | funnel.started | lead.captured | purchase.completed | purchase.claimed | subscription.renewed | subscription.payment_failed | subscription.canceled | refund.created",
  "created_at": "2026-04-04T12:00:00.000Z",
  "livemode": true,
  "funnel": {
    "id": "uuid",
    "slug": "my-funnel",
    "name": "My funnel"
  },
  "session_id": "string or null",
  "data": {},
  "legacy": {}
}
  • legacy — optional object with the older flat fields some automations may still expect (see below).
  • livemodefalse when the triggering Stripe event was in test mode.

Event types

funnel.started

First step view in a browser session (maps from internal funnel_view). session_id is set.

data includes step_id plus fields from the tracker (step_type, step_index, UTM params when present).

legacy: { funnelId, stepId, sessionId, eventType: "funnel_view", properties, timestamp }.

funnel.step_viewed

Visitor opened another step (step_view). Same data / legacy pattern with eventType: "step_view".

Preview mode does not hit the tracker — no webhook.

lead.captured

After a successful email capture step on a published funnel.

data:

{
  "email": "[email protected]",
  "name": "string or null",
  "step_id": "uuid or null",
  "properties": {}
}

Use this to trigger welcome emails, CRM leads, etc. legacy mirrors the previous { event: "lead_captured", … } object.

purchase.completed

Fired from the server when Stripe or Paddle confirms a successful charge (workspace Payment webhooks in Settings — not the browser). Includes money for reporting and automation.

data (Stripe example):

{
  "processor": "stripe",
  "purchase_token": "pur_...",
  "access_code": "K7MZQ",
  "amount_minor": 1999,
  "currency": "usd",
  "plan_id": "plan_abc",
  "customer_email": "[email protected]",
  "stripe_payment_intent_id": "pi_…",
  "stripe_invoice_id": "in_… or null",
  "stripe_subscription_id": "sub_… or null",
  "stripe_event_id": "evt_…"
}

amount_minor — integer in the smallest currency unit (e.g. cents), same convention as Stripe.

Paddle uses processor: "paddle", purchase_token, access_code, paddle_transaction_id, paddle_customer_id, paddle_subscription_id, paddle_notification_event_id, and the same amount_minor / currency (totals converted from Paddle’s decimal strings).

Subscription renewals each produce another purchase.completed when the processor sends a paid invoice / completed transaction.

Stripe timing: purchase.completed is sent for confirmed one-time payments and paid subscription invoices when Stripe includes enough information to match the payment to the funnel.

purchase.claimed

Fired when the Managed Access API links a web purchase to an app user.

{
  "purchase_token": "pur_...",
  "access_code": "K7MZQ",
  "app_user_id": "user_123",
  "platform": "ios",
  "appsflyer_id": "1700000000000-123456789"
}

subscription.canceled

Fired when Stripe/Paddle tells web2app the subscription is canceled.

{
  "processor": "stripe",
  "stripe_subscription_id": "sub_..."
}

subscription.payment_failed

Fired when a recurring payment fails and the local access state becomes past_due. For Stripe this comes from invoice.payment_failed.

{
  "processor": "stripe",
  "stripe_subscription_id": "sub_...",
  "status": "past_due"
}

refund.created

Fired when Stripe/Paddle reports a refund/adjustment. Full refunds revoke local purchase access. Partial refunds keep access active and include full_refund: false.

{
  "processor": "stripe",
  "amount_minor": 1999,
  "full_refund": true,
  "currency": "usd",
  "stripe_charge_id": "ch_...",
  "stripe_payment_intent_id": "pi_..."
}

Paddle refunds/adjustments use Paddle identifiers:

{
  "processor": "paddle",
  "paddle_transaction_id": "txn_...",
  "paddle_adjustment_id": "adj_...",
  "full_refund": true
}

What is not sent here

  • Workspace Stripe/Paddle inbound webhooks (signing secrets in Settings → Payment) are separate — they update Subscribers and trigger these outbound funnel webhooks when appropriate.
  • Client-only GTM / dataLayer / Pixel events are not duplicated to this URL.
  • AppsFlyer OneLink clicks/installs are not sent here by AppsFlyer; configure AppsFlyer postbacks/S2S separately if you need those in your systems.

Best practices

  1. Verify W2A-Webhook-Secret (if configured) before parsing the body.
  2. Respond 200 immediately and queue work asynchronously (avoid timeouts).
  3. Use id for idempotency if you might process the same logical event twice (we do not retry, but you may change endpoints or replay).
  4. Prefer type + data for new integrations; keep legacy only while migrating old Zaps.

Related