Skip to content

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.

  1. Your app gets a GuapAdapter — either generated Drizzle tables or the store-backed createGuapLocal.
  2. Your app exposes a webhook receiver that verifies, dedupes, and projects Guapocado domain events.
  3. Guapocado forwards domain events to the receiver, once the endpoint is approved in the dashboard.
  4. The SDK adapter reads local state first.
  5. 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/sdk ships a concrete adapter, a webhook receiver, and typed hooks in one call. Bring your own GuapStore, 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.

createGuapLocal gives you a concrete GuapAdapter, a webhook receiver, and typed hooks in one call — no code generation and no ORM required.

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.

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.

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:

my-store.test.ts
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 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.

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.

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’s data shape.
  • 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. onPurchase is a convenience alias for onPurchaseCompleted that surfaces the purchase and its grants directly on ctx.
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.

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.

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.

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:

Terminal window
npx guap generate

Supported table targets:

  • ORM: drizzle
  • Databases: sqlite, pg, mysql

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.

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.updated
  • subscription.updated
  • purchase.completed
  • purchase.updated
  • entitlements.updated
  • invoice.updated
  • usage.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"),
});

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:

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

The dev relay is test-only. Live webhook delivery uses direct HTTPS delivery to approved receivers.

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.