Badge service RPC protocol

Schema: badges-rpc.schema.json, definitions request and response. Types: Simplex.Chat.Badges.Service. Model: plans/2026-07-30-supporter-badges-v3-ux.md §3 — cited below as "model".

Transport

Service RPC (plans/2026-07-22-service-rpc-chat.md, branch rpc): the request travels in APISendServiceRequest.request, the response in CRServiceResponse.responseData; one response per request; per-call timeout.

A request is an envelope: version — the client's protocol version; purchaseKey; request — the command, discriminated on type. Responses are discriminated on type. The service is deployed ahead of app releases, answers within the client's version, and rejects clients older than it supports with unsupported_version.

Identity

Each purchase runs under a fresh Ed25519 key pair; purchaseKey is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below and a store subscription moved by transferBadge, whose old purchase's ledger names the new key. getBadgeCatalog may omit purchaseKey: unsigned, it returns the catalog alone; signed, its response adds the purchase's badgeStatement — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a purchaseKey that differs from it with bad_request.

A purchase record is created by redeemBadgeCode, by getBadgeInvoice (not built), by purchaseBadge funded with apple or google (or receipt, post-MVP), or by transferBadge. Those commands accept a key the service holds no record of — on a first purchase it always will, and transferBadge requires one. Every other command answers unknown_purchase_key for such a key.

Idempotency

A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer.

  • getBadgeInvoice — returns the open invoice again; a new invoice is created only when none is open.
  • redeemBadgeCode — a code already redeemed by the signing key returns its latest badgeCredential and writes nothing; once other keys have taken all its uses, code_used. The client must therefore keep the key it first signed with, or a retry cannot be recognised.
  • purchaseBadge — a payment already credited returns the same badgeCredential and writes nothing; a store payment credited to another key, receipt_used.
  • upgradeBadgeSubscription — evidence already applied returns the same result and writes nothing.
  • transferBadge — a payment already moved to the signing key returns the same badgeCredential, without asking the store and without the move limit.
  • issueBadge — repeated within an issued period, returns the cached credential and writes nothing.
  • purchaseBadge with the recovery receipt — presented again by the same key it returns the same result; presented by another key, receipt_used.

Commands

The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the sundayAfter of the period issued (endOfMondayAfter in code), shared by every credential of that week; badgeExtra is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries badgeRequest.

  • getBadgeCatalog → badgeCatalog — the prices and offers; signed, also the purchase's badgeStatement. Store builds never send it: prices come from the store and SKUs from app config.
  • getBadgeInvoice → badgeInvoice — prices the purchase for badgeInfo and paymentVia (card — Stripe; crypto — btc, xmr). The response holds the generic invoice — invoiceId, price, discount, the upgrade credit, amount = price − discount − credit, currency, expiresAt, and paymentTo (url for card; address and cryptoAmount for crypto) — beside the badge part, badgeType and months. priceId pins the price the client displayed; offerId selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: deprecated is still accepted, disabled is rejected; a badge type with no active price yields product_unavailable.
  • redeemBadgeCode → badgeCredential — redeems a code, records the credit, and issues the first credential, in one round trip. It carries masterKey and code and no badgeRequest: a code states no tier and no expiry, so the credential is what reports them. Errors: code_invalid for an unknown or malformed code, code_used when other keys have taken all its uses (a code has one use unless issued with more), code_expired past a redemption deadline.
  • purchaseBadge → badgeCredential — verifies the funding (apple JWS offline; google product id and token via the Publisher API; invoice against webhook-confirmed settlement, payment_pending until it lands; receipt), records the credit, and issues the first credential, in one round trip. It carries masterKey and no badgeRequest. The response receipt is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
    • A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. An App Store auto-renewable subscription is claimed by its originalTransactionId, which every renewal keeps, so all its charges fund one purchase; a Play subscription keeps its token across renewals already. A receipt already credited to the signing key is answered from the record, without asking the store.
    • Subscriptions. A Play subscription is verified by reading the subscription itself, not by the one-time purchase read; an App Store one by its JWS offline, as any transaction, and then by reading the subscription's status. The latest charge the store reports is credited as a charge credit for the months of the plan it was for, dated to the store's billing period start, and the response carries subscription. A charge is credited only while its period runs, if it is not revoked, not bought with an offer (on the App Store any offer type; on Play a free-trial or introductory-price phase), for a plan this service sells, while the subscription has not ended, and while the months its plan grants from its period start still run (Play derives that start by clipping at month ends, so a period's last days can fund nothing). Otherwise nothing is written. No record yet bounds how often the store is asked, so a subscription is asked about at most once an hour on this path, an App Store one per original transaction id and a Play one per token, in an hour not shared with transferBadge's: presented to purchaseBadge again inside that hour, by any key (and an App Store one as any of its transactions), it is answered payment_pending with the time left, and an answer whose retryAfter would be shorter carries the hour. Across all requests, this path asks Play about at most 300 subscriptions a minute (one it credits is read once more to acknowledge it): a Play subscription presented when none is left is answered rate_limited with the seconds left in the minute, and is not held off. The answers: payment_pending with an hour for a period that has not started yet or a store that reports the payment pending; receipt_invalid for a Play subscription that has ended, since Play issues a new token when it is bought again; payment_pending with a day for everything a later charge on the same receipt could still change — a revoked charge, one in an offer, no charge yet, a period over while the store still retries, a charge whose months from its period start have already run out, a plan this service does not sell, and an App Store subscription that has ended, which a resubscription under the same original id would continue. A charge whose period does not end after it starts is answered internal, and a deployment that cannot read a store's subscriptions answers provider_not_configured.
    • Errors: receipt_invalid, test_purchase, payment_pending, provider_unavailable — see the classes below; rate_limited for a Play subscription when this path's Play checks for the minute are used; receipt_used when another key was credited with it; product_unavailable for a product this service does not price; provider_not_configured when this deployment has no verifier for the store. receipt_invalid, test_purchase and receipt_used are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger.
    • Which class an answer belongs to. The two mistakes do not cost the same, so when it is not certain, answer unreachable. receipt_invalid tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good, but for an App Store subscription charge, which stops holding the profile a day after its period ends.
      • Terminal, receipt_invalid — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes.
      • Terminal and named, test_purchase — the store vouched for a purchase that cost nothing: Apple's Sandbox, or Play's purchaseType test. It is separated from receipt_invalid for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason.
      • Pending, payment_pending — a real purchase the store has not settled.
      • Unreachable, provider_unavailable — the store was not asked or did not answer.
      • Internal, internal — this service's own failure, never the store's answer.
      • App Store. Apple's receipt is verified offline, so it has no "not yet"; a subscription's status is then read from the App Store Server API, which can fail to answer.
        • receipt_invalid: a JWS that is not three base64url parts or names no transactionId, or, for an auto-renewable subscription, no originalTransactionId; an x5c certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a revocationDate, refunded or revoked; and a subscription status whose transaction is family-shared.
        • test_purchase: refused by the service after the verifier vouched for it, a Sandbox transaction, and a subscription status from the Sandbox environment.
        • provider_unavailable, for a subscription's status only: network failure or timeout, and every error response but 401 and 403 — a 404 included, which Apple answers for an original transaction id its environment does not hold — 429 and 5xx among them; and an answer that names no such subscription, as after a Sandbox purchase history is cleared, which is no more certain than that 404.
        • internal: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's bundleId, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name ES256 or carries no x5c of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown environment; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. For a subscription's status: Apple refusing this service's API token or key (401, 403); an original transaction id that is not all digits; an answer over a mebibyte, or whose list of subscriptions or entry for this subscription cannot be read, an unreadable entry for another subscription being ignored; a signed transaction or renewal info that fails any check above (Apple answered it and no client presented it, so it is never receipt_invalid), is not a subscription transaction or renewal info, names another app's bundle id or another subscription, or an unknown environment; and a status this service does not know.
        • product_unavailable: a product this service does not price — the verifier vouched for the transaction either way.
        • provider_not_configured: the deployment has no [apple] section, and, for a subscription, an [apple] section without an App Store Server API key.
      • Google Play. Only a purchase record is a verdict.
        • receipt_invalid: purchaseState canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one; and a 410 from purchases.subscriptionsv2.get, a subscription that expired too long ago to read, on a first purchase (a renewal check answers it as ended).
        • test_purchase: refused by the service after the verifier vouched for it, purchaseType test, a license tester's purchase, and a subscription with testPurchase.
        • payment_pending: purchaseState pending, and SUBSCRIPTION_STATE_PENDING.
        • provider_unavailable: network failure or timeout, and every error response from purchases.products.get or purchases.subscriptionsv2.get except 401, 403 and a subscription check's 410 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400, or a 410 from purchases.products.get, calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away.
        • internal: Google refusing this service's credentials or permissions — a 401 or 403 from purchases.products.get or purchases.subscriptionsv2.get, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a purchaseState or subscriptionState this service does not know; a subscription that is not pending with more than one line item, or with none unless it has ended (SUBSCRIPTION_STATE_EXPIRED or SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED); a charge without its expiryTime, product or base plan, and a charge for a base plan this service sells under another product; and a purchaseType other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase.
        • product_unavailable and provider_not_configured as for the App Store, the latter without a [google] section.
        • Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed; a subscription is acknowledged with purchases.subscriptions.acknowledge before its first charge is credited. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — provider_unavailable when Play does not answer, internal when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers internal to every Play purchase the app has not already acknowledged itself.
    • Funding by receipt is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as debit(transferOut) on the source and credit(transferIn) on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them.
  • upgradeBadgeSubscription → badgeCredential — an App Store subscription raised to a higher tier, on the same key. The client sends it when the device sees a transaction of the purchase's own subscription for a higher tier, bought after the purchase's last charge credit in its ledger and not expired (see Upgrades); it carries payment, that transaction, and balance, and makes the service ask the store now rather than at the next renewal check. It is refused before the store is asked when the transaction does not verify (receipt_invalid), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (bad_request), and for an unknown key (unknown_purchase_key). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is payment_pending with a retryAfter to the next allowed check, never the old tier's credential. The client retries it a bounded number of times for each hand-over of the transaction, then sets it aside until the device hands it over again. The tier granted is the store's; the transaction only names the subscription and the tier to wait for, so a lower tier sent here is granted only once the store charges it, at its renewal.
  • transferBadge → badgeCredential — Restore on another device or profile: it carries masterKey and payment, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (bad_request). The store is asked first, at most once an hour per subscription as for purchaseBadge but in an hour of its own, so a Restore right after the purchase is not held off by the purchase's check. An App Store transaction other than the subscription's latest is answered payment_pending, so an older receipt stops moving it once the store charges again. The store's current charge must be one a first purchase could be credited, and is answered as for a first purchase when it is not. The old purchase is lapsed and debited transferOut {toPurchaseKey} of its remaining months, even none, and is answered subscription = ended with no credential from then on; the new purchase is credited that charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (transfer_limited): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by purchaseBadge. A one-time purchase is not moved (receipt_used): its months were issued at purchase.
  • issueBadge → badgeCredential — issues the next period from the balance, the only source of issuance. It carries balance alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the sundayAfter of the period issued. The ledger is advanced first; the credential is signed before the debit(badge) and issuance rows are written, in one transaction. An exhausted balance yields no credential; the statement shows why. Issuing on a paused badge resumes it (model 2.13).
    • Subscriptions. The service asks the store whenever a month is due, at most once an hour per subscription whether or not the store answers, and answers requests in between from what it last read; a request with no month due is answered as above, without asking. While the paid coverage runs, as an annual plan's does, a store that reports the subscription ended, or the charge it was granted for revoked, ends it: the months not yet issued are debited as refund, at the time of the request after a lapse of those that ran out, and the answer is subscription = ended with no credential — also when the service already read it ended within the hour. Any other answer issues the month due as above, a store that fails or does not answer included, and a new charge it reports is recorded and granted only once the coverage ends. Once coverage has ended, a charge it can grant, by the rule purchaseBadge applies to a first charge but never twice, is granted at the store's period start, or at the ledger's last row when that is later, so rows are never dated before the row they follow, after a lapse of what ran out before it, and the month due is issued. A charge of another badge type is granted as that type: what remains of the old run is debited as upgrade naming the purchase's own key, the new run starts at that date, and the purchase's badge type follows it. Otherwise no ledger row is written: the charge, if any, and the state are recorded, and the answer carries no new credential and the subscription the store reported. A store that does not answer is answered provider_unavailable with a retryAfter of the hour. Every badgeCredential answer for a subscription purchase carries subscription. A development deployment may ask its store at every request, during coverage too, and bank the months of a charge it can grant, issued once coverage reaches them, and ends it as above; a real store is asked at every request only by a development deployment that also credits test purchases.
  • pauseBadge (post-MVP) → badgeCredential — suspends issuance and lapse (model 2.13).

Upgrades

Always a new purchase under a new key, except App Store subscriptions, where the store owns the change and the purchase keeps its key.

  • Non-store (not built): getBadgeInvoice.upgrade — fromPurchaseKey, the old purchase's receipt, receiptSignature binding the old key to the new, and the asserted old balance. The invoice returns the conversion credit; settlement records debit(upgrade) on the old purchase and the credit on the new.
  • Store one-time (not built: purchaseBadge with upgrade is answered bad_request): an upgrade SKU at a fixed discounted price; purchaseBadge.upgrade — fromPurchaseKey, receipt, receiptSignature — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU.
  • App Store subscription: the client never starts a change; the user makes it in Apple's manage-subscriptions sheet, and it keeps the original transaction id, as all badge subscriptions share one subscription group. A higher tier, which Apple charges at once with a prorated refund, is sent as upgradeBadgeSubscription when the device sees its transaction bought after the purchase's last charge credit and not yet expired, however late the device sees it, and is granted as soon as the store reports the charge; each hand-over is retried a bounded number of times, and the next one holds it again. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier whose transaction has expired or is no newer than the last charge credit — the client sends nothing: the service reads the change from the store and grants the badge type the store charged at its next check after coverage ends.
  • Play subscription: the client never starts a plan change. Each badge is its own Play product, with a monthly and an annual base plan, and when tested (2026-10-22) Play's own pages offered neither another badge nor another billing period, so another badge is bought as a new purchase once the current one ends. A plan change made in Play's own pages, should one become possible, gets a new purchase token, so it is a new purchase under a fresh key, credited as a first purchase is, and the old token is granted nothing more. No client hands that token over unprompted: the app's sweep finds it in Play's list, but core records a listed token only when it already holds a record of it, so only Restore hands it over, and Restore is refused while the profile shows a badge. Until then it stays unacknowledged, which Play refunds after three days (see Google Play above).

Catalog

prices — priceId, badgeType, monthPrice, currency, status, createdAt; offers — offerId, priceId? (absent applies to any price), months, discount, status, createdAt. An offer states a discount, as free months or a percentage; a duration without one is priced at months × monthPrice. Repricing appends a price and deprecates the old, which is still accepted at invoice creation; deprecated prices and offers are sent so that a refresh cannot remove what the client pinned, and disabled ones are omitted. Rendering is app-driven — tiers and durations come from app resources, and one without a price is shown disabled.

Statement and balance

The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from balanceAnchorTs, the start of the current run of months, and not from balanceStartTs — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a run restarts: one that has lapsed, or a grant of another badge type, which starts a run of its own; months of the same type granted while coverage still runs extend it on its existing anchor.

statement — entries, and previousEntryId when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states entryId, the signed changeMonths, the resulting balanceMonths, balanceStartTs, balanceAnchorTs, and balanceBadgeType, wasPausedSince on the entry ending a pause, createdAt, and entryType — credit: payment {invoiceId?}, code, charge {chargeId}, support, transferIn {fromPurchaseKey}, opening; debit: refund, upgrade {toPurchaseKey}, transferOut {toPurchaseKey}, support, badge, lapse. A code grant is code rather than payment with no invoiceId: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. A charge credit is dated to the store's billing period start, its createdAt, so coverage follows the store's periods, or to the previous entry's createdAt when that is later, so dates never decrease; the entry is its grant re-run at that time. An unknown type is stored as received and decoded after an app upgrade.

subscription — present exactly when a store subscription funds the purchase, as the service last read the store, or funded it before transferBadge moved it away, then always ended: renewing; cancelled, auto-renewal is off and the paid period still runs; billingRetry, a charge failed and the store is retrying it; ended, no charge will come. A client reads any other value as not ended.

balance — lastEntry, the client's last entry, asserting the position and the months it believes it holds.

An assertion that names an entry the service holds is a prefix: the service proceeds and returns what follows it. Otherwise the service heals its own ledger first — provider evidence for charges, time for lapses — proceeds, and returns either the complete history or one opening credit. An opening entry is an absolute restatement: the ledger is reset to the amount it states, without relation to the preceding entry, which also serves a new device and, later, the discarding of old history into a brought-forward balance.

Errors

retryAfter marks the transient codes: payment_pending, provider_unavailable, rate_limited. offer_disabled calls for a catalog refresh. code_invalid covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; code_used — all its uses taken by other keys; code_expired — past its redemption deadline. receipt_invalid covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; test_purchase is named apart from them because the device that bought it already knows; receipt_used — credited to another key; transfer_limited — the subscription moved from a key whose credential is still valid, with no retryAfter; provider_not_configured — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.