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.
Domain Events
Section titled “Domain Events”Current event types:
customer.updatedsubscription.updatedpurchase.completedpurchase.updatedentitlements.updatedinvoice.updatedusage.updated— declared, not yet emitted (see below)
events: "*" subscribes to all event types.
Why Domain Events
Section titled “Why Domain Events”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.
Delivery
Section titled “Delivery”Each delivery is an HTTP POST with a JSON body and three headers:
content-type: application/jsonguapocado-signature: t=1719000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdguapocado-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.
Signature verification
Section titled “Signature verification”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.
Retry semantics
Section titled “Retry semantics”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 attempt | Delay before next attempt |
|---|---|
| 1 | 60s (1 min) |
| 2 | 120s (2 min) |
| 3 | 240s (4 min) |
| 4 | 480s (8 min) |
| 5 | none — 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.
Idempotency
Section titled “Idempotency”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.
Event Envelope
Section titled “Event Envelope”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" }}Event Reference
Section titled “Event Reference”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.
customer.updated
Section titled “customer.updated”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; };};
metadataon the wire today is the raw JSON-encoded string stored in thecustomers.metadatacolumn (e.g."{\"source\":\"stripe.checkout\"}"), not a parsed object — the SDK type is deliberatelyunknownso it can absorb either representation;createGuapLocalparses 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.
subscription.updated
Section titled “subscription.updated”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/currentPeriodEndas non-nullablestring, but the emission code (unixToIso) can and does emitnullfor either field when the underlying Stripe subscription object lacks a numericcurrent_period_start/current_period_end(e.g. someincompletesubscriptions). Treat both asstring | nulldefensively.
{ "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).
purchase.completed
Section titled “purchase.completed”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.
purchase.updated
Section titled “purchase.updated”Fires on checkout session events for a one-time-purchase product when the
resolved status is not completed (e.g. pending → failed, 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.
entitlements.updated
Section titled “entitlements.updated”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.
invoice.updated
Section titled “invoice.updated”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.
usage.updated (reserved)
Section titled “usage.updated (reserved)”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.
Config-declared Receivers
Section titled “Config-declared Receivers”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, }, ],}Receiver Approval
Section titled “Receiver Approval”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.
Local Development
Section titled “Local Development”Use the test dev relay:
npx guap listen --test --dev --to http://localhost:3000/api/guap-webhookThe receiver must be declared in config and approved for test relay use.
Receiver Design
Section titled “Receiver Design”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.
When You Do Not Need Webhooks
Section titled “When You Do Not Need Webhooks”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