Appearance
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
| Event | Sent when |
|---|---|
lead.captured | A funnel user provides an email address. Emitted once per endpoint for the canonical capture. |
subscription.activated | A subscription first becomes active or trialing. |
subscription.renewed | Stripe confirms a recurring subscription_cycle invoice. |
subscription.cancel_scheduled | cancel_at_period_end changes from false to true. |
subscription.ended | Stripe deletes the subscription, or it transitions to incomplete_expired or unpaid. |
payment.succeeded | A 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
| Event | Status |
|---|---|
funnel_user.updated | Deprecated. High-volume user snapshot event. |
subscription.updated | Legacy catch-all subscription event. |
payment.succeeded | Retained 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:
| Field | Meaning |
|---|---|
data.test | true when the linked Stripe environment is test mode. |
data.funnelUser.user_id | Public funnel user id. Use this for subscription lookup and claim calls after a verified account association. |
data.funnelUser.email | Email captured by the funnel or checkout. |
data.funnelUser.answers | Extracted answer map from the funnel document. |
data.funnelUser.document | Full saved funnel user document. |
data.funnelUser.stripeCustomerId | Stripe customer id when known. |
data.funnelUser.stripeSubscriptionId | Stripe 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:
| Field | Meaning |
|---|---|
data.payment.kind | one_time for one-time payments, subscription for subscription invoice payments. |
data.payment.providerPaymentId | Stripe PaymentIntent id when available, otherwise the invoice id fallback for invoice payments. Use it for payment-level idempotency. |
data.payment.providerSubscriptionId | Stripe subscription id for subscription payments, otherwise null. |
data.payment.status | Stripe payment or invoice status, for example succeeded or paid. |
data.payment.amountMinor | Total amount in the minor currency unit, for example cents. |
data.payment.amountReceivedMinor | Received amount in the minor currency unit. |
data.payment.sourceEvent | Stripe event that produced this project webhook, for example payment_intent.succeeded or invoice.payment_succeeded. |
data.payment.funnelUser | Linked funnel user payload. |
Signature Headers
Each delivery includes:
| Header | Value |
|---|---|
X-FunnelsGrove-Event | Event type, for example funnel_user.updated. |
X-FunnelsGrove-Delivery | Delivery id. Use this in the signature payload and for idempotency. |
X-FunnelsGrove-Timestamp | Unix timestamp in seconds. |
X-FunnelsGrove-Signature | v1=<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:
| Result | What funnelgrove does |
|---|---|
2xx response | Marks the delivery as delivered. |
| Network error | Marks the delivery retryable. |
Non-2xx response | Marks 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.timingSafeEqualor an equivalent constant-time function. - Deduplicate by envelope
idandX-FunnelsGrove-Delivery. - Return
2xxonly after your durable write succeeds.