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"
}| Field | Type | Required | Description |
|---|---|---|---|
signed_transaction_info | string | Yes | JWS string from StoreKit 2 Transaction.jsonRepresentation |
user_id | string | No | Your app's user identifier. Required for subscription record creation. |
device_id | string | No | Device 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"
}| Field | Type | Description |
|---|---|---|
valid | boolean | Whether the receipt is valid |
transaction_id | string | Unique transaction identifier |
original_transaction_id | string | Original transaction ID (same across renewals) |
product_id | string | App Store product identifier |
entitlements | string[] | Entitlement keys granted by this product |
expires_date | string | null | ISO 8601 expiration date (null for non-expiring purchases) |
status | string | "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
-
JWS Decoding: The
signed_transaction_infois a JWS (JSON Web Signature) string with three base64url-encoded parts: header, payload, and signature. -
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. -
Transaction Upsert: The decoded transaction is upserted by
transactionId, storing product ID, purchase date, expiration date, environment (Production/Sandbox), and status. -
Subscription Upsert: If
user_idis provided, a subscription record is created or updated, keyed by(projectId, userId, originalTransactionId). -
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
| Condition | Status |
|---|---|
| Revocation date set | revoked |
| No expiration date (consumable/lifetime) | active |
| Expiration date in the future | active |
| Expiration date in the past | expired |
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| Parameter | Location | Type | Description |
|---|---|---|---|
publicKey | URL path | string | Your project's public key (pk_...) |
userId | URL path | string | The 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
}
]
}| Field | Type | Description |
|---|---|---|
user_id | string | The queried user ID |
status | string | Overall status: "active", "expired", "revoked", or "none" |
entitlements | string[] | Deduplicated entitlement keys from all active subscriptions |
subscriptions | object[] | Individual subscription records |
Subscription Object
| Field | Type | Description |
|---|---|---|
product_id | string | App Store product identifier |
status | string | "active", "expired", or "revoked" |
current_period_end | string | null | ISO 8601 end date of current billing period |
auto_renew_enabled | boolean | Whether 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:
StoreKitManagercompletes the transaction and callstransaction.finish()- The SDK posts the signed transaction to
POST /v1/receipts/:publicKey - The server validates and stores the transaction
- 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.