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.
OPTIONSrequests are answered with204and anAllowheader.
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-issuedapp_account_token(an unguessable UUID from/v1/identify). It cannot read/attribute by a developer-chosenapp_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.identifyunder a publishable key is anonymous (send a knownapp_account_tokento re-attach, or nothing for a fresh one). - Secret key (
xz_sec_…) — server-to-server only, never shipped. It may use a customapp_user_idfor 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.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed JSON, failed schema validation, missing query parameter |
| 400 | invalid_notification | Notification payload missing required fields (e.g. notificationUUID) |
| 401 | unauthorized | Missing/invalid/revoked API key |
| 401 | invalid_transaction | JWS signature/chain verification failed for a transaction |
| 401 | invalid_notification | JWS verification failed for a notification |
| 403 | bundle_id_mismatch | The JWS bundle id does not match the app's registered bundle_id |
| 404 | not_found / app_not_found / paywall_not_found / subscriber_not_found | Resource missing |
| 404 | apple_not_configured | The app has no Apple IAP key (offer signatures need one) |
| 405 | method_not_allowed | Wrong HTTP method |
| 409 | conflict | Alias conflict: the app_account_token is already bound to a different app_user_id |
| 422 | unsupported_environment | JWS from Xcode/LocalTesting environment (only Production and Sandbox are accepted) |
| 422 | invalid_transaction | Verified payload missing required fields |
| 500 | internal_error | Unexpected 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"
}
| Field | Type | Required | Notes |
|---|---|---|---|
app_user_id | string | no | Customer-assigned user id. Omit for anonymous subscribers. |
app_account_token | uuid | no | Token from a previous identify (re-install / re-login). |
platform | string | yes | e.g. ios |
sdk_version | string | yes | e.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
XcodeorLocalTestingenvironment are rejected with422 unsupported_environment(they are signed with local certificates and cannot pass Apple's chain). - If the payload carries an
appAccountToken, subscriber attribution prefers it overapp_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>"andCache-Control: no-cache. - Clients revalidate with
If-None-Matchon every presentation; the server answers304 Not Modified(empty body, sameETag) 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 bind →
result: "set". - Merge: if
app_user_idalready 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 conflictwhen the token is already bound to a differentapp_user_id(re-aliasing is not allowed; log the user out server-side and identify fresh instead).404 subscriber_not_foundfor 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"
}
]
}
| Field | Type | Required |
|---|---|---|
name | string (≤120) | yes |
app_user_id | string | yes (accepts app_account_token) |
placement_id | uuid | no |
paywall_id | uuid | no |
paywall_version | int | no |
properties | object | no |
client_ts | ISO 8601 | yes |
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.typeis one ofauto_renewable,consumable,non_consumable,non_renewing;periodis 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
- JWS verification against the pinned Apple root CAs; the bundle id inside the payload must match the app's registered bundle id.
- Idempotent insert into
store_notificationskeyed bynotificationUUID— Apple retries are acknowledged with200without reprocessing. - The inner
signedTransactionInfois verified and upserted intotransactions(renewals, refunds, revocations keep revenue data current). - The notification state machine updates
subscription_states(all 23 V2 notification types are mapped). - 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)
| Variable | Purpose |
|---|---|
SUPABASE_URL | Supabase project URL |
SUPABASE_SERVICE_ROLE_KEY | service_role key (createServiceClient from @xizor/db/server) |
CRON_SECRET | Bearer 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.