Entitlements for every payment rail.
Doubloon bridges app store purchases, subscription billing, and open payment protocols to your entitlement backend. One integration handles Apple, Google, Stripe, and HTTP 402 — writing entitlements to Starfish or Supabase, whichever your app already uses.
Apple App Store ──┐
Google Play ──────┤ ┌─── Starfish (document sync)
Stripe Billing ───┼── Doubloon ──────┼─── Supabase (Anchor-compatible rows)
HTTP 402 (x402) ──┤ Server └─── custom destination
Custom Store ─────┘
| Package | Description |
|---|---|
@drakkar.software/doubloon-core |
Shared types, ProductRegistry, WalletResolver, error codes, utilities |
@drakkar.software/doubloon-server |
Webhook handler, defineConfig, createNamespacedServer, dedup, rate limiter, reconciliation |
@drakkar.software/doubloon-starfish |
Starfish entitlement destination — pull-modify-push with OCC retry |
@drakkar.software/doubloon-anchor |
Supabase entitlement destination — full rows with expiry, source, revocation |
@drakkar.software/doubloon-bridge-apple |
Apple App Store Server Notifications V2 |
@drakkar.software/doubloon-bridge-google |
Google Play Real-Time Developer Notifications |
@drakkar.software/doubloon-bridge-stripe |
Stripe webhook events with signature verification |
@drakkar.software/doubloon-bridge-x402 |
HTTP 402 Payment Required protocol |
Full setup guides for every bridge, destination, and server option are in docs/:
- Bridges: Apple · Google · Stripe · x402
- Destinations: Starfish · Anchor / Supabase
- Server: defineConfig & createServer · Namespaced server
pnpm add @drakkar.software/doubloon-server @drakkar.software/doubloon-starfish @drakkar.software/doubloon-bridge-stripeimport { defineConfig, createServer } from '@drakkar.software/doubloon-server';
import { createStarfishDestination } from '@drakkar.software/doubloon-starfish';
import { StripeBridge } from '@drakkar.software/doubloon-bridge-stripe';
const PRODUCTS = [
{ slug: 'pro-monthly', name: 'Pro Monthly', defaultDuration: 2592000 },
{ slug: 'lifetime', name: 'Lifetime', defaultDuration: 0 },
];
const dest = createStarfishDestination({
client: starfishClient, // @drakkar.software/starfish-client
products: PRODUCTS,
signerKey: 'my-admin-key',
});
const { serverConfig, registry } = defineConfig({
products: PRODUCTS,
destination: dest,
bridges: {
stripe: new StripeBridge({
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
productResolver,
walletResolver,
}),
},
onMintFailure: async (instr, err) => console.error(err.message),
});
const server = createServer(serverConfig);
// Handle webhooks
app.post('/webhook', async (req, res) => {
const result = await server.handleWebhook({
headers: req.headers as Record<string, string>,
body: req.body,
});
res.status(result.status).send(result.body);
});
// Check entitlements
const check = await server.checkEntitlement(registry.getProductId('pro-monthly'), userId);
if (check.entitled) {
// grant access
}Starfish is a document-sync server. @drakkar.software/doubloon-starfish stores entitlements as a per-user JSON document:
{ "features": ["pro-monthly", "lifetime"] }Every write is a pull-modify-push cycle:
- Writer — pulls the document, adds/removes the slug, returns a pending
StarfishTransaction - Signer — pushes the transaction. If the document changed (409 Conflict),
mintWithRetryautomatically re-runs the full cycle
import { createStarfishDestination } from '@drakkar.software/doubloon-starfish';
const dest = createStarfishDestination({
client: starfishClient,
products: PRODUCTS,
signerKey: 'my-admin-key',
// storagePath: 'users/{user}/entitlements', // default
// field: 'features', // default
});
// dest.reader — ChainReader (checkEntitlement, checkEntitlements, getProduct)
// dest.writer — ChainWriter (mintEntitlement, revokeEntitlement)
// dest.signer — ChainSigner (signAndSend, publicKey)
// dest.registry — ProductRegistry (slug ↔ productId)Starfish entitlements have no per-feature expiry — expiresAt is always null. Expiry enforcement requires external revocation (via a cancellation webhook) or a reconciliation job.
On the client, use pullEntitlements from @drakkar.software/starfish-client directly:
import { pullEntitlements } from '@drakkar.software/starfish-client';
const features = await pullEntitlements(starfishClient, userId);
if (features.includes('pro-monthly')) {
// unlock premium UI
}@drakkar.software/doubloon-anchor stores entitlements as rows in a Supabase table with full metadata — expiry, source, revocation. The schema is compatible with @drakkar.software/anchor so client-side Anchor stores can read the same table directly.
pnpm add @drakkar.software/doubloon-anchor @supabase/supabase-jsimport { createClient } from '@supabase/supabase-js';
import { createAnchorDestination } from '@drakkar.software/doubloon-anchor';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
const dest = createAnchorDestination({
supabase,
products: PRODUCTS,
signerKey: 'service-role',
// tableName: 'entitlements', // default
});
const { serverConfig } = defineConfig({
products: PRODUCTS,
destination: dest,
bridges: { stripe },
onMintFailure,
});Anchor entitlements are full rows. checkEntitlement returns all four reasons:
| Reason | When |
|---|---|
active |
Row exists, active=true, not expired |
expired |
Row exists, active=true, expires_at in past |
revoked |
Row exists, active=false |
not_found |
No row for this product+user |
Apply packages/destinations/anchor/schema.sql to your Supabase project:
CREATE TABLE entitlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id TEXT NOT NULL,
user_wallet TEXT NOT NULL,
slug TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
auto_renew BOOLEAN NOT NULL DEFAULT false,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
revoked_at TIMESTAMPTZ,
revoked_by TEXT,
UNIQUE (product_id, user_wallet)
);
CREATE INDEX idx_entitlements_wallet ON entitlements (user_wallet);
CREATE INDEX idx_entitlements_product ON entitlements (product_id);import { createTableStore } from '@drakkar.software/anchor';
const entitlementsStore = createTableStore({
supabase,
table: 'entitlements',
});
const state = entitlementsStore.getState();
await state.fetch({ filters: [{ column: 'user_wallet', op: 'eq', value: userId }] });
const rows = entitlementsStore.getState().rows;
const hasPro = rows.some((r) => r.slug === 'pro-monthly' && r.active);Declarative wiring of products, destination, and bridges.
import { defineConfig, createServer } from '@drakkar.software/doubloon-server';
const { serverConfig, registry } = defineConfig({
products: PRODUCTS,
destination: dest, // any { reader, writer, signer }
bridges: { stripe, apple },
hooks: {
afterMint: async (instr, txSig) => analytics.track('mint', instr),
},
onMintFailure: async (instr, err) => alerting.send(err),
mintRetry: { maxRetries: 5, baseDelayMs: 50, maxDelayMs: 2000 },
mode: 'production', // optional: 'production' | 'sandbox' — rejects mismatched events
});- Validates slugs (lowercase alphanumeric + hyphens, no duplicates)
- Derives deterministic
productIdfrom each slug via SHA-256 - Returns
serverConfig(forcreateServer) andregistry(for slug/productId lookups)
One server for multiple independent apps.
import { createNamespacedServer } from '@drakkar.software/doubloon-server';
const ns = createNamespacedServer({
namespaces: {
'app-prod': {
products: prodProducts,
destination: createStarfishDestination({ client, products: prodProducts, signerKey: 'key' }),
bridges: { stripe, apple },
mode: 'production', // reject sandbox events in prod namespace
},
'app-staging': {
products: stagingProducts,
destination: stagingDest,
mode: 'sandbox', // reject live events in staging namespace
},
},
onMintFailure: async (instr, err) => console.error(err),
});
app.all('*', async (req, res) => {
const result = await ns.handleRequest({
method: req.method, url: req.url,
headers: req.headers as Record<string, string>,
body: req.body,
});
res.status(result.status).send(result.body);
});| Method | Path | Action |
|---|---|---|
POST |
/{namespace}/webhook |
Route webhook to namespace |
GET |
/{namespace}/check/{productId}/{user} |
Check entitlement |
GET |
/{namespace}/health |
Health check |
Namespace names: a-z A-Z 0-9 _ -. Reserved: webhook, check, health, products, entitlements, batch.
Each bridge sets StoreNotification.environment to 'production' or 'sandbox':
| Bridge | How environment is determined |
|---|---|
| Stripe | event.livemode — correct for both test-mode and live keys |
| Apple | Decoded from the signed JWS payload (payload.data.environment: "Sandbox" | "Production") |
Config flag only (environment option on GoogleBridgeConfig; defaults to 'production'). Google's RTDN schema carries no per-event environment signal — use a separate bridge key or Pub/Sub topic for test-tier traffic |
|
| x402 | Always 'production' |
Pass mode to defineConfig to reject events with a mismatched environment (HTTP 400, before deduplication):
const { serverConfig } = defineConfig({
products,
destination,
bridges: { stripe },
onMintFailure,
mode: 'production', // reject sandbox/test webhooks
});Omit mode (default) to accept both environments. Per-namespace mode is also supported in createNamespacedServer.
Store sends webhook
|
v
detectStore() — Routes by header/body pattern
|
v
Rate Limiter — 60 req/min per IP (configurable)
|
v
Bridge.handleNotification()
- Verify signature — Stripe HMAC, Apple JWS, Google JWT
- Parse notification — Normalize to StoreNotification
- Resolve product — Map store SKU → productId
- Resolve user — Map store user → identity
- Build instruction — MintInstruction or RevokeInstruction
|
v
Deduplication — Atomic check-and-mark (in-memory default)
|
v
processInstruction()
- beforeMint hook — Optional gate (return false to reject)
- mintWithRetry() — Writer.mintEntitlement + Signer.signAndSend
(Starfish: retries on OCC 409; Anchor: no retry needed)
- afterMint hook — Post-processing (analytics, notifications)
|
v
Return 200 OK
Any object satisfying DestinationLike (alias for Destination) works:
import type { Destination } from '@drakkar.software/doubloon-server';
const myDest: Destination = {
reader: {
async checkEntitlement(productId, user) { /* ... */ },
async checkEntitlements(productIds, user) { /* ... */ },
async getEntitlement(productId, user) { /* ... */ },
async getProduct(productId) { /* ... */ },
},
writer: {
async mintEntitlement(params) { /* return tx */ },
async revokeEntitlement(params) { /* return tx */ },
},
signer: {
async signAndSend(tx) { /* return txId */ },
publicKey: 'my-signer-id',
},
};Any payment source can be added by implementing the Bridge interface and registering it under an arbitrary key in bridges. Route requests to it by setting the x-doubloon-bridge header.
import type { Bridge } from '@drakkar.software/doubloon-server';
import type { StoreNotification, MintInstruction } from '@drakkar.software/doubloon-core';
const myBridge: Bridge = {
async handleNotification(headers, body) {
// 1. Verify the payload (signature, HMAC, etc.)
const payload = JSON.parse(body.toString());
if (!verify(payload, headers['x-my-signature'])) {
throw new Error('Invalid signature');
}
// 2. Build a normalized StoreNotification
const notification: StoreNotification = {
id: payload.eventId,
type: 'initial_purchase',
store: 'my-store', // arbitrary store name
environment: 'production',
productId: resolveProductId(payload.sku),
userWallet: payload.userId,
originalTransactionId: payload.txId,
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
autoRenew: payload.recurring ?? false,
storeTimestamp: new Date(payload.createdAt),
receivedTimestamp: new Date(),
deduplicationKey: `my-store:${payload.eventId}`,
raw: payload,
};
// 3. Return a MintInstruction (or RevokeInstruction, or null)
const instruction: MintInstruction = {
productId: notification.productId,
user: notification.userWallet,
expiresAt: notification.expiresAt,
source: 'my-store' as any,
sourceId: payload.txId,
};
return { notification, instruction };
},
};
const { serverConfig } = defineConfig({
products,
destination: dest,
bridges: {
stripe, // built-in
'my-store': myBridge, // custom
},
onMintFailure,
});Send webhooks to your custom bridge by including the x-doubloon-bridge header:
curl -X POST https://your-server/webhook \
-H "x-doubloon-bridge: my-store" \
-H "x-my-signature: ..." \
-d '{ "eventId": "...", ... }'The x-doubloon-bridge header also works for built-in bridges, bypassing auto-detection.
Each bridge already performs store-specific signature verification (Stripe HMAC, Apple JWS, Google JWT). For an additional shared-secret layer, set webhookSecret in your config:
const { serverConfig } = defineConfig({
products,
destination: dest,
webhookSecret: process.env.WEBHOOK_SECRET, // optional shared secret
// ...
});When webhookSecret is set, every incoming webhook must include the matching value in the x-doubloon-secret header. The comparison uses crypto.timingSafeEqual to prevent timing attacks. Requests with a missing or wrong header receive 401 Unauthorized.
# Send a webhook with the secret
curl -X POST https://your-server/webhook \
-H "x-doubloon-secret: $WEBHOOK_SECRET" \
-d '...'pnpm install
pnpm build
pnpm test # per-package unit tests
pnpm test:e2e # root integration tests (11 suites)
# Dev server (requires a running Starfish instance)
STARFISH_URL=http://localhost:3000 STARFISH_SIGNER_KEY=dev-key pnpm devpackages/
core/ — Shared types, ProductRegistry, WalletResolver, utilities
server/ — Webhook server, defineConfig, namespaced server, dedup, rate limiter
destinations/
starfish/ — Starfish destination (pull-modify-push, OCC retry)
anchor/ — Supabase destination (full rows, expiry, revocation)
bridges/
apple/ — Apple App Store bridge
google/ — Google Play bridge
stripe/ — Stripe bridge
x402/ — HTTP 402 bridge
tests/ — E2E integration tests (11 suites)
scripts/
run-server.ts — Local dev server (Starfish-backed)
See LICENSE for details.