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 and select the events it should receive. All project webhooks use v2.
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.trialing | A Stripe subscription is created in or transitions to trialing. |
subscription.renewed | Stripe confirms payment for a new subscription period, including the first full-price charge after a free trial or paid intro. |
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.
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:
| Fields | Meaning |
|---|---|
kind | one_time or subscription, based on the linked provider subscription. |
provider, environment, test | stripe, the test/live environment, and its boolean test flag. |
providerPaymentId | PaymentIntent id, or the payment id resolved from the invoice. |
providerCustomerId, providerSubscriptionId, providerPlanId | Linked provider identifiers; absent identifiers are null. |
status | Confirmed provider state, such as succeeded or paid. |
currency | Provider currency code, or null if unavailable. |
amountMinor, amountReceivedMinor, amountRefundedMinor | Monetary amounts in the currency's minor units; unavailable values are null. |
occurredAt | Event timestamp in ISO format. |
metadata | Payment metadata retained from the provider. |
sourceEvent | Original Stripe event type. |
user_id | Runtime funnel user identifier. |
funnelUser | Reduced 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:
| Header | Value |
|---|---|
X-FunnelsGrove-Event | Event type, for example subscription.activated. |
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 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:
| 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 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.timingSafeEqualor an equivalent constant-time function. - Deduplicate by envelope
idandX-FunnelsGrove-Delivery. - Return
2xxonly after your durable write succeeds.