Skip to content

REST API

Full reference for api.xizor.dev: identify, transactions, entitlements, paywalls, events, Apple notifications, and webhook signature verification.

Most apps never call this API directly — the iOS and Expo SDKs cover the client surface. Call it yourself for server-to-server entitlement checks (with a secret key, xz_sec_…) and to receive webhooks. This page is generated from the canonical API contract in the Xizor repository at build time, so it cannot drift from the implementation.

The public Xizor API consumed by the SDKs (XizorKit, @xizor/expo), by the Apple App Store (Server Notifications V2), and — for the entitlement/webhook surface — by customer servers. Implemented as Next.js route handlers in apps/api.

  • Base URL: https://api.xizor.dev
  • All request and response bodies are JSON, all field names are snake_case.
  • Timestamps are ISO 8601 strings (e.g. 2026-07-13T09:30:00Z).
  • CORS is not enabled — these endpoints are for native apps and servers, not browsers. OPTIONS requests are answered with 204 and an Allow header.

Authentication

All SDK endpoints require an API key:

Authorization: Bearer xz_pub_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Key kinds and the subscriber-identity rule

  • Publishable key (xz_pub_…) — safe to embed in client apps. It may address a subscriber only by the server-issued app_account_token (an unguessable UUID from /v1/identify). It cannot read/attribute by a developer-chosen app_user_id. This is deliberate: the publishable key ships in every binary, so allowing lookups by a guessable id would let anyone enumerate the paying-subscriber base. identify under a publishable key is anonymous (send a known app_account_token to re-attach, or nothing for a fresh one).
  • Secret key (xz_sec_…) — server-to-server only, never shipped. It may use a custom app_user_id for reads/attribution and to bind a stable cross-device identity. Associating a logged-in user to a subscriber is a backend (secret-key) operation.

So: GET /v1/entitlements and POST /v1/events accept a UUID app_account_token under a publishable key — pass the token (the UUID from /v1/identify) as the app_user_id parameter/field; a non-UUID value returns 403 forbidden. A custom app_user_id requires a secret key, and binding one to a subscriber is done via POST /v1/subscribers/alias.

Secret keys (xz_sec_…, server-side only) are also accepted on the same endpoints. Keys are looked up by SHA-256 hash; a revoked or unknown key yields 401 unauthorized.

The Apple notification endpoint (/v1/apple/notifications/{app_id}) uses no API key — authenticity comes from the JWS signature, which is verified against the pinned Apple root certificates and the app's registered bundle id.

Error format

Every error is:

{ "error": { "code": "invalid_request", "message": "Human-readable detail." } }

Stack traces are never leaked; unexpected failures return an opaque internal_error.

HTTPcodeMeaning
400invalid_requestMalformed JSON, failed schema validation, missing query parameter
400invalid_notificationNotification payload missing required fields (e.g. notificationUUID)
401unauthorizedMissing/invalid/revoked API key
401invalid_transactionJWS signature/chain verification failed for a transaction
401invalid_notificationJWS verification failed for a notification
403bundle_id_mismatchThe JWS bundle id does not match the app's registered bundle_id
404not_found / app_not_found / paywall_not_found / subscriber_not_foundResource missing
404apple_not_configuredThe app has no Apple IAP key (offer signatures need one)
405method_not_allowedWrong HTTP method
409conflictAlias conflict: the app_account_token is already bound to a different app_user_id
422unsupported_environmentJWS from Xcode/LocalTesting environment (only Production and Sandbox are accepted)
422invalid_transactionVerified payload missing required fields
500internal_errorUnexpected server error

POST /v1/identify

Find or create the subscriber for this device/user. Call once at SDK start and again after login. The returned app_account_token (UUID) must be passed to StoreKit as appAccountToken on every purchase — it is what ties App Store transactions back to the subscriber.

Lookup order: app_account_token → (app_id, app_user_id) → create. An anonymous subscriber is later merged with an app_user_id on the first identified call carrying its token.

Request

{
  "app_user_id": "user_42",
  "app_account_token": "6f8a2f0e-4f6e-4b1a-9f5e-1c2d3e4f5a6b",
  "platform": "ios",
  "sdk_version": "0.1.0"
}
FieldTypeRequiredNotes
app_user_idstringnoCustomer-assigned user id. Omit for anonymous subscribers.
app_account_tokenuuidnoToken from a previous identify (re-install / re-login).
platformstringyese.g. ios
sdk_versionstringyese.g. 0.1.0

Response 200

{
  "app_user_id": "user_42",
  "app_account_token": "6f8a2f0e-4f6e-4b1a-9f5e-1c2d3e4f5a6b",
  "entitlements": []
}

For anonymous subscribers app_user_id equals the app_account_token, so the field is always a stable, non-null identifier.


POST /v1/transactions

Server-side verification of a StoreKit 2 signed transaction (VerificationResult.jwsRepresentation). The JWS x5c chain is verified against the pinned Apple root CAs (with online revocation checks); the environment (Production/Sandbox) is taken from the payload itself, and the payload's bundleId must match the app's registered bundle id. On success the transaction is upserted (idempotent by transaction_id), product metadata is learned, and the subscription state is updated.

Request

{
  "signed_transaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6Wy4uLl19...",
  "app_user_id": "user_42"
}

Response 200

{
  "entitlements": [
    {
      "identifier": "pro",
      "is_active": true,
      "product_id": "com.example.pro.yearly",
      "expires_at": "2027-07-13T09:30:00.000Z",
      "will_renew": true,
      "is_trial": false,
      "state": "subscribed"
    }
  ]
}

state is one of subscribed, expired, in_grace_period, in_billing_retry, revoked, paused. Only subscribed and in_grace_period entitle the user. is_trial is derived from the offer type of the underlying subscription (introductory offer).

Notes:

  • Transactions from the Xcode or LocalTesting environment are rejected with 422 unsupported_environment (they are signed with local certificates and cannot pass Apple's chain).
  • If the payload carries an appAccountToken, subscriber attribution prefers it over app_user_id (it is cryptographically bound to the purchase).

GET /v1/entitlements?app_user_id=…

Current computed entitlements of a subscriber. app_user_id also accepts an app_account_token (how anonymous subscribers are addressed).

Response 200

{
  "entitlements": [
    {
      "identifier": "pro",
      "is_active": true,
      "product_id": "com.example.pro.yearly",
      "expires_at": "2027-07-13T09:30:00.000Z",
      "will_renew": true,
      "is_trial": false,
      "state": "subscribed"
    }
  ]
}

404 subscriber_not_found if the id is unknown.


GET /v1/paywall?placement=…&locale=…&context=…

The published paywall document for a placement. App code references placements, never paywalls directly; the dashboard decides which paywall a placement shows. locale is optional — documents carry all locales, the renderer picks.

context (optional) — placement rules

context is a URL-encoded JSON object (string | number | boolean values, max 20 keys, 1 KB — otherwise 400 invalid_request) describing the presentation, e.g. {"source":"onboarding","wasReferred":true}. When the placement has rules (dashboard → Placements → Rules, or the MCP tool set_placement_rules), they are evaluated top to bottom against this context — eq, neq (also matches when the param is absent), in, exists — and the first match decides which paywall is served. No match, no rules, malformed rules, or a rule paywall without a published version → the placement's assigned paywall (a hard gate never comes up empty). The context is also available to the renderer as {{context.<key>}} variables.

Without context, rules are skipped entirely (hot path).

Caching ("save → live")

  • Response carries ETag: "<paywall-version-id>" and Cache-Control: no-cache.
  • Clients revalidate with If-None-Match on every presentation; the server answers 304 Not Modified (empty body, same ETag) when unchanged.
  • A publish in the dashboard changes the version id, so the very next presentation fetches the new document — no app update needed.
  • With context, the ETag still is the version id of whichever paywall the rules selected — it varies naturally with the outcome, so SDK caches must key on placement + locale + (sorted) context.

Response 200

{
  "paywall_id": "0b9cf480-8f2f-4d55-a2b7-0a3f9f6f2f11",
  "paywall_identifier": "summer-sale",
  "version": 4,
  "placement": "onboarding",
  "document": { "schemaVersion": 1, "root": { "type": "stack" } },
  "products": [
    {
      "slot": "yearly",
      "store_product_id": "com.example.pro.yearly",
      "offer_id": "referral_yearly"
    },
    { "slot": "monthly", "store_product_id": "com.example.pro.monthly" }
  ]
}

document is the full paywall document (@xizor/paywall-schema); products is the slot → App Store product id mapping extracted from it, so SDKs can prefetch StoreKit products before rendering. offer_id (only present when the slot configures one) is the promotional offer the SDK signs via POST /v1/offers/signature before purchasing.

404 paywall_not_found when the placement is missing, inactive, has no paywall assigned, or the paywall was never published.


POST /v1/subscribers/alias

Secret key only (403 forbidden otherwise) — server-to-server, typically right after your user logs in. Binds a developer-chosen app_user_id to the subscriber holding app_account_token (the UUID your app got from the SDK — Xizor.shared.appAccountToken / client.getAppAccountToken()).

Request

{
  "app_account_token": "8f14e45f-ceea-4671-9f5e-1c2d3e4f5a6b",
  "app_user_id": "user_42"
}

Semantics

  • Idempotent: aliasing to the id the token is already bound to → result: "noop".
  • First bindresult: "set".
  • Merge: if app_user_id already belongs to a _different_ subscriber (e.g. the user reinstalled and got a fresh anonymous token), that subscriber's transactions, subscription states, events and attributes are merged into the token subscriber atomically and the duplicate is deleted → result: "merged". The token subscriber always wins — the SDK holds the token and it is embedded in future signed transactions.
  • 409 conflict when the token is already bound to a different app_user_id (re-aliasing is not allowed; log the user out server-side and identify fresh instead).
  • 404 subscriber_not_found for an unknown token.

Response 200

{
  "result": "merged",
  "app_user_id": "user_42",
  "app_account_token": "8f14e45f-ceea-4671-9f5e-1c2d3e4f5a6b",
  "entitlements": [{ "identifier": "pro", "is_active": true, "…": "…" }]
}

POST /v1/subscribers/import

Secret key only (403 forbidden otherwise) — bulk provider-migration import, called from your backend when cutting over from another subscription provider. For each original_transaction_id the current subscription state is fetched from the App Store Server API, every signed transaction is JWS-verified through the same pinned-chain path as device syncs, and subscribers, transactions, and subscription states are seeded — entitlements are live before any device ships the new SDK.

Request

{
  "subscribers": [
    {
      "original_transaction_id": "2000000123456789",
      "app_account_token": "8f14e45f-ceea-4671-9f5e-1c2d3e4f5a6b",
      "app_user_id": "user_42"
    }
  ]
}
  • subscribers: 1–50 items per request — page through your subscriber base in batches (items are processed sequentially against App Store Server API rate limits).
  • app_account_token (optional): seeds the subscriber token when the purchase JWS carries none.
  • app_user_id (optional): developer user id to bind after import (alias semantics).

Response 200

{
  "imported": 1,
  "not_found": 0,
  "errors": 0,
  "results": [
    { "original_transaction_id": "2000000123456789", "result": "imported" }
  ]
}

POST /v1/offers/signature

ES256 signature for an App Store Connect promotional offer — publishable or secret key (the signature only lets the current subscriber redeem a developer-created discount; it grants no data access). Stateless, nothing is written. The SDKs call this automatically when a paywall product slot has an offerId.

Request

{
  "store_product_id": "com.example.pro.yearly",
  "offer_id": "referral_yearly",
  "app_account_token": "8f14e45f-ceea-4671-9f5e-1c2d3e4f5a6b"
}

Response 200

{
  "key_id": "2X9R4HXF34",
  "nonce": "f8f14e45-ceea-4671-9f5e-1c2d3e4f5a6b",
  "timestamp": 1789300200000,
  "signature": "MEUCIQ…(base64)"
}

Maps 1:1 onto StoreKit's Product.PurchaseOption.promotionalOffer(offerID:keyID:nonce:signature:timestamp:) (and expo-iap's withOffer). Apple honors the signature for 24 h from timestamp. 404 apple_not_configured until the app has an IAP key id + .p8 configured (dashboard → App settings → Apple).


POST /v1/events

Batch event ingestion (1–500 events per request). Feeds all paywall funnel and analytics stats.

Request

{
  "events": [
    {
      "name": "paywall_view",
      "app_user_id": "user_42",
      "placement_id": "b57a2f0e-4f6e-4b1a-9f5e-1c2d3e4f5a6b",
      "paywall_id": "0b9cf480-8f2f-4d55-a2b7-0a3f9f6f2f11",
      "paywall_version": 4,
      "properties": { "source": "onboarding" },
      "client_ts": "2026-07-13T09:30:00Z"
    }
  ]
}
FieldTypeRequired
namestring (≤120)yes
app_user_idstringyes (accepts app_account_token)
placement_iduuidno
paywall_iduuidno
paywall_versionintno
propertiesobjectno
client_tsISO 8601yes

Well-known names: paywall_view, paywall_dismiss, purchase_started, purchase_completed, purchase_failed, restore; anything else is treated as a custom event. Events with an unknown app_user_id are stored without a subscriber link — they never fail the batch.

Response 200

{ "accepted": 1 }

POST /v1/products

Store-catalog metadata sync — the SDKs report product metadata they learn from StoreKit (billing period, type, subscription group, display-price variables). The nominal product period is what lets analytics normalize MRR (sandbox transaction durations are compressed). Publishable keys are allowed: the data is already public in the App Store, and verified prices are never touched here — the variables are stored for the paywall editor preview only.

Request

{
  "products": [
    {
      "store_product_id": "com.example.pro.yearly",
      "type": "auto_renewable",
      "period": "P1Y",
      "subscription_group_id": "21341234",
      "variables": { "price": "$39.99", "pricePerMonth": "$3.33" },
      "storefront": "USA"
    }
  ]
}
  • products: 1–100 items. type is one of auto_renewable, consumable, non_consumable, non_renewing; period is an ISO-8601 billing cycle (P1W, P1M, P1Y, …), null for non-subscriptions.

Response 200

{ "synced": 1 }

POST /v1/apple/notifications/{app_id}

App Store Server Notifications V2 receiver. Configure in App Store Connect (App Information → App Store Server Notifications, both Production and Sandbox):

https://api.xizor.dev/v1/apple/notifications/<your-xizor-app-id>

<your-xizor-app-id> is the app's Xizor id (UUID, shown in the dashboard).

Request (sent by Apple)

{ "signedPayload": "eyJhbGciOiJFUzI1NiIs..." }

Processing

  1. JWS verification against the pinned Apple root CAs; the bundle id inside the payload must match the app's registered bundle id.
  2. Idempotent insert into store_notifications keyed by notificationUUID — Apple retries are acknowledged with 200 without reprocessing.
  3. The inner signedTransactionInfo is verified and upserted into transactions (renewals, refunds, revocations keep revenue data current).
  4. The notification state machine updates subscription_states (all 23 V2 notification types are mapped).
  5. The event is fanned out to the app's webhook endpoints (see below).

Response

200 {"ok": true} on success ({"ok": true, "duplicate": true} for retries of already-processed notifications). Any non-2xx tells Apple to retry later.


GET /v1/health

Unauthenticated liveness probe.

{ "ok": true, "version": "0.1.0" }

Customer webhooks

When an Apple notification is processed, Xizor delivers an event to every webhook endpoint configured for the app (dashboard → App → Webhooks). An endpoint with an empty event-type filter receives all events.

Event names

apple.<notification_type lowercased>, e.g. apple.subscribed, apple.did_renew, apple.expired, apple.refund, apple.did_change_renewal_status.

apple.notification.raw — the verbatim Apple JWS (signed_payload) plus routing metadata, for customers who keep their own notification pipeline (e.g. mid-migration from another provider: point App Store Connect at Xizor, keep your old consumer fed). Opt-in only: an endpoint receives it only when the event type is listed explicitly in its filter — catch-all endpoints (empty filter) never get it.

{
  "data": {
    "notification_uuid": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
    "notification_type": "DID_RENEW",
    "subtype": null,
    "environment": "Production",
    "signed_payload": "eyJhbGciOiJFUzI1NiIsIng1YyI6…"
  }
}

Delivery

POST to your endpoint URL:

Content-Type: application/json
X-Xizor-Event: apple.did_renew
X-Xizor-Delivery-Id: 6d1a…            (unique per delivery; use for idempotency)
X-Xizor-Signature: t=1789300200,v1=5257a869e7…
{
  "id": "6d1a2f0e-4f6e-4b1a-9f5e-1c2d3e4f5a6b",
  "event": "apple.did_renew",
  "created_at": "2026-07-13T09:30:00.000Z",
  "data": {
    "notification_uuid": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
    "notification_type": "DID_RENEW",
    "subtype": null,
    "environment": "Production",
    "app_id": "e2a3…",
    "bundle_id": "com.example.app",
    "original_transaction_id": "2000000123456789",
    "product_id": "com.example.pro.yearly",
    "state": "subscribed",
    "expires_at": "2027-07-13T09:30:00.000Z",
    "app_user_id": "user_42",
    "signed_date": "2026-07-13T09:29:58.000Z"
  }
}

Respond with any 2xx within 10 seconds. Failed deliveries are retried on a backoff schedule (1 min, 5 min, 30 min, 2 h, 12 h; 6 attempts total). Retries are drained by a background worker (GET /internal/webhooks/retry, Vercel Cron every 5 minutes, authenticated via CRON_SECRET), so a delivery whose first attempt failed is re-attempted even if no further traffic arrives.

Refund protection (CONSUMPTION_REQUEST)

When refund protection is enabled for an app (dashboard → App settings → Apple) and Apple sends a CONSUMPTION_REQUEST notification (a customer asked for a refund), Xizor automatically answers within Apple's ~12 h window via the App Store Server API (sendConsumptionInformation): elapsed share of the current subscription period as consumptionPercentage, deliveryStatus: DELIVERED, sampleContentProvided from the subscription's intro-offer history, and refundPreference: DECLINE once ≥ 80 % of the period is consumed. Requires the app's Apple issuer id, key id and .p8. Enable only if your app collects the customer's consent to share consumption data with Apple (customerConsented). The apple.consumption_request webhook event is dispatched either way.

Verifying the signature

Each endpoint has a secret (whsec_…, shown once at creation, rotatable). The X-Xizor-Signature header is t=<unix seconds>,v1=<hex> where:

v1 = HMAC_SHA256(secret, "<t>." + <raw request body>)

Verification (Node.js):

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyXizorSignature(
  rawBody,
  signatureHeader,
  secret,
  toleranceSeconds = 300,
) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds)
    return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return (
    given.length === expected.length / 2 &&
    timingSafeEqual(Buffer.from(expected, "hex"), given)
  );
}

Always compute the HMAC over the raw body bytes (before any JSON parsing), compare in constant time, and reject stale timestamps to prevent replays.


Environment variables (apps/api)

VariablePurpose
SUPABASE_URLSupabase project URL
SUPABASE_SERVICE_ROLE_KEYservice_role key (createServiceClient from @xizor/db/server)
CRON_SECRETBearer secret for GET /internal/webhooks/retry (Vercel Cron sends it automatically once set)

Apple root certificates (AppleRootCA-G3.cer, AppleRootCA-G2.cer, AppleIncRootCertificate.cer) live in apps/api/certs/ and are bundled via outputFileTracingIncludes; re-download from https://www.apple.com/certificateauthority/ when Apple rotates roots.