API Reference
Receipts & Subscriptions

Receipts & Subscriptions

Server-side receipt validation and subscription status endpoints. These are used by the iOS SDK automatically after StoreKit 2 purchases, but can also be called directly from your backend.

Receipt Validation

POST /v1/receipts/:publicKey

Validate a StoreKit 2 signed transaction. Decodes the JWS payload, verifies the signature (when enabled), upserts transaction and subscription records, and returns entitlements.

Authentication: Public key in URL path (no Bearer token required).

Request

POST /v1/receipts/pk_your_public_key
Content-Type: application/json
 
{
  "signed_transaction_info": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF...",
  "user_id": "user_123",
  "device_id": "device_abc"
}
FieldTypeRequiredDescription
signed_transaction_infostringYesJWS string from StoreKit 2 Transaction.jsonRepresentation
user_idstringNoYour app's user identifier. Required for subscription record creation.
device_idstringNoDevice identifier for tracking

Response (201 Created)

{
  "valid": true,
  "transaction_id": "1000000123456789",
  "original_transaction_id": "1000000123456000",
  "product_id": "com.myapp.pro.yearly",
  "entitlements": ["pro"],
  "expires_date": "2027-03-20T00:00:00.000Z",
  "status": "active"
}
FieldTypeDescription
validbooleanWhether the receipt is valid
transaction_idstringUnique transaction identifier
original_transaction_idstringOriginal transaction ID (same across renewals)
product_idstringApp Store product identifier
entitlementsstring[]Entitlement keys granted by this product
expires_datestring | nullISO 8601 expiration date (null for non-expiring purchases)
statusstring"active", "expired", or "revoked"

Error Responses

400 Bad Request -- Missing or invalid fields:

{
  "error": "Validation error",
  "details": [{ "path": ["signed_transaction_info"], "message": "Required" }],
  "suggestion": "Request body requires signed_transaction_info (string). Optional: user_id, device_id."
}

400 Bad Request -- Invalid JWS format:

{
  "error": "Invalid JWS format: expected 3 dot-separated parts",
  "code": "INVALID_JWS_FORMAT",
  "suggestion": "Provide a valid JWS string (3 dot-separated base64url parts) from StoreKit 2 Transaction.jsonRepresentation."
}

400 Bad Request -- JWS verification failed:

{
  "error": "JWS signature verification failed: ...",
  "code": "JWS_VERIFICATION_FAILED",
  "suggestion": "The JWS signature could not be verified against Apple's certificate chain. Ensure you are sending a genuine StoreKit 2 signed transaction."
}

401 Unauthorized -- Invalid public key:

{
  "error": "Invalid public key.",
  "code": "AUTH_INVALID_PUBLIC_KEY",
  "suggestion": "Use a valid public key (pk_...) in the URL path: POST /v1/receipts/:public_key."
}

How Validation Works

  1. JWS Decoding: The signed_transaction_info is a JWS (JSON Web Signature) string with three base64url-encoded parts: header, payload, and signature.

  2. Signature Verification: When APPSTORE_VERIFY_RECEIPTS=true (default in production), the server verifies the JWS signature using the x5c certificate chain from the JWS header against Apple's root CA. When disabled (sandbox/development), the payload is decoded without verification.

  3. Transaction Upsert: The decoded transaction is upserted by transactionId, storing product ID, purchase date, expiration date, environment (Production/Sandbox), and status.

  4. Subscription Upsert: If user_id is provided, a subscription record is created or updated, keyed by (projectId, userId, originalTransactionId).

  5. Entitlement Lookup: The server looks up the product's configured entitlements in the project's product table and returns them in the response.

Status Computation

ConditionStatus
Revocation date setrevoked
No expiration date (consumable/lifetime)active
Expiration date in the futureactive
Expiration date in the pastexpired
⚠️

Set APPSTORE_VERIFY_RECEIPTS=true in production to enable full JWS signature verification against Apple's certificate chain. In development/sandbox, you can set it to false to skip verification.


Subscription Status

GET /v1/subscriptions/:publicKey/:userId

Query the current subscription status and entitlements for a user. Returns all subscription records and aggregated active entitlements.

Authentication: Public key in URL path (no Bearer token required).

Request

GET /v1/subscriptions/pk_your_public_key/user_123
ParameterLocationTypeDescription
publicKeyURL pathstringYour project's public key (pk_...)
userIdURL pathstringThe user identifier

Response (200 OK)

{
  "user_id": "user_123",
  "status": "active",
  "entitlements": ["pro"],
  "subscriptions": [
    {
      "product_id": "com.myapp.pro.yearly",
      "status": "active",
      "current_period_end": "2027-03-20T00:00:00.000Z",
      "auto_renew_enabled": true
    }
  ]
}
FieldTypeDescription
user_idstringThe queried user ID
statusstringOverall status: "active", "expired", "revoked", or "none"
entitlementsstring[]Deduplicated entitlement keys from all active subscriptions
subscriptionsobject[]Individual subscription records

Subscription Object

FieldTypeDescription
product_idstringApp Store product identifier
statusstring"active", "expired", or "revoked"
current_period_endstring | nullISO 8601 end date of current billing period
auto_renew_enabledbooleanWhether auto-renewal is enabled

Error Responses

401 Unauthorized -- Invalid public key:

{
  "error": "Invalid public key.",
  "code": "AUTH_INVALID_PUBLIC_KEY",
  "suggestion": "Use a valid public key (pk_...) in the URL path."
}

If no subscriptions exist for the user, the response returns status: "none" with an empty subscriptions array.


Integration with iOS SDK

The iOS SDK handles receipt validation automatically. After a successful StoreKit 2 purchase:

  1. StoreKitManager completes the transaction and calls transaction.finish()
  2. The SDK posts the signed transaction to POST /v1/receipts/:publicKey
  3. The server validates and stores the transaction
  4. Entitlements are returned and applied locally

For apps using a custom PurchaseController, receipt validation still works the same way -- the SDK posts receipts regardless of which controller handles the purchase.

See the Purchases & StoreKit guide for the full client-side integration.