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, choose its API version, and select the events it should receive. New integrations should use v2. Existing endpoints remain on v1 until you change them, so you can add a separate v2 URL and migrate one receiver at a time.

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.renewedStripe confirms a recurring subscription_cycle invoice.
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.

v1 legacy events

EventStatus
funnel_user.updatedDeprecated. High-volume user snapshot event.
subscription.updatedLegacy catch-all subscription event.
payment.succeededRetained for compatibility.

V2 test sends use signed synthetic data and do not require a real funnel user. V1 test sends keep the existing real funnel_user.updated behavior.

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.

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; v2 does not send answers or the raw funnel user document.

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",
      "funnelUser": {
        "id": "funnel_user_123",
        "funnelId": "funnel_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "stripeCustomerId": null,
        "stripeSubscriptionId": null
      }
    }
  }
}

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"
      }
    }
  }
}

subscription.renewed additionally includes invoiceId and renewedAt. payment.succeeded keeps the documented payment fields below, adds apiVersion: "v2" and data.source, and uses the reduced funnelUser identity shape.

V1 Legacy Payloads

funnel_user.updated

Example body:

json
{
  "id": "evt_528bc642e1824d95ad2d5861a08c85c4",
  "type": "funnel_user.updated",
  "createdAt": "2026-05-01T16:07:44.761Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": true,
    "funnelUser": {
      "id": "373274ce-e25f-4507-ab82-980e97beb5a4",
      "funnelId": "funnel_123",
      "workspaceId": "workspace_123",
      "user_id": "fg-user-123",
      "email": "user@example.com",
      "fullName": "User Example",
      "subscriptionStatus": "active",
      "stripeCustomerId": "cus_123",
      "stripeSubscriptionId": "sub_123",
      "stripeEnvironment": "test",
      "test": true,
      "answers": {
        "goal": "recover_refunds",
        "platform": "ios"
      },
      "document": {
        "profile": {
          "email": "user@example.com"
        },
        "answers": {
          "goal": "recover_refunds",
          "platform": "ios"
        }
      },
      "createdAt": "2026-05-01T16:00:00.000Z",
      "updatedAt": "2026-05-01T16:07:44.761Z",
      "lastSeenAt": "2026-05-01T16:07:44.761Z"
    }
  }
}

Important fields:

FieldMeaning
data.testtrue when the linked Stripe environment is test mode.
data.funnelUser.user_idPublic funnel user id. Use this for subscription lookup and claim calls after a verified account association.
data.funnelUser.emailEmail captured by the funnel or checkout.
data.funnelUser.answersExtracted answer map from the funnel document.
data.funnelUser.documentFull saved funnel user document.
data.funnelUser.stripeCustomerIdStripe customer id when known.
data.funnelUser.stripeSubscriptionIdStripe subscription id when known.

subscription.updated

Example body:

json
{
  "id": "evt_867c61a9998c46d5aeb38f69ed78fa60",
  "type": "subscription.updated",
  "createdAt": "2026-05-07T12:38:35.205Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": false,
    "subscription": {
      "id": "billing_subscription_123",
      "workspaceId": "workspace_123",
      "funnelId": "funnel_123",
      "funnelEndUserId": "fg-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-05-07T12:38:27.000Z",
      "currentPeriodEnd": "2026-06-07T12:38:27.000Z",
      "canceledAt": null,
      "endedAt": null,
      "metadata": {
        "customer_email": "user@example.com"
      },
      "rawPayload": {},
      "lastEventId": "evt_stripe_123",
      "lastEventAt": "2026-05-07T12:38:35.000Z",
      "createdAt": "2026-05-07T12:38:27.000Z",
      "updatedAt": "2026-05-07T12:38:35.205Z",
      "funnelUser": {
        "id": "373274ce-e25f-4507-ab82-980e97beb5a4",
        "funnelId": "funnel_123",
        "workspaceId": "workspace_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "subscriptionStatus": "active",
        "stripeCustomerId": "cus_123",
        "stripeSubscriptionId": "sub_123",
        "stripeEnvironment": "live",
        "test": false,
        "answers": {},
        "document": {},
        "createdAt": "2026-05-01T16:00:00.000Z",
        "updatedAt": "2026-05-07T12:38:35.205Z",
        "lastSeenAt": "2026-05-07T12:38:35.205Z"
      }
    }
  }
}

Canceled subscriptions can include a future paid-through timestamp in currentPeriodEnd, endedAt, or provider metadata. Keep access active until that timestamp if your app supports paid-through cancellation.

Renewal invoices can also send subscription.updated with invoice-specific fields such as invoiceId, renewedAt, and sourceEvent.

payment.succeeded

payment.succeeded is emitted from Stripe payment_intent.succeeded for one-time payments and from Stripe invoice.payment_succeeded for subscription invoice payments. It is not emitted from checkout.session.completed, so keep the required Stripe payment and invoice events enabled for your funnel webhook endpoint.

Example body:

json
{
  "id": "evt_b2d97de40919424291f2e5b3020a9b12",
  "type": "payment.succeeded",
  "createdAt": "2026-07-08T12:38:35.205Z",
  "workspaceId": "workspace_123",
  "projectId": "project_123",
  "funnelId": "funnel_123",
  "user_id": "fg-user-123",
  "data": {
    "test": false,
    "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-07-08T12:38:27.000Z",
      "metadata": {
        "source": "funnelsgrove",
        "funnelId": "funnel_123",
        "userId": "fg-user-123"
      },
      "sourceEvent": "invoice.payment_succeeded",
      "user_id": "fg-user-123",
      "funnelUser": {
        "id": "373274ce-e25f-4507-ab82-980e97beb5a4",
        "funnelId": "funnel_123",
        "workspaceId": "workspace_123",
        "user_id": "fg-user-123",
        "email": "user@example.com",
        "fullName": "User Example",
        "subscriptionStatus": "active",
        "stripeCustomerId": "cus_123",
        "stripeSubscriptionId": "sub_123",
        "stripeEnvironment": "live",
        "test": false,
        "answers": {},
        "document": {},
        "createdAt": "2026-05-01T16:00:00.000Z",
        "updatedAt": "2026-07-08T12:38:35.205Z",
        "lastSeenAt": "2026-07-08T12:38:35.205Z"
      }
    }
  }
}

Important fields:

FieldMeaning
data.payment.kindone_time for one-time payments, subscription for subscription invoice payments.
data.payment.providerPaymentIdStripe PaymentIntent id when available, otherwise the invoice id fallback for invoice payments. Use it for payment-level idempotency.
data.payment.providerSubscriptionIdStripe subscription id for subscription payments, otherwise null.
data.payment.statusStripe payment or invoice status, for example succeeded or paid.
data.payment.amountMinorTotal amount in the minor currency unit, for example cents.
data.payment.amountReceivedMinorReceived amount in the minor currency unit.
data.payment.sourceEventStripe event that produced this project webhook, for example payment_intent.succeeded or invoice.payment_succeeded.
data.payment.funnelUserLinked funnel user payload.

Signature Headers

Each delivery includes:

HeaderValue
X-FunnelsGrove-EventEvent type, for example funnel_user.updated.
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 project's SDK secret key as the HMAC key. Rotating the SDK secret key also rotates 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 === 'funnel_user.updated') {
    // Legacy v1 only.
  }

  if (payload.type === 'subscription.updated' || 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 });
});

During migration, point a separate v2 endpoint at the new receiver and keep the v1 endpoint enabled until v2 deliveries are verified. Then disable or remove v1.

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 project SDK secret key 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.