Integrating a product backend
What a product's backend must build to sell through UPG: keys, the callback endpoint, the local entitlement table, the checkout routes and the nightly reconcile. Read it before you wire a new product, or a new app of an existing product, to UPG.
How it works
How to build it
1. Keys and transport
- One API key per app and environment:
sk_test_…orsk_live_…. The key decides the app and environment, so you never send them separately. - Keys live in the secret store, for example SSM
/<product>/<env>/<service>/UPG_API_KEY. - Optional mutual TLS per app on a separate port. Turn it on for live.
- Use the server SDK (
nodeorpython). It retries with the same idempotency key, types the errors, and verifies callbacks. - Pin the SDK version exactly. For
0.xversions,^0.2.0means "below 0.3.0", so it never installs 0.4.0.
2. Every write has an idempotency key
Idempotency-Key of at least 8 characters. Same key and same body replays the stored
answer. Same key with a different body is a 422. A request still in flight is a 409.
For checkout, a good key is checkout:<user_ref>:<plan>:<attempt>.
3. The callback endpoint
app.register(upgWebhooks, {
path: '/upg/example-app/webhooks',
secrets: env.UPG_WEBHOOK_SECRETS.split(',').map(s => s.trim()), // two during rotation
onEvent: (event, tx) => applyEntitlements(tx, event), // claims event id, then upserts
});
- Verify the HMAC on the raw bytes. Header
x-upg-signature: v1=<hex>, signed over"<timestamp>.<raw body>", 5-minute tolerance. - During secret rotation UPG signs with both the new and the old secret for 24 hours. Accept a list of secrets.
- Dedupe on
x-upg-event-id, inside the same transaction as the upsert. - Order by the per-subscription
sequenceand the provider time. See Entitlements. - Answer 2xx quickly. UPG retries for 7 days, then marks the delivery dead and replayable.
- Exempt this route from your per-IP rate limit. UPG sends from a few addresses.
- Store the raw signed bytes for 30 days.
Each event carries the user's complete entitlement map for the app, so a full replace per user is always correct.
4. The local tables
| Table | Purpose |
|---|---|
entitlement | The paywall reads this. One row per user and entitlement, with an ordering guard |
callback_log | Dedupe and raw bytes, kept 30 days |
subscription_mirror | Display only: "your plan renews on…". Same ordering guard |
reconcile_subject | Users to reconcile tonight: anyone who started a checkout or validation |
Store only what gates the app quickly and processes callbacks correctly. Never store prices, gateways, mandates or money. Those belong to UPG.
5. Checkout and store purchases
| Product backend operation | UPG call | Notes |
|---|---|---|
| Show plans | GET /v1/catalog | Cache 5 minutes. On error show no price |
| Start checkout | POST /v1/checkout/initiate | Returns an action, not a URL |
| Return page | GET /v1/checkout/{id} | Check user_ref matches the caller |
| Confirm | POST /v1/checkout/{id}/verify | Safe to retry, no key needed |
| Store purchase | POST /v1/purchases/validate | Apple signed transaction or Play token |
| Apple account token | GET /v1/store-account-token | Before every iOS purchase |
| Cancel | POST /v1/subscriptions/{id}/cancel | Store subscriptions answer cancel_in_store with a manage_url |
| Upgrade | GET /v1/upgrades/quote, POST /v1/upgrades | Echo the quote; a changed price is 409 quote_changed |
Errors to handle in the UI: subscription_already_active (show "manage" with the
manage_url), upgrade_available (show the quote), not_priced_here,
account_token_mismatch, rail_error (502) and rail_misconfigured (503).
Map a UPG 401 or 403 to your own 502 billing_unavailable. It is our configuration, not
the user's session. Never tell them to sign in again.
6. Nightly reconcile
Page through entitlements.list for every app, and repair drift in the local table.
Follow the reconcile rules in Entitlements.
Rules
Every operation that reads or changes a subscription checks ownership first: the
subscription's user_ref must be the caller. Our first integration let any signed-in
user cancel or verify anyone's subscription.
The website never calls UPG and never holds a UPG key. Enforce it with a CSP
connect-src that excludes UPG and a CI check that greps for UPG hosts in web code.
One UPG client per app. If a product has two apps, every billing operation takes the app as an argument. Our first version sent one app's purchases to the other app's UPG account.
Mistakes we made
- Anyone could cancel anyone's subscription. Cancel and verify had no ownership check, and "cancel now" was exposed. Ownership is now checked by reading the subscription first; cancel-now is gone.
- Credit packs were never credited, and refunds always debited. The handler matched
guessed event type names. Credit on
subscription.activatedfor a pack plan, claw back onrefundedorrevoked. - Every rotation broke callbacks until the backend redeployed. UPG signed only with the new secret. It now signs with both during an overlap window, and backends accept a list.
- Reconcile missed the users who needed it most. It took "the 200 most recently updated", so a user whose first webhook was lost was never in it.
Checklist
- One key per app and environment, in the secret store.
- SDK version pinned exactly.
- Callback route verifies raw bytes, accepts two secrets, dedupes in the transaction.
- Callback route exempt from per-IP rate limits.
- Local entitlement table with the ordering guard; paywall reads only it.
- Ownership checked on every subscription operation.
- Website has no UPG key and cannot reach UPG.
- Nightly reconcile for every app.