Local Read Model
Local read-model mode is optional. It keeps a local copy of billing state in your app database for read paths.
This mode is more work than managed edge API mode. Use it when the benefit is clear.
Architecture
Section titled “Architecture”- Your app gets a
GuapAdapter— either generated Drizzle tables or the store-backedcreateGuapLocal. - Your app exposes a webhook receiver that verifies, dedupes, and projects Guapocado domain events.
- Guapocado forwards domain events to the receiver, once the endpoint is approved in the dashboard.
- The SDK adapter reads local state first.
- On a local miss, expired record, or adapter error, the SDK falls back to the hosted API, then true-ups the adapter with the fresh result.
There are two ways to get a GuapAdapter:
- Store-backed (
createGuapLocal) — no codegen, no ORM.@guapocado/sdkships a concrete adapter, a webhook receiver, and typed hooks in one call. Bring your ownGuapStore, or start with the in-memory default. The recommended starting point. - Generated tables (Drizzle) — codegen tables into your own database, wire your own webhook receiver. More control, more setup.
Store-Backed (createGuapLocal)
Section titled “Store-Backed (createGuapLocal)”createGuapLocal gives you a concrete GuapAdapter, a webhook receiver, and
typed hooks in one call — no code generation and no ORM required.
Quickstart
Section titled “Quickstart”import { createGuapocadoClientWithLocal } from "@guapocado/sdk";
const guap = createGuapocadoClientWithLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, webhook: { publicUrl: "https://app.example.com/webhooks/guap" }, hooks: { onCancel: async (ctx) => { await notifyChurn(ctx.customerId, ctx.previous); }, onPurchase: async (ctx) => { await sendReceipt(ctx.customerId, ctx.purchase.productKey); }, },});
// Mount as a fetch handler (Workers, Bun, Deno, Node's http.toWebHandler).export default { fetch: (request: Request) => guap.handler()(request),};createGuapocadoClientWithLocal wires the local adapter into the client and
attaches .handler so you never juggle two objects. guap.handler() verifies
the guapocado-signature header, dedupes on delivery id, projects the event
into your store, runs your hooks, and returns a Response — it never throws
into your app. A GET to the same URL returns registration status and lazily
registers the endpoint, so pinging it (or guap listen) bootstraps
registration.
You can also pass hooks per call to guap.handler({ ... }) instead of (or in
addition to) the constructor hooks.
On Hono, mount the fetch-shaped handler with @guapocado/hono’s
guapLocalHandler instead of the raw SDK:
import { createGuapLocal, createGuapocadoClient } from "@guapocado/sdk";import { guapLocalHandler } from "@guapocado/hono";import { Hono } from "hono";
const local = createGuapLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, webhook: { publicUrl: "https://app.example.com/webhooks/guap" },});
const guap = createGuapocadoClient({ apiKey: process.env.GUAPOCADO_API_KEY!, adapter: local.adapter,});
const app = new Hono();
app.all("/webhooks/guap", guapLocalHandler(local, { onCancel, onPurchase }));
app.get("/features/:key", async (c) => { const hasAccess = await guap.has(c.req.param("key"), { customerId: c.req.query("customerId"), }); return c.json({ hasAccess });});guapLocalHandler takes the GuapLocal from createGuapLocal (not the
.handler-augmented client from createGuapocadoClientWithLocal), and adapts
its fetch-shaped handler into a Hono route. app.get/app.post work in place
of app.all — the underlying handler branches on GET vs POST internally.
The GuapStore contract
Section titled “The GuapStore contract”Without a store option, createGuapLocal defaults to
createMemoryGuapStore() — process-local and non-durable, fine for local dev
and single-instance deployments where losing the projection on restart (and
re-seeding it from miss-through API calls) is acceptable.
For anything durable, implement GuapStore: point get/put/delete plus a
customer-scoped prefix scan.
type GuapStoreRecord = { value: unknown; // JSON-serializable; shape depends on the collection sourceTs: number; // event/API timestamp, used for last-write-wins ordering writtenAt: number; // wall-clock write time, used for maxAgeMs staleness};
type GuapStore = { get(collection: string, id: string): Promise<GuapStoreRecord | null>; put(collection: string, id: string, record: GuapStoreRecord): Promise<void>; delete(collection: string, id: string): Promise<void>; listByPrefix( collection: string, idPrefix: string, ): Promise<Array<{ id: string; record: GuapStoreRecord }>>;};Collections are opaque string namespaces (customers, subscriptions,
purchases, features, limits, usage, plans, meta, events). Ids
within a collection are encodeURIComponent-sanitized components joined by
:, so every customer-scoped lookup is a <customerId>: prefix scan — one
LIKE/range query on any backend, with no secondary index to register.
Bring your own store
Section titled “Bring your own store”Implement GuapStore over any key-value or SQL backend. This sketch targets
Cloudflare D1 (swap the driver calls for Postgres, better-sqlite3, Redis,
etc — the schema and query shape stay the same):
function createD1GuapStore(db: D1Database): GuapStore { return { async get(collection, id) { const row = await db .prepare("select value, source_ts, written_at from guap_store where collection = ?1 and id = ?2") .bind(collection, id) .first<{ value: string; source_ts: number; written_at: number }>(); return row ? { value: JSON.parse(row.value), sourceTs: row.source_ts, writtenAt: row.written_at } : null; }, async put(collection, id, record) { await db .prepare( `insert into guap_store (collection, id, value, source_ts, written_at) values (?1, ?2, ?3, ?4, ?5) on conflict(collection, id) do update set value = excluded.value, source_ts = excluded.source_ts, written_at = excluded.written_at`, ) .bind(collection, id, JSON.stringify(record.value), record.sourceTs, record.writtenAt) .run(); }, async delete(collection, id) { await db.prepare("delete from guap_store where collection = ?1 and id = ?2").bind(collection, id).run(); }, async listByPrefix(collection, idPrefix) { const { results } = await db .prepare("select id, value, source_ts, written_at from guap_store where collection = ?1 and id like ?2") .bind(collection, `${idPrefix}%`) .all<{ id: string; value: string; source_ts: number; written_at: number }>(); return results.map((row) => ({ id: row.id, record: { value: JSON.parse(row.value), sourceTs: row.source_ts, writtenAt: row.written_at }, })); }, };}Validate a custom implementation against the shipped contract suite before trusting it in production:
import { testGuapStoreContract } from "@guapocado/sdk/testing";import { createD1GuapStore } from "./my-store.js";
testGuapStoreContract("my D1 store", () => createD1GuapStore(testDb()));Then wire it in:
const guap = createGuapocadoClientWithLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, store: createD1GuapStore(env.DB), webhook: { publicUrl: "https://app.example.com/webhooks/guap" },});webhook.publicUrl and auto-registration
Section titled “webhook.publicUrl and auto-registration”webhook.publicUrl is required for auto-registration. The registration
URL is never derived from request data by default — registering a webhook
endpoint at a URL an attacker can influence (for example by forging a Host
header behind a misconfigured proxy) would let them redirect where Guapocado
delivers your events.
Without publicUrl, auto-registration is skipped entirely on every GET/POST,
and onError fires with { scope: "registration" } — reads keep working via
API miss-through in the meantime, but no webhook events are delivered until
you set publicUrl (or call local.register() explicitly).
If you run behind a proxy you control end-to-end, and that strips or
overwrites the x-guapocado-public-url header before it reaches your
handler, you can opt into deriving the URL from that header instead:
const guap = createGuapocadoClientWithLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, webhook: { trustForwardedHost: true }, // only if you control every hop});trustForwardedHost defaults to false. Prefer setting publicUrl
explicitly unless you have a specific reason to derive it per-request.
Approval gate
Section titled “Approval gate”createGuapLocal registers using your server-side apiKey, and registration
requires a server key — the tenant’s own highest-trust credential. That means
auto-registered endpoints go active immediately: there’s no dashboard
approval step to complete before deliveries start flowing. (Client-key
registrations, which aren’t possible through this SDK, would still land
pending_approval and need dashboard approval — the gate exists for
lower-trust surfaces, not for a tenant registering a destination for its own
data.) A GET on the handler URL surfaces the current status so you can
confirm registration succeeded.
Webhook hooks
Section titled “Webhook hooks”Pass hooks to run your own code after an event is verified and projected —
no polling required. Three tiers, all optional:
onEvent— a catch-all for every event, including unknown/future types.- Raw per-event hooks —
onCustomerUpdated,onSubscriptionUpdated,onPurchaseCompleted,onPurchaseUpdated,onEntitlementsUpdated,onInvoiceUpdated— typed to that event’sdatashape. - Semantic transition hooks —
onSubscribe,onCancel,onPlanChange— derived from the previously stored record, so “did this customer just subscribe/cancel/upgrade” needs no diffing in your own code.onPurchaseis a convenience alias foronPurchaseCompletedthat surfaces the purchase and its grants directly onctx.
import { createGuapocadoClientWithLocal, type GuapPurchaseHookContext } from "@guapocado/sdk";
async function sendReceipt(ctx: GuapPurchaseHookContext) { await sendEmail(ctx.customerId, `Thanks for your purchase of ${ctx.purchase.productKey}!`);}
const guap = createGuapocadoClientWithLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, webhook: { publicUrl: "https://app.example.com/webhooks/guap" },});
const webhookHandler = guap.handler({ onPurchase: sendReceipt, // a pre-packaged function reference... onCancel: async (ctx) => { // ...or an inline lambda — both fully type-check with zero annotations. await notifyChurn(ctx.customerId, ctx.previous); },});Hooks run after projection but before the delivery is marked handled,
so a throwing hook causes a 500 and the platform’s at-least-once retry
re-fires it. Write hooks to be idempotent — for example, dedupe outbound
emails by event.id — since the same delivery can invoke your hook more than
once. Hooks never re-run on a deduplicated redelivery; the event was already
fully processed.
Two-tier delivery contract
Section titled “Two-tier delivery contract”onEvent and the raw per-event hooks are the at-least-once event log:
they fire on every authentic (signature-verified, non-deduplicated) delivery,
including one that last-write-wins conflict resolution goes on to reject as
stale — for example, a delayed subscription.updated that arrives after a
newer event already moved the stored subscription on.
onSubscribe, onCancel, and onPlanChange are the semantic tier: they
fire only when that delivery’s write was actually applied to the store. A
superseded/rejected delivery changed nothing, so there’s no real transition to
report and these hooks are skipped for it — without this gate, a stale
canceled event arriving after a newer active event would incorrectly fire
onCancel even though the customer’s active subscription never changed.
Write the raw tier and onEvent defensively — they may describe an event the
store no longer reflects. The semantic tier can be trusted to match current
state. onPurchase (the purchase.completed alias) is not gated this way —
like the raw tier, it fires on every authentic delivery.
Staleness (maxAgeMs)
Section titled “Staleness (maxAgeMs)”Without maxAgeMs, a local record is served forever once written — correctness
comes from webhook-driven invalidation, not expiry. Once webhooks are flowing,
entitlement/limit/subscription reads are safe uncached.
usage balances change more often than they’re invalidated (no
usage.updated event ships yet), so give usage a short maxAgeMs — for
example 60_000 — until it does:
const guap = createGuapocadoClientWithLocal({ apiKey: process.env.GUAPOCADO_API_KEY!, maxAgeMs: 60_000, webhook: { publicUrl: "https://app.example.com/webhooks/guap" },});A stale record is treated as a miss: the adapter reports { found: false, reason: "stale" }, the SDK falls back to the hosted API, and the fresh result
true-ups the store.
Generated Tables (Drizzle)
Section titled “Generated Tables (Drizzle)”Add generation defaults to billing.config.ts:
import { defineBilling } from "@guapocado/sdk";
export default defineBilling({ entitlements: { seats: { type: "limit" }, }, products: [ { key: "pro", entitlements: { seats: { included: 10 }, }, }, ], generate: { tables: { enabled: true, orm: "drizzle", db: "sqlite", output: "src/db/guapocado.ts", }, },});Then generate:
npx guap generateSupported table targets:
- ORM:
drizzle - Databases:
sqlite,pg,mysql
Attach the Adapter
Section titled “Attach the Adapter”Generated Drizzle tables include an adapter helper:
import { createGuapocadoClient } from "@guapocado/sdk";import { createGuapDrizzleAdapter } from "./db/guapocado";
export function createBilling(customerId: string) { return createGuapocadoClient({ apiKey: process.env.GUAPOCADO_API_KEY!, customerId, adapter: createGuapDrizzleAdapter(db), });}With either adapter, SDK reads try the adapter first:
has()limit()usage.balance()plans.list()purchases.list()subscription.current()context()
SDK commands still go to the hosted API.
Webhook Receiver Design
Section titled “Webhook Receiver Design”Whichever adapter you use, your receiver should be:
- idempotent
- tolerant of retries
- tolerant of eventual consistency
Domain events are product-level snapshots, not raw Stripe events:
customer.updatedsubscription.updatedpurchase.completedpurchase.updatedentitlements.updatedinvoice.updatedusage.updated
Events may retry. Events may arrive after your app has already handled a fallback API read.
If you write a receiver by hand instead of using createGuapLocal, verify
signatures with the same check the platform uses:
import { verifyGuapocadoSignature } from "@guapocado/sdk";
const valid = await verifyGuapocadoSignature({ payload: rawBody, secret: signingSecret, signature: request.headers.get("guapocado-signature"),});Dev Relay
Section titled “Dev Relay”For local development, enable a dev tunnel receiver:
webhooks: { devTunnel: true, forwarding: [ { key: "local-read-model", path: "/api/guap-webhook", events: "*", integration: "custom", autoRegister: true, }, ],}Run your app, then run:
npx guap listen --test --dev --to http://localhost:3000/api/guap-webhookThe dev relay is test-only. Live webhook delivery uses direct HTTPS delivery to approved receivers.
When Not to Use This
Section titled “When Not to Use This”Do not start here just because local reads sound cleaner.
Managed edge API mode is usually enough for:
- new products
- early products
- small teams
- serverless apps without a persistent database worker
- products that do not need custom billing reports
Move to local read-model mode when the additional moving parts are worth it.
Within local read-model mode, start with createGuapLocal — move to generated
tables only if you need SQL reporting over projected billing state or already
operate the migration workflow for it.