Skip to content

Backend contract ​

Your backend implements two-to-four required endpoints (plus one optional). The library calls them; they call Attesto. This page documents the exact request/response shapes the library expects.

Implementing a custom transport?

If your backend isn't HTTP/JSON (GraphQL, Firebase, Supabase, gRPC-web), implement a BackendAdapter instead. The shapes below still apply at the domain level — only the wire encoding changes.

Overview ​

EndpointMethodPurposeRequired?
verifyApplePOSTValidate one Apple StoreKit transactioniOS-supporting builds only †
verifyGooglePOSTValidate one Google Play Billing transactionAndroid-supporting builds only †
entitlementsGETReturn the user's current entitlementsyes
restorePOSTValidate a batch of platform receipts (re-link user)yes
productsGETReturn the SKU manifest the app should registeroptional

† At least one of verifyApple / verifyGoogle must be set. iOS-only consumers can omit verifyGoogle and vice versa — the library throws IAPError(INVALID_CONFIG) with a clear message if the runtime ever dispatches to a missing endpoint.

Paths are configured via config.backend.endpoints. The defaults shown in Configuration — /api/iap/verify/apple etc. — are convention, not required.

Authentication ​

The library calls config.backend.getAuthHeaders() before every request and merges the returned headers into the outgoing call. Your backend authenticates the user from those headers (typically Authorization: Bearer <jwt>).

If your backend returns 401/403, the library throws IAPError(BACKEND_AUTH_FAILED) and does not retry — that's a fatal client-side problem, not a transient one.

verifyApple ​

http
POST <baseUrl><endpoints.verifyApple>
Content-Type: application/json
Authorization: Bearer <user token>

{
  "platform": "apple",
  "token": "2000000123456789",
  "productId": "premium_monthly",
  "productType": "subscription"
}
  • token — StoreKit 2 transactionId. The numeric string the plugin gives us.
  • productType — 'subscription' | 'product' | 'consumable'. Helps your backend decide expiry semantics.

Successful response ​

json
{
  "valid": true,
  "transaction": {
    "id": "2000000123456789",
    "productId": "premium_monthly",
    "expiresAt": "2026-04-30T12:00:00Z"
  },
  "entitlements": [
    {
      "key": "premium",
      "productId": "premium_monthly",
      "expiresAt": "2026-04-30T12:00:00Z",
      "tier": "pro"
    }
  ]
}
  • transaction — required on verify (the library surfaces transaction.id, productId, expiresAt in PurchaseResult).
  • expiresAt — ISO-8601 string or null for one-time non-consumables.
  • entitlements — the user's full entitlement set after this verification (not a delta). The library replaces the cache with this list.
  • Any extra fields you attach (e.g. verifiedAt, traceId, your own debug metadata) ride through unvalidated and unstripped — the library validates only the named fields above. Custom fields on entitlement objects (e.g. tier) likewise ride through; type them via createIAP<MyEntitlement> so consumer code stays typed.

Rejected response ​

json
{
  "valid": false,
  "error": "TRANSACTION_NOT_FOUND",
  "message": "Apple returned TRANSACTION_NOT_FOUND"
}
  • error — required, stable machine-readable code your client may key on.
  • message — optional human-readable detail. Both surface in the thrown IAPError.message.

When valid: false, the library throws IAPError(VERIFICATION_REJECTED). The transaction stays in the unfinished queue; you can use iap.refresh() to re-attempt later, but the library does NOT auto-retry rejections (transient backend errors are retried; explicit rejections are not).

Internal flow ​

client → your /verify/apple → Attesto POST /v1/apple/verify
       ←                    ←   { valid, expiresAt, ... }

Your backend translates Attesto's verdict into your entitlement model (look up the user, decide tier from product, set/update DB row, return your entitlement shape).

verifyGoogle ​

Identical to verifyApple except for two extra Android-specific fields:

json
{
  "platform": "google",
  "token": "ojnbalfgmieckdfgjnpekoam.AO-J1OyeQ8R...",
  "productId": "premium_monthly",
  "productType": "subscription",
  "packageName": "com.example.myapp",
  "androidPlanId": "monthly-plan"
}
  • token — Play Billing purchaseToken. Long opaque string.
  • packageName — required by Attesto's /v1/google/verify (Google Play's purchases.subscriptions.get is keyed on the package).
  • androidPlanId — base plan id, when the product is a subscription with multiple plans.

Response shape is identical to verifyApple.

entitlements ​

http
GET <baseUrl><endpoints.entitlements>
Authorization: Bearer <user token>

Response ​

json
{
  "entitlements": [
    {
      "key": "premium",
      "productId": "premium_monthly",
      "expiresAt": "2026-04-30T12:00:00Z",
      "tier": "pro"
    }
  ]
}

This is the canonical refresh path. Called by:

  • iap.refresh() (manual)
  • App resume (when refreshOnResume: true)
  • Cache TTL expiry (background refresh after initialize() emits ready)

The response replaces the entire local cache. Always return the full set, not a delta.

Empty list is meaningful

{ "entitlements": [] } means the user currently has no entitlements. The library will clear its cache and emit entitlements-changed with entitlements: []. Consumers should handle the empty case explicitly (lock premium UI, show paywall).

restore ​

http
POST <baseUrl><endpoints.restore>
Content-Type: application/json
Authorization: Bearer <user token>

{
  "transactions": [
    {
      "platform": "apple",
      "token": "2000000123456789",
      "productId": "premium_monthly",
      "productType": "subscription"
    },
    {
      "platform": "apple",
      "token": "2000000987654321",
      "productId": "remove_ads",
      "productType": "product"
    }
  ]
}

The library calls this when the consumer invokes iap.restorePurchases() — typically wired to a "Restore Purchases" button. The library:

  1. Asks the native plugin for all owned transactions (getOwnedTransactions()).
  2. Sends them in one request to /restore.
  3. Updates the entitlement cache from the consolidated response.

Response ​

json
{
  "valid": true,
  "entitlements": [
    {
      "key": "premium",
      "productId": "premium_monthly",
      "expiresAt": "2026-04-30T12:00:00Z"
    },
    {
      "key": "remove_ads",
      "productId": "remove_ads",
      "expiresAt": null
    }
  ]
}
  • valid: true — required.
  • entitlements — full entitlement set after restore (replaces cache).
  • No transaction field is required. Restore is a batch operation; the library never reads a per-batch transaction echo. If you do attach one, it rides through via passthrough — the library just doesn't gate on it.
  • Any other fields (analytics ids, server timestamps, debug metadata) ride through unvalidated.

The rejected shape mirrors verify ({ valid: false, error, message? }) and throws IAPError(VERIFICATION_REJECTED).

If a single receipt in the batch is invalid, your backend should still validate the rest and return the consolidated state — the library doesn't have per-receipt failure handling for restore (it's an idempotent re-link, not a paid purchase).

The iap.restorePurchases() Promise resolves to { restored, entitlements } where restored is the count of native transactions submitted to your backend (set by the library, not by your response).

products (optional) ​

Return the SKU manifest the app should register. The library calls this during initialize() only when config.products is omitted — letting the backend curate which SKUs to surface (feature flags, A/B mixes, regional catalogs, evolving catalogs between app releases).

http
GET <baseUrl><endpoints.products>
Authorization: Bearer <user token>

Response ​

json
{
  "products": [
    { "id": "premium_monthly", "type": "subscription", "androidPlanId": "monthly-plan" },
    { "id": "premium_yearly",  "type": "subscription", "androidPlanId": "yearly-plan" },
    { "id": "remove_ads",      "type": "product" }
  ]
}
  • id — must match the product id in App Store Connect / Google Play Console.
  • type — 'subscription' | 'product' | 'consumable'.
  • androidPlanId — required when type: 'subscription'. Maps to the Play Console base plan id.

Pre-registration is non-negotiable

Every id you return MUST already be registered in App Store Connect AND Google Play Console for the platforms you ship on. The backend manifest is a curated subset of pre-registered SKUs, not a registration. A new id surfaced only by the backend will fail at the platform store with no usable error.

When to use this vs static products: [...]:

Static products: [...]Backend endpoints.products
Small, stable catalogCatalog evolves between releases
Ships with the app — always available offlineRequires backend to be reachable on first launch
Simpler config; one less failure surfaceLets you toggle SKUs by region, cohort, feature flag
Ideal for tutorials, tests, prototypesProduction realism for apps that A/B price points or plans

Response transforms ​

If your backend wraps responses in an envelope ({ data: ..., meta: ... }), use the optional responseTransform:

typescript
backend: {
  // ...
  responseTransform: (raw) => raw.data,
};

Runs on the parsed JSON before the library's zod validation. See Configuration → backend.

Error responses ​

The library distinguishes four error categories — all observable as IAPErrorCode values:

HTTP / conditionCodeRetried?
Network failure, DNS, connection dropBACKEND_UNAVAILABLEyes
Timeout (timeoutMs exceeded)BACKEND_TIMEOUTyes
5xxBACKEND_UNAVAILABLEyes
408, 429BACKEND_UNAVAILABLEyes
401, 403BACKEND_AUTH_FAILEDno
Other 4xx, malformed JSON, schema failBACKEND_BAD_RESPONSEno
{ valid: false } from verifyVERIFICATION_REJECTEDno

Retry count comes from config.backend.retries (default 2, max 5). Backoff is exponential with jitter.

Reference implementation sketch ​

A minimal Express handler — not production code, just the shape:

typescript
import express from 'express';
import { Attesto } from '@nossdev/attesto-server';

const app = express();
const attesto = new Attesto({ apiKey: process.env.ATTESTO_KEY });

app.post('/api/iap/verify/apple', requireAuth, async (req, res) => {
  const { token, productId, productType } = req.body;
  const userId = req.user.id;

  const verdict = await attesto.verifyApple({
    transactionId: token,
    productId,
  });

  if (!verdict.valid) {
    return res.json({ valid: false, reason: verdict.reason });
  }

  await db.entitlements.upsert({
    userId,
    key: keyForProduct(productId),
    productId,
    expiresAt: verdict.expiresAt,
    tier: tierForProduct(productId),
  });

  const entitlements = await db.entitlements.findActive(userId);

  res.json({
    valid: true,
    transaction: {
      id: token,
      productId,
      expiresAt: verdict.expiresAt,
      verifiedAt: new Date().toISOString(),
    },
    entitlements,
  });
});

The same pattern repeats for /verify/google, /entitlements, /restore. See the Attesto docs for the full server SDK reference.

Next ​

Released under the MIT License. Pairs with Attesto for server-side receipt validation.