Skip to main content

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_… or sk_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 (node or python). It retries with the same idempotency key, types the errors, and verifies callbacks.
  • Pin the SDK version exactly. For 0.x versions, ^0.2.0 means "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 sequence and 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​

TablePurpose
entitlementThe paywall reads this. One row per user and entitlement, with an ordering guard
callback_logDedupe and raw bytes, kept 30 days
subscription_mirrorDisplay only: "your plan renews on…". Same ordering guard
reconcile_subjectUsers 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 operationUPG callNotes
Show plansGET /v1/catalogCache 5 minutes. On error show no price
Start checkoutPOST /v1/checkout/initiateReturns an action, not a URL
Return pageGET /v1/checkout/{id}Check user_ref matches the caller
ConfirmPOST /v1/checkout/{id}/verifySafe to retry, no key needed
Store purchasePOST /v1/purchases/validateApple signed transaction or Play token
Apple account tokenGET /v1/store-account-tokenBefore every iOS purchase
CancelPOST /v1/subscriptions/{id}/cancelStore subscriptions answer cancel_in_store with a manage_url
UpgradeGET /v1/upgrades/quote, POST /v1/upgradesEcho 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​

Rule

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.

Rule

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.

Rule

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.activated for a pack plan, claw back on refunded or revoked.
  • 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.