Skip to content

Project Webhooks ​

Project webhooks send signed server-to-server lead and economic events from funnelsgrove to your backend.

Use them when your app backend needs to keep its own user database in sync with the web funnel. ClaimBee uses this pattern to ingest paid funnel users, update subscription access, and send app download emails.

Configure An Endpoint ​

In Project Settings, add an HTTPS endpoint and select the events it should receive. All project webhooks use v2.

v2 events ​

EventSent when
lead.capturedA funnel user provides an email address. Emitted once per endpoint for the canonical capture.
subscription.activatedA subscription first becomes active or trialing.
subscription.trialingA Stripe subscription is created in or transitions to trialing.
subscription.renewedStripe confirms payment for a new subscription period, including the first full-price charge after a free trial or paid intro.
subscription.cancel_scheduledcancel_at_period_end changes from false to true.
subscription.endedStripe deletes the subscription, or it transitions to incomplete_expired or unpaid.
payment.succeededA one-time payment or subscription invoice payment is confirmed by Stripe.

funnel_user.updated, payment.failed, and refund events are not part of v2. Read payment failures and refunds from Stripe if you need them.

Test sends use signed synthetic V2 data and do not require a real funnel user.

Delivery Envelope ​

Every webhook is a POST with Content-Type: application/json. A v2 envelope includes "apiVersion": "v2":

json
{
  "id": "stripe:evt_123:v2:subscription.activated",
  "type": "subscription.activated",
  "apiVersion": "v2",
  "createdAt": "2026-05-01T16:07:44.761Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {}
}

The envelope id is the event id. V2 Stripe-derived ids are deterministic, so a replay of the same Stripe event is deduplicated for an endpoint. The X-FunnelsGrove-Delivery header is a different id: it identifies the persisted delivery row and is the value used in signature verification.

subscription.trialing reflects Stripe status: an update must contain an actual status change to emit it, and paid introductory pricing reported as active does not generate it. Status events alone do not confirm a successful charge; grant or extend paid access on subscription.renewed and payment.succeeded. These emissions currently come from the Stripe integration.

V2 Payloads ​

All v2 payloads include data.source.provider, data.source.eventId, and data.source.eventType. Funnel user data is intentionally limited to stable identity and Stripe linkage fields; the raw funnel user document is not sent. lead.captured additionally includes quiz answers in data.lead.answers.

Lead captures use the canonical SDK email-capture event as their idempotency source:

json
{
  "id": "sdk:fgev1_example:v2:lead.captured",
  "type": "lead.captured",
  "apiVersion": "v2",
  "createdAt": "2026-08-04T12:00:00.000Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": false,
    "source": {
      "provider": "funnelsgrove",
      "eventId": "fgev1_example",
      "eventType": "email_captured"
    },
    "lead": {
      "capturedAt": "2026-08-04T12:00:00.000Z",
      "email": "user@example.com",
      "answers": { "goal": "improve_memory" },
      "funnelUser": {
        "id": "funnel_user_123",
        "funnelId": "funnel_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "stripeCustomerId": null,
        "stripeSubscriptionId": null
      }
    }
  }
}

data.lead.answers contains the canonical user's saved quiz answers when the email-capture worker builds the delivery, or {} if no answer object exists. An early email step can therefore send empty answers. This is not an onboarding completion signal: later answers do not trigger another delivery. If email capture resolves to an existing canonical user, its saved answers are used, not another visit's answers. Retries reuse the originally persisted payload.

The event id is stable for the canonical email capture. Retries and later email upserts therefore do not create duplicate deliveries for the same endpoint.

Subscription lifecycle events use this shape:

json
{
  "id": "stripe:evt_123:v2:subscription.activated",
  "type": "subscription.activated",
  "apiVersion": "v2",
  "createdAt": "2026-08-04T12:00:00.000Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": false,
    "source": {
      "provider": "stripe",
      "eventId": "evt_123",
      "eventType": "customer.subscription.created"
    },
    "subscription": {
      "id": "billing_subscription_123",
      "funnelId": "funnel_123",
      "funnelEndUserId": "funnel_user_123",
      "user_id": "fg-user-123",
      "provider": "stripe",
      "environment": "live",
      "test": false,
      "providerSubscriptionId": "sub_123",
      "providerCustomerId": "cus_123",
      "providerPlanId": "price_123",
      "status": "active",
      "cancelAtPeriodEnd": false,
      "currentPeriodStart": "2026-08-04T12:00:00.000Z",
      "currentPeriodEnd": "2026-09-04T12:00:00.000Z",
      "canceledAt": null,
      "endedAt": null,
      "funnelUser": {
        "id": "funnel_user_123",
        "funnelId": "funnel_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "stripeCustomerId": "cus_123",
        "stripeSubscriptionId": "sub_123"
      }
    }
  }
}

All subscription events include currentPeriodStart and currentPeriodEnd when Stripe supplies a valid billing period. The fields are null when no period is available. subscription.renewed additionally includes invoiceId, renewedAt and renewalKind. renewalKind is intro_conversion for the first full-price charge after a free trial or paid intro period (for example, a $1 trial moving to the monthly price) and cycle for every other renewal. Grant or extend access on both; use renewalKind only to tell conversions apart.

Payment succeeded ​

payment.succeeded uses the same V2 envelope and reduced funnelUser identity. One-time payments require a successful PaymentIntent with a positive received amount, purchase_kind: one_time metadata, and no linked subscription. Paid subscription invoices emit the event when amount_paid is positive. checkout.session.completed does not emit this event.

data.test identifies the Stripe test environment. data.source contains provider: stripe, the original Stripe eventId, and eventType. The event id is stripe:<Stripe event id>:v2:payment.succeeded.

The data.payment object contains:

FieldsMeaning
kindone_time or subscription, based on the linked provider subscription.
provider, environment, teststripe, the test/live environment, and its boolean test flag.
providerPaymentIdPaymentIntent id, or the payment id resolved from the invoice.
providerCustomerId, providerSubscriptionId, providerPlanIdLinked provider identifiers; absent identifiers are null.
statusConfirmed provider state, such as succeeded or paid.
currencyProvider currency code, or null if unavailable.
amountMinor, amountReceivedMinor, amountRefundedMinorMonetary amounts in the currency's minor units; unavailable values are null.
occurredAtEvent timestamp in ISO format.
metadataPayment metadata retained from the provider.
sourceEventOriginal Stripe event type.
user_idRuntime funnel user identifier.
funnelUserReduced V2 user identity shown above; no answers or raw user document.

For example, a payment event includes:

json
{
  "id": "stripe:evt_123:v2:payment.succeeded",
  "type": "payment.succeeded",
  "apiVersion": "v2",
  "createdAt": "2026-08-04T12:00:00.000Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": false,
    "source": {
      "provider": "stripe",
      "eventId": "evt_123",
      "eventType": "invoice.payment_succeeded"
    },
    "payment": {
      "kind": "subscription",
      "provider": "stripe",
      "environment": "live",
      "test": false,
      "providerPaymentId": "pi_123",
      "providerCustomerId": "cus_123",
      "providerSubscriptionId": "sub_123",
      "providerPlanId": "price_123",
      "status": "paid",
      "currency": "usd",
      "amountMinor": 1900,
      "amountReceivedMinor": 1900,
      "amountRefundedMinor": null,
      "occurredAt": "2026-08-04T12:00:00.000Z",
      "metadata": {},
      "sourceEvent": "invoice.payment_succeeded",
      "user_id": "fg-user-123",
      "funnelUser": {
        "id": "funnel_user_123",
        "funnelId": "funnel_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "stripeCustomerId": "cus_123",
        "stripeSubscriptionId": "sub_123"
      }
    }
  }
}

Signature Headers ​

Each delivery includes:

HeaderValue
X-FunnelsGrove-EventEvent type, for example subscription.activated.
X-FunnelsGrove-DeliveryDelivery id. Use this in the signature payload and for idempotency.
X-FunnelsGrove-TimestampUnix timestamp in seconds.
X-FunnelsGrove-Signaturev1=<hex hmac sha256>.

The signature is HMAC-SHA256 over the exact raw JSON request body:

text
<timestamp>.<deliveryId>.<rawJsonBody>

Use the Signing secret shown in Project Settings → Webhooks as the HMAC key. It appears after you save the first webhook. Projects created before API keys sign with their SDK secret key, so rotating that key also rotates their webhook signing.

Do not verify against a parsed and re-stringified JSON object. JSON key order and whitespace are part of the signed raw body.

Verify In Node And Express ​

Register the webhook route with express.raw() before any global JSON parser consumes the body.

js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const webhookSecret = process.env.FUNNELGROVE_WEBHOOK_SECRET;

function getV1Signatures(signatureHeader) {
  return String(signatureHeader || '')
    .split(',')
    .map((entry) => entry.trim())
    .filter((entry) => entry.startsWith('v1='))
    .map((entry) => entry.slice(3))
    .filter((value) => /^[a-fA-F0-9]{64}$/.test(value));
}

function verifyFunnelsGroveSignature({ rawBody, timestamp, deliveryId, signatureHeader, secret }) {
  if (!secret || !timestamp || !deliveryId || !signatureHeader) {
    return false;
  }

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 5 * 60) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${deliveryId}.`)
    .update(rawBody)
    .digest();

  return getV1Signatures(signatureHeader).some((signature) => {
    const received = Buffer.from(signature, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

app.post('/webhooks/funnelsgrove', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
  const deliveryId = req.header('X-FunnelsGrove-Delivery');
  const timestamp = req.header('X-FunnelsGrove-Timestamp');
  const signatureHeader = req.header('X-FunnelsGrove-Signature');

  const signatureOk = verifyFunnelsGroveSignature({
    rawBody,
    timestamp,
    deliveryId,
    signatureHeader,
    secret: webhookSecret,
  });

  if (!signatureOk) {
    res.status(403).send({ error: 'Invalid signature' });
    return;
  }

  const payload = JSON.parse(rawBody.toString('utf8'));

  if (payload.type.startsWith('subscription.')) {
    // Update access from payload.data.subscription.
  }

  if (payload.type === 'payment.succeeded') {
    // Record a confirmed payment from payload.data.payment.
  }

  res.send({ success: true });
});

The v1= signature prefix identifies the HMAC signing scheme and remains valid for V2 payloads.

Idempotency And Retries ​

Use the envelope id as the business-event idempotency key and X-FunnelsGrove-Delivery as the transport-delivery key. If your endpoint receives either one again, return 2xx after confirming it was already processed.

Retry behavior:

ResultWhat funnelgrove does
2xx responseMarks the delivery as delivered.
Network errorMarks the delivery retryable.
Non-2xx responseMarks the delivery retryable and stores the status and truncated response body.

New outbox rows immediately wake a dedicated per-project worker lane. A scheduled sweep recovers missed wakeups. Within a project, payment deliveries run first, subscription lifecycle deliveries second, and deprecated user updates last. Retries use gradual backoff: 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, then 24 hours. The retry window expires after 7 days.

Receiver Checklist ​

  • Store the signing secret only on your backend.
  • Read and verify the raw request body before parsing JSON.
  • Reject missing or invalid signature headers.
  • Reject stale timestamps, for example older than 5 minutes.
  • Compare signatures with crypto.timingSafeEqual or an equivalent constant-time function.
  • Deduplicate by envelope id and X-FunnelsGrove-Delivery.
  • Return 2xx only after your durable write succeeds.

Build. Launch. Grow.