Skip to content

Webhooks

Guapocado receives Stripe webhooks, projects them into product state, and forwards Guapocado domain events to approved receivers.

Your app usually should not consume raw Stripe events for entitlement logic. Consume Guapocado events instead.

Current event types:

  • customer.updated
  • subscription.updated
  • purchase.completed
  • purchase.updated
  • entitlements.updated
  • invoice.updated
  • usage.updated — declared, not yet emitted (see below)

events: "*" subscribes to all event types.

Stripe events are payment-system events. They answer questions like “was an invoice paid?” or “did a checkout session complete?”

Your app usually needs product-state events. It wants to know:

  • customer billing state changed
  • subscription snapshot changed
  • one-time purchase was completed
  • entitlements changed
  • usage changed

Guapocado forwards projected snapshots after it has processed Stripe state.

Each delivery is an HTTP POST with a JSON body and three headers:

content-type: application/json
guapocado-signature: t=1719000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
guapocado-endpoint-id: whe_...

The body is the event envelope, serialized with a stable key-sort (nested objects too) so the exact bytes are reproducible from the JSON value alone — signature verification hashes these bytes directly, so verify against the raw request body, not a value you’ve re-serialized yourself.

guapocado-signature is t=<unix-seconds>,v1=<hex-encoded HMAC-SHA256>, computed as HMAC-SHA256(signingSecret, "${t}.${rawBody}"). Verify it with verifyGuapocadoSignature from @guapocado/sdk rather than reimplementing the HMAC and timestamp-tolerance check by hand:

import { verifyGuapocadoSignature } from "@guapocado/sdk";
export default {
async fetch(request: Request): Promise<Response> {
const payload = await request.text();
const signature = request.headers.get("guapocado-signature");
const valid = await verifyGuapocadoSignature({
payload,
secret: process.env.GUAPOCADO_WEBHOOK_SECRET!,
signature,
// toleranceSeconds defaults to 300 (5 minutes)
});
if (!valid) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(payload); // GuapDomainEventEnvelope
// ...handle event...
return new Response("ok");
},
};

createGuapLocal’s handler() (see Server SDK API) does this verification for you, plus dedup and typed hooks — prefer it over a hand-rolled receiver unless you need the raw bytes for something else.

A 2xx response marks the delivery delivered. Anything else (non-2xx status, thrown error, timeout) marks it retrying and schedules another attempt, up to 5 attempts total. The delay before attempt N+1, after attempt N fails, is:

min(3600, 2^(N-1) * 60) seconds
Failed attemptDelay before next attempt
160s (1 min)
2120s (2 min)
3240s (4 min)
4480s (8 min)
5none — marked failed

Respond quickly and acknowledge before doing slow work; a receiver that’s slow enough to hit the request timeout is indistinguishable from a failure and triggers a retry.

Delivery is at-least-once. Dedupe on the envelope’s id (evt_...) before applying an event’s effects — you may see the same id again on a retry, and (rarely, across Stripe-side retries of the underlying source event) two different ids can describe the same conceptual change. For that second case, resolve conflicts with last-write-wins keyed on createdAt rather than assuming id uniqueness alone gives you exactly-once semantics per entity.

If you’re writing your own receiver instead of using createGuapLocal (which already does this), track processed event ids in durable storage alongside whatever state you’re projecting, and skip events whose id you’ve already applied.

Every delivery’s body matches GuapDomainEventEnvelope, exported from @guapocado/sdk:

type GuapDomainEventEnvelope = {
id: string; // stable event id (evt_...), used for delivery dedup
type: string; // e.g. "customer.updated"; widened to allow forward-compatible types
createdAt: string; // ISO 8601; last-write-wins source timestamp
data: unknown; // type-specific — see the per-event reference below
source: {
provider: "stripe" | "guapocado";
eventId?: string; // underlying Stripe event id, when provider is "stripe"
objectId?: string; // underlying Stripe object id (customer, subscription, invoice, ...)
objectType?: string; // e.g. "checkout.session", "subscription", "invoice"
};
};

source is omitted entirely (not present as a key) when no source metadata applies. source.eventId, objectId, and objectType are each independently optional — every domain event emitted today sets all three, sourced from the Stripe event that triggered the projection.

Annotated example (subscription.updated):

{
"id": "evt_01hz3k9x8v",
"type": "subscription.updated",
"createdAt": "2026-07-08T16:42:11.203Z",
"data": {
"subscription": {
"id": "sub_01hz3k9wqr",
"customerId": "cus_01hz3k9w1a",
"stripeSubscriptionId": "sub_1PxYzABC",
"planKey": "pro",
"status": "active",
"currentPeriodStart": "2026-07-08T00:00:00.000Z",
"currentPeriodEnd": "2026-08-08T00:00:00.000Z",
"cancelAtPeriodEnd": false
}
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzDEF",
"objectId": "sub_1PxYzABC",
"objectType": "subscription"
}
}

For each event type below: when it fires, the exact data shape (as produced by the emission code — nullability called out where the platform and the exported SDK type disagree), a realistic JSON example, and the @guapocado/sdk hook(s) it drives.

All events also reach onEvent (untyped, fires for every verified delivery including unknown/future types) — see Webhook hooks for the two-tier (raw vs. semantic) hook contract and its idempotency requirement.

Fires when a Stripe checkout.session.* or customer.subscription.* event causes the platform to insert a new customer row or change an existing one’s stripeCustomerId.

Data shape — the full customer row, typed as GuapCustomerUpdatedData:

type GuapCustomerUpdatedData = {
customer: {
id: string;
stripeCustomerId?: string | null;
name?: string | null;
email?: string | null;
metadata: unknown; // see note below
createdAt?: string;
updatedAt?: string;
};
};

metadata on the wire today is the raw JSON-encoded string stored in the customers.metadata column (e.g. "{\"source\":\"stripe.checkout\"}"), not a parsed object — the SDK type is deliberately unknown so it can absorb either representation; createGuapLocal parses it leniently (falling back to the raw string if parsing fails) before handing it to your hook.

{
"id": "evt_01hz3k8a1b",
"type": "customer.updated",
"createdAt": "2026-07-08T16:40:02.910Z",
"data": {
"customer": {
"id": "cus_01hz3k7z9c",
"stripeCustomerId": "cus_QpXyzABC",
"name": null,
"email": null,
"metadata": "{\"source\":\"stripe.checkout\"}",
"createdAt": "2026-07-08 16:40:02",
"updatedAt": "2026-07-08 16:40:02"
}
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzGHI",
"objectId": "cs_test_1PxYzJKL",
"objectType": "checkout.session"
}
}

SDK hook: onCustomerUpdated. No semantic (transition) hook — the customer row itself has no “state” to transition between, unlike subscriptions.

Fires on customer.subscription.created / .updated / .deleted Stripe events when the projected subscription snapshot changed (status, plan, period bounds, or cancel-at-period-end flag).

type GuapSubscriptionUpdatedData = {
subscription: {
id: string;
customerId: string;
stripeSubscriptionId?: string | null;
planKey: string;
status: SubscriptionStatus; // "active" | "trialing" | "past_due" | "canceled" | "unpaid" | "incomplete"
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
};
};

Nullability note: the SDK type declares currentPeriodStart / currentPeriodEnd as non-nullable string, but the emission code (unixToIso) can and does emit null for either field when the underlying Stripe subscription object lacks a numeric current_period_start / current_period_end (e.g. some incomplete subscriptions). Treat both as string | null defensively.

{
"id": "evt_01hz3k9x8v",
"type": "subscription.updated",
"createdAt": "2026-07-08T16:42:11.203Z",
"data": {
"subscription": {
"id": "sub_01hz3k9wqr",
"customerId": "cus_01hz3k9w1a",
"stripeSubscriptionId": "sub_1PxYzABC",
"planKey": "pro",
"status": "active",
"currentPeriodStart": "2026-07-08T00:00:00.000Z",
"currentPeriodEnd": "2026-08-08T00:00:00.000Z",
"cancelAtPeriodEnd": false
}
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzDEF",
"objectId": "sub_1PxYzABC",
"objectType": "subscription"
}
}

May also emit a paired entitlements.updated (see below) immediately after, when the new planKey is known and the subscription isn’t canceled.

SDK hooks: onSubscriptionUpdated (raw, every delivery). Semantic hooks, derived from whether the write actually changed the locally stored record — onSubscribe (no/inactive → active), onCancel (any status → canceled), onPlanChange (planKey changed on an existing stored subscription).

Fires once a one-time-purchase checkout session’s Stripe payment status resolves to completed (payment_status: "paid" / "no_payment_required", or an async-payment-succeeded event) and the locally projected purchase snapshot changed.

type GuapPurchaseGrant = {
entitlementKey: string;
grantType: "feature" | "meter_credit" | "limit_increment";
amount: number;
};
type GuapPurchaseSnapshot = {
id: string;
customerId: string;
productKey: string;
stripeCheckoutSessionId?: string | null;
stripePaymentIntentId?: string | null;
status: PurchaseStatus; // includes "partially_refunded" and "refunded"
amount: number;
currency: string;
quantity: number;
completedAt?: string | null;
};
type GuapPurchaseCompletedData = {
purchase: GuapPurchaseSnapshot;
grants: GuapPurchaseGrant[]; // entitlement grants this purchase applied; may be empty
};
{
"id": "evt_01hz3ka1c2",
"type": "purchase.completed",
"createdAt": "2026-07-08T16:44:00.501Z",
"data": {
"purchase": {
"id": "pur_01hz3ka0z1",
"customerId": "cus_01hz3k7z9c",
"productKey": "credits-1000",
"stripeCheckoutSessionId": "cs_test_1PxYzMNO",
"stripePaymentIntentId": "pi_1PxYzPQR",
"status": "completed",
"amount": 5000,
"currency": "usd",
"quantity": 1,
"completedAt": "2026-07-08T16:44:00.212Z"
},
"grants": [
{ "entitlementKey": "api-calls", "grantType": "meter_credit", "amount": 1000 }
]
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzSTU",
"objectId": "cs_test_1PxYzMNO",
"objectType": "checkout.session"
}
}

When grants is non-empty, an entitlements.updated (reason: "purchase.completed") fires immediately after, carrying the same grants.

SDK hooks: onPurchaseCompleted (raw, every delivery). Semantic: onPurchase — a purchase.completed alias that surfaces purchase and grants directly on the hook context. Unlike the subscription semantic hooks, onPurchase is not gated on whether the write changed local state — it fires on every authentic delivery, same as the raw tier.

Fires on checkout session events for a one-time-purchase product when the resolved status is not completed (e.g. pendingfailed, or a pending snapshot’s other fields changing) and the snapshot changed.

type GuapPurchaseUpdatedData = {
purchase: GuapPurchaseSnapshot; // same shape as purchase.completed, no grants
};
{
"id": "evt_01hz3kb3d4",
"type": "purchase.updated",
"createdAt": "2026-07-08T16:45:30.114Z",
"data": {
"purchase": {
"id": "pur_01hz3ka0z1",
"customerId": "cus_01hz3k7z9c",
"productKey": "credits-1000",
"stripeCheckoutSessionId": "cs_test_1PxYzMNO",
"stripePaymentIntentId": "pi_1PxYzPQR",
"status": "failed",
"amount": 5000,
"currency": "usd",
"quantity": 1,
"completedAt": null
}
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzVWX",
"objectId": "cs_test_1PxYzMNO",
"objectType": "checkout.session"
}
}

SDK hook: onPurchaseUpdated. No semantic hook.

An invalidation signal only — it does not carry computed balances. On receipt, re-fetch entitlement state through the SDK (guap.has(), guap.limit(), guap.usage.balance(), guap.context()) or, in local read-model mode, rely on createGuapLocal’s own projection to have already applied the underlying purchase.completed / subscription.updated event that preceded this one.

Two variants, distinguished by reason:

type GuapEntitlementsUpdatedData =
| {
customerId: string;
reason: "purchase.completed";
purchaseId: string;
productKey: string;
grants: GuapPurchaseGrant[];
}
| {
customerId: string;
reason: "subscription.updated";
subscriptionId: string;
productKey: string;
// no `grants` field on this variant
};

Purchase-reason example (fires right after a purchase.completed with grants.length > 0):

{
"id": "evt_01hz3ka1c3",
"type": "entitlements.updated",
"createdAt": "2026-07-08T16:44:00.615Z",
"data": {
"customerId": "cus_01hz3k7z9c",
"reason": "purchase.completed",
"purchaseId": "pur_01hz3ka0z1",
"productKey": "credits-1000",
"grants": [
{ "entitlementKey": "api-calls", "grantType": "meter_credit", "amount": 1000 }
]
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzSTU",
"objectId": "cs_test_1PxYzMNO",
"objectType": "checkout.session"
}
}

Subscription-reason example (fires right after a subscription.updated whose planKey is known and status isn’t canceled):

{
"id": "evt_01hz3k9x8w",
"type": "entitlements.updated",
"createdAt": "2026-07-08T16:42:11.309Z",
"data": {
"customerId": "cus_01hz3k9w1a",
"reason": "subscription.updated",
"subscriptionId": "sub_01hz3k9wqr",
"productKey": "pro"
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzDEF",
"objectId": "sub_1PxYzABC",
"objectType": "subscription"
}
}

SDK hook: onEntitlementsUpdated (raw only — narrow on data.reason for the variant). No semantic hook.

Fires on invoice.created / .updated / .paid / .payment_failed / .voided Stripe events when the projected invoice snapshot changed. Waits (via a retryable error, so Stripe redelivers) if the invoice’s customer hasn’t been projected yet — see the source comment on processInvoiceEvent for the race this covers.

type GuapInvoiceUpdatedData = {
invoice: {
id: string;
customerId: string;
stripeInvoiceId?: string | null;
subscriptionId?: string | null; // local subscription id, not the Stripe one
status: string; // Stripe invoice status: "draft" | "open" | "paid" | "void" | "uncollectible"
amountDue: number;
amountPaid: number;
currency: string;
periodStart?: string | null;
periodEnd?: string | null;
hostedInvoiceUrl?: string | null;
pdfUrl?: string | null;
};
};
{
"id": "evt_01hz3kc5e6",
"type": "invoice.updated",
"createdAt": "2026-07-08T16:46:12.777Z",
"data": {
"invoice": {
"id": "inv_01hz3kc4y5",
"customerId": "cus_01hz3k9w1a",
"stripeInvoiceId": "in_1PxYzYZA",
"subscriptionId": "sub_01hz3k9wqr",
"status": "paid",
"amountDue": 2900,
"amountPaid": 2900,
"currency": "usd",
"periodStart": "2026-07-08T00:00:00.000Z",
"periodEnd": "2026-08-08T00:00:00.000Z",
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_.../test_...",
"pdfUrl": "https://pay.stripe.com/invoice/acct_.../test_.../pdf"
}
},
"source": {
"provider": "stripe",
"eventId": "evt_1PxYzBCD",
"objectId": "in_1PxYzYZA",
"objectType": "invoice"
}
}

SDK hook: onInvoiceUpdated. No semantic hook.

Declared in GUAPOCADO_DOMAIN_EVENTS (@guapocado/shared) and listed as a valid subscription target, but not currently emitted by the platform — no code path calls emitDomainEvent with this type, and @guapocado/sdk exports no corresponding Guap*UpdatedData type or onUsageUpdated hook.

Usage changes today are visible only via polling (guap.usage.balance() / guap.context()). Don’t build on this event type until it ships — subscribing to it via events: "*" is safe (you’ll simply never receive one yet), but don’t add an explicit "usage.updated" entry to a receiver’s events array expecting deliveries.

Declare receivers in billing.config.ts:

webhooks: {
forwarding: [
{
key: "app",
url: "https://app.example.com/api/guap-webhook",
events: "*",
integration: "custom",
autoRegister: true,
},
],
}

Use path instead of url for framework integrations that know the public app URL:

webhooks: {
devTunnel: true,
forwarding: [
{
key: "better-auth",
path: "/api/auth/guap",
events: "*",
integration: "better-auth",
autoRegister: true,
},
],
}

Registration requires a server key, and server-key registrations go active immediately — there’s no dashboard approval step. A server key is the tenant’s own highest-trust credential, so a tenant registering a destination for their own data doesn’t need platform review; the approval gate exists for lower-trust surfaces (client keys can’t register receivers at all).

A config typo pointing url at the wrong destination isn’t caught by an approval gate — double-check declared receivers before deploying to production.

Use the test dev relay:

Terminal window
npx guap listen --test --dev --to http://localhost:3000/api/guap-webhook

The receiver must be declared in config and approved for test relay use.

Webhook receivers should be:

  • idempotent
  • tolerant of retries
  • tolerant of eventual consistency
  • fast to acknowledge
  • backed by durable storage if they update a local read model

For local read-model mode, store enough event metadata to avoid reapplying the same event twice.

@guapocado/sdk’s createGuapLocal ships a receiver that already does this — signature verification, dedup, projection, and typed hooks in one call. See Local Read Model.

Managed edge API mode does not require your app to receive webhooks for normal runtime checks. Guapocado receives Stripe webhooks and serves the projected state from the hosted API.

You need app receivers when:

  • using local read-model mode
  • integrating with a framework plugin that stores local billing state
  • triggering app-specific side effects from billing changes