Purchases & subscriptions
Validated purchases are persisted behind the storage backend:
on the Postgres and SQLite backends they survive a node restart; the in-memory
backend stays non-durable by design. The raw receipt is never stored — only
its SHA-256 hex digest — so the store cannot leak resubmittable receipt
material. A transaction_id is recorded at most once (the durable primary key);
replaying the same receipt is a conflict. Subscriptions are a read-derived view
over purchases that carry an expiry — there is no separate subscription store.
The store enum
Section titled “The store enum”Every purchase/subscription row carries a store, one of:
| Value | Meaning |
|---|---|
apple |
Apple App Store. |
google |
Google Play. |
huawei |
Huawei AppGallery. |
custom |
A game-defined custom store — the dev validator’s natural home. |
The store value is recorded as given; the dev validator does not verify it
against the actual store — it only shapes the response and audit line.
Dev validator receipt shape
Section titled “Dev validator receipt shape”The dev validator (the only validator that ships) accepts a receipt as a JSON string with exactly these fields — unknown fields are rejected:
{ "transaction_id": "tx-1", "product_id": "gold-pack", "subscription_expiry_unix_ms": 1751999999999}| Field | Type | Required | Meaning |
|---|---|---|---|
transaction_id |
string | yes | Store-unique transaction id. Must be non-empty. |
product_id |
string | yes | The purchased product id. Must be non-empty. |
subscription_expiry_unix_ms |
integer | no | Present only for subscription products. When set, the purchase also shows up in the subscriptions listing with a status derived from this expiry. |
A non-subscription (consumable) purchase simply omits
subscription_expiry_unix_ms.
List purchases
Section titled “List purchases”GET /console/v1/purchases?user_id&limitAuth: bearer token, any role.
Query parameters
Section titled “Query parameters”| Name | Type | Required | Meaning |
|---|---|---|---|
user_id |
string | no | Restrict to one buying account. Omit to list every account’s purchases. |
limit |
integer | no | Page size, newest-first. Default 50, capped at 200. |
Response 200 OK
Section titled “Response 200 OK”{ "items": [ { "transaction_id": "tx-1", "user_id": "u-1", "product_id": "gold-pack", "store": "custom", "receipt_sha256": "3f786850e387550fdab836ed7e6dc881de23001b", "validated_at_unix_ms": 1751791000000 } ]}| Field | Type | Meaning |
|---|---|---|
items |
array | Newest-first validated purchases (by validation time, then transaction id), optionally filtered to user_id. |
items[].transaction_id |
string | Store-unique transaction id. |
items[].user_id |
string | The buying account. |
items[].product_id |
string | The purchased product. |
items[].store |
string | One of apple/google/huawei/custom. |
items[].receipt_sha256 |
string | SHA-256 hex digest of the raw receipt. The receipt itself is never stored. |
items[].validated_at_unix_ms |
integer | When the purchase was validated (Unix milliseconds). |
items[].subscription_expiry_unix_ms |
integer, optional | Present only when the product is a subscription. |
Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
401 |
authentication_failed |
Missing/invalid/expired bearer token. |
Example
Section titled “Example”curl -s "http://127.0.0.1:7350/console/v1/purchases?user_id=u-1&limit=20" \ -H "Authorization: Bearer $TOKEN"Get one purchase
Section titled “Get one purchase”GET /console/v1/purchases/:transaction_idAuth: bearer token, any role.
Path parameters
Section titled “Path parameters”| Name | Type | Required | Meaning |
|---|---|---|---|
transaction_id |
string | yes | The transaction id to look up. |
Response 200 OK
Section titled “Response 200 OK”One purchase object, same shape as an items[] entry above.
Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
401 |
authentication_failed |
Missing/invalid/expired bearer token. |
404 |
not_found |
No purchase with that transaction id. |
Example
Section titled “Example”curl -s http://127.0.0.1:7350/console/v1/purchases/tx-1 \ -H "Authorization: Bearer $TOKEN"Validate + record a purchase
Section titled “Validate + record a purchase”POST /console/v1/purchasesAuth: bearer token, admin only — a viewer token gets 403.
Audited as purchases.validate.
Body parameters
Section titled “Body parameters”| Name | Type | Required | Meaning |
|---|---|---|---|
user_id |
string | yes | The buying account. Not validated against the account store — any string is accepted. |
store |
string | yes | One of apple, google, huawei, custom. Any other value is a 400. |
receipt |
string | yes | The raw receipt document. For the shipped dev validator this must be the dev validator receipt shape as a JSON string. |
Unknown fields in the body are rejected (400).
Response 201 Created
Section titled “Response 201 Created”{ "transaction_id": "tx-1", "user_id": "u-1", "product_id": "gold-pack", "store": "custom", "receipt_sha256": "3f786850e387550fdab836ed7e6dc881de23001b", "validated_at_unix_ms": 1751791000000}Same shape as a purchase listing row (see List purchases).
Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
400 |
invalid_request |
Malformed body, unknown field/store value, or a receipt that fails validation (not JSON, missing/empty transaction_id/product_id, or unknown fields in the receipt document). |
401 |
authentication_failed |
Missing/invalid/expired bearer token. |
403 |
forbidden |
Caller has the viewer role. |
409 |
conflict |
The transaction_id was already recorded (replayed receipt). Nothing changes. |
Example
Section titled “Example”curl -s -X POST http://127.0.0.1:7350/console/v1/purchases \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user_id": "u-1", "store": "custom", "receipt": "{\"transaction_id\":\"tx-1\",\"product_id\":\"gold-pack\"}" }'Subscription example (sets subscription_expiry_unix_ms inside the receipt
document):
curl -s -X POST http://127.0.0.1:7350/console/v1/purchases \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "user_id": "u-1", "store": "apple", "receipt": "{\"transaction_id\":\"tx-vip\",\"product_id\":\"vip\",\"subscription_expiry_unix_ms\":1751999999999}" }'List subscriptions
Section titled “List subscriptions”GET /console/v1/subscriptions?user_id&limitAuth: bearer token, any role.
Only purchases whose receipt carried subscription_expiry_unix_ms appear
here; consumable (non-subscription) purchases never do.
Query parameters
Section titled “Query parameters”| Name | Type | Required | Meaning |
|---|---|---|---|
user_id |
string | no | Restrict to one account’s subscriptions. Omit to list every account’s. |
limit |
integer | no | Page size, newest-first. Default 50, capped at 200. |
Response 200 OK
Section titled “Response 200 OK”{ "items": [ { "transaction_id": "tx-vip", "user_id": "u-1", "product_id": "vip", "store": "apple", "expiry_unix_ms": 1751999999999, "status": "active" } ]}| Field | Type | Meaning |
|---|---|---|
items |
array | Newest-first subscription rows, optionally filtered to user_id. |
items[].transaction_id |
string | The owning transaction. |
items[].user_id |
string | The subscribing account. |
items[].product_id |
string | The subscription product. |
items[].store |
string | One of apple/google/huawei/custom. |
items[].expiry_unix_ms |
integer | Subscription expiry (Unix milliseconds). |
items[].status |
string | "active" or "expired", derived by comparing expiry_unix_ms against the read-time clock — not stored, recomputed on every request. |
Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
401 |
authentication_failed |
Missing/invalid/expired bearer token. |
Example
Section titled “Example”curl -s "http://127.0.0.1:7350/console/v1/subscriptions?user_id=u-1" \ -H "Authorization: Bearer $TOKEN"Known limitations
Section titled “Known limitations”- Dev validator only. No network call is made to any real store;
storeis recorded as given but not verified against it. Real App Store / Google Play validators are pending. - No game-client SDK surface. Receipt submission and purchase/subscription reads are console-admin-only today. A player-facing purchase API (Unreal/Unity/Godot/Rust clients) is not implemented.
- Raw receipts are never retained, only their SHA-256 digest — by design, not a gap, but it means a lost/forgotten receipt cannot be re-inspected from the store.
Source
Section titled “Source”src/repository/purchases.rs (PurchaseStore enum, Purchase/SubscriptionRow
value types, replay/paging/subscription-derivation contract, unit-tested),
src/repository/pg/purchases.rs + src/repository/sqlite/purchases.rs (durable
backends), src/services/purchases.rs (ReceiptValidator trait,
DevReceiptValidator, PurchaseService validate-then-delegate, unit-tested
including replay and malformed-receipt rejection),
src/http/console_api/purchases.rs (list_handler, validate_handler,
detail_handler, subscriptions_handler). Cross-reference
Admin console & console API for login, roles, and the
audit trail these routes participate in.