# SimpleX badge service `simplex-badge-service` runs the whole supporter-badge service as one process, two lanes: a SimpleX chat bot that answers service RPC over a double-ratchet contact address, and — only when `--service-config` names a `badge_service.ini` — the badge-codes web checkout (BTCPay and Stripe payment, code issuance, the poller). **Without `--service-config` the web listener does not start at all**; the process still runs the chat side and nothing else. The wire protocol for the RPC side is [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md). The web checkout is specified in [`plans/badges-codes/2026-08-27-badge-codes.md`](../../plans/badges-codes/2026-08-27-badge-codes.md), whose §9 is the `badge_service.ini` reference, with the implementation plan in [`plans/badges-codes/2026-08-31-service-btcpay.md`](../../plans/badges-codes/2026-08-31-service-btcpay.md). Earlier plans (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, `2026-08-04-badges-mvp-scope.md`) predate the web checkout and describe the RPC-only scaffold this service started as. At this stage the service: - creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)), - listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`, - issues redemption codes, storing only their `SHA-256` and printing each code once, - does not accept contact requests unless `[dev] chat_redeem` is on: the address is for RPC only, - in service mode with `--service-config`, also serves the built web app (`npm run build` in `web/`), `POST /api/invoice` and `GET /api/invoice/:id`, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start, - owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`). Every other command answers `unsupported_version`, or `unknown_purchase_key` when the key that signed it is not one the service has stored — every command but `redeemBadgeCode` needs a purchase that already exists. ## Build Build prerequisites and the general contribution flow are in [`docs/CONTRIBUTING.md`](../../docs/CONTRIBUTING.md). ``` cabal build exe:simplex-badge-service ``` ## Run ``` simplex-badge-service --help ``` - default (no `--run-cli`): background service mode, no interactive terminal. - `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands below: it starts no web listener and no poller, and `[dev] chat_redeem` does not apply to it, whatever `--service-config` says. - `--no-address`: skip address creation on start-up (for operators who provision the address themselves). The service cannot sign credentials without an issuer key and refuses to start without one: - `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`). - `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`. The service checks the secret against the configured public key at that index and refuses to start if they disagree: credentials signed with the wrong key cannot be verified by any client, and the codes redeemed against them would be spent for nothing. The key can come from `badge_service.ini` instead: ```ini [issuer] index = 1 private_key = ``` `index` is the index clients verify against (`badgePublicKeys` in `ChatConfig`), and `private_key` is the secret that signs. Both are required, and the key is checked at startup like the flags. Rotating is a change to both and a restart, once clients trust the new index. The command line wins over the file when both `--issuer-key-idx` and `--issuer-secret` are given. Note that a secret passed as a flag is visible to every user on the machine through `ps`, where one in the ini is only as readable as the file: `badge_service.ini` is gitignored and already holds the provider API keys and webhook secrets. Other options: - `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but uses only its `[issuer]` section. An issuer key is still required either way. - `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). - `--client-service`: use the client service certificate. - also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers, `--socks-proxy`, `--log-level`/`-l`, and the rest — run `simplex-badge-service --help` for the complete list. ### Running the web checkout `badge_service.ini` holds the listener bind address and `static_dir`, an optional `[btcpay]` section (omitting it disables Bitcoin and Monero), an optional `[stripe]` section (omitting it disables card payments) and the poll cadence. `badge_service.ini.example` is the committed template; `badge_service.ini` itself is gitignored, since a real one holds API keys and webhook secrets. The full walkthrough is in [`web/README.md`](web/README.md#running-the-real-service-against-this-build). Short version: ``` cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../.. cp apps/simplex-badge-service/badge_service.ini.example apps/simplex-badge-service/badge_service.ini cabal run simplex-badge-service -- \ --issuer-key-idx IDX --issuer-secret SECRET \ --service-config apps/simplex-badge-service/badge_service.ini ``` `IDX` and `SECRET` must be one of the issuer keys clients already ship, not a fresh `simplex-chat badge keygen` pair: startup refuses a key no client could verify against. `trust_forwarded_for` decides what the rate limiter counts. Behind a reverse proxy it must be `on`, or every request keys on the proxy's address and the whole service shares one bucket of 60 reads and 5 checkouts a minute. Where the listener is reached directly it must be `off`, because then the header is whatever the caller wrote. The last entry is the one read, which is the one a proxy appends. The BTCPay API key needs four permissions, each scoped to the one store: `cancreateinvoice` and `canviewinvoices` for checkout and the poller, `canviewstoresettings` to log the store's live payment methods at startup, and `canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay rather than only in this store. Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are on. The poller lists every invoice and payment intent of the last three days or so, and one it did not create and cannot read is reported in a warning once an hour until it falls out of that window. ### Card payments (Stripe) An optional `[stripe]` section enables the card lane; omitting it disables card payments and `POST /api/invoice` answers `provider_unavailable` for a card order, the same as an absent `[btcpay]` does for Bitcoin and Monero. Its keys: - `secret_key` — a **restricted** key (`rk_...`), never a full secret key (`sk_...`). Grant it **PaymentIntents: write**, to create and cancel intents, plus **Charges: read** — the poller reads an intent with `expand[]=latest_charge`, and Stripe rejects the whole read if the key lacks read on an expanded object (settlement then falls back to the slower list pass). - `publishable_key` (`pk_...`) — the public key the browser mounts the Payment Element with. - `webhook_secret` (`whsec_...`) — the signing secret of the `/webhooks/stripe` endpoint, configured in the Stripe Dashboard alongside it. - `session_minutes` — how many minutes an unpaid card order stays open (1 to 1440, default 60). Ten minutes after that, the service cancels the payment at Stripe, and retries if that fails. If the order is over 72 hours old and the cancel has kept failing for an hour, the service marks it expired and logs an error. Check such orders in the Stripe Dashboard. After a restart, the hour starts again. The service fills `publishable_key` into the shell at boot: the built `index.html` ships with an empty `` (`web/public/index.html`), and the listener serves a copy with the configured key substituted in, so the ini is the only place the key is set. With no `[stripe]` section the pristine (empty) shell is served and the page shows its development stand-in card form. The dev mock (`web/mock/server.py`) does the same substitution from `$STRIPE_PUBLISHABLE_KEY`, since it runs without an ini. `POST /webhooks/stripe` verifies `Stripe-Signature` against `webhook_secret` and queues a read, the same hint-only role as `POST /webhooks/btcpay`: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. A delivery signed more than 15 minutes away from the server's clock is refused with a warning, so keep the clock synced (NTP). The reverse proxy in front of this service must send a Content-Security-Policy that allows Stripe.js and its iframes, since Stripe forbids bundling or self-hosting its script: ``` script-src 'self' https://js.stripe.com https://*.js.stripe.com; frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com ``` **Stripe Link must be disabled in the Dashboard.** Left on, it needs `link.com` in the CSP above and reintroduces an email prompt of its own, which this design otherwise avoids. **Enable card only; do not enable redirect-based methods** (iDEAL, Bancontact, PayPal, and the rest) in the Dashboard. The embedded checkout sends no `return_url`, which Stripe requires the moment a redirect-based method is offered, so enabling one makes every checkout fail to create. The service pins the Stripe API version it was built against, so the account's default version does not affect the card lane. ### The checkout endpoints The browser in `web/` is the only client of the `/api` routes; the two webhooks below are the providers', one each for BTCPay and Stripe. The invoice id is the only credential for reading or cancelling an order: it is 16 random bytes, it travels in the path, and anything that logs request paths logs it. It is also what the buyer is shown as their reference and asked to quote, so support sees it: losing it lets someone cancel an invoice, which is why the code, which lets them take the badge, is never printed until the service says the invoice is paid. The service keeps it out of its own logs, which name the provider's reference instead. | Route | Answers | |---|---| | `POST /api/invoice` | `{invoiceId, badgeType, months, amount, currency, expiresAt}` plus a destination: `clientSecret` for a card, or `address`, `cryptoAmount`, `cryptoCurrency`. Refuses with `code_conflict`, `catalog_changed`, `bad_request`, `provider_unavailable` or `rate_limited`. | | `GET /api/invoice/:id` | `{status, badgeType, months, amount, currency, expiresAt}` and the same destination — no `invoiceId`, since the caller already has it — plus `amountPaid`, `cryptoAmountPaid`, `cryptoAmountDue`, `paidInFull`, `settledAt` and `requiredConfirmations` once each has a value. Never the code, which this service has never seen. | | `GET /api/invoice/:id?wait=&seenPaid=
&seenFull=<0\|1>` | The same, held for up to 30 seconds while the invoice's status is still `` **and** its payment is the one the caller says it has rendered. `wait=paid` and any value that is not a status answer at once, since neither can change. A status the caller has not seen, or a payment it has not seen, answers at once — the provider's verdict counts as much as the figure, because Monero reports an invoice as confirming while its figures are still zero. A request that omits `seenPaid` holds on the status alone. | | `POST /api/invoice/:id/cancel` | Invalidates an open invoice at the provider and expires it here. Refuses a settled or expired one with `not_open`, and one that already holds a payment with `funded`. The provider is told first, and a payment landing in between does not keep the invoice open: nothing can reach that address any more, so the row is expired either way and the poller settles or reports what arrived. | | `POST /webhooks/btcpay` | Verifies `BTCPay-Sig` over the bytes as received and queues a read. A hint only: the poller is what carries authority, so an unverified or unreadable delivery costs nothing but a log line. | | `POST /webhooks/stripe` | Verifies `Stripe-Signature` over the bytes as received and queues a read. The card lane's equivalent of the row above, and a hint just the same: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. | Every `/api` refusal is `{"error": ""}`. Besides the codes above, any of them can answer `internal`, an unknown id answers `not_found`, and a wrong verb answers `method_not_allowed` — ten codes in all, which is what the browser's `WIRE_ERROR_CODES` lists. The two webhook routes are the exception: each answers 200, 400 or 413 with an empty body, because its provider is the only caller and nothing it could read would change what the route does. A wrong verb on any route, those two included, answers `method_not_allowed`. ### Redeeming over chat, for local testing ```ini [dev] chat_redeem = on ``` With this on, the service accepts contact requests and answers `/redeem ` from a contact with the credential as one-line JSON, ready to paste into a client as `/badge add `. Off by default, and only `on`/`off` parse, so a typo cannot silently arm it. It applies to the service mode only; `--run-cli` ignores it. Keep it off anywhere real. The service RPC signs over a master key only the client holds; here there is no client key, so the service generates one and hands it over with the credential, which means it can link every badge it issues this way. `simplex-chat badge sign` has the same property and is the offline equivalent. ## Issuing codes Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way to start it — so codes are issued without a second process touching the service's database: ``` //issue [months] [paid|unpaid|free] //issue supporter 12 ``` `months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator (`free`). Redemption refuses an `unpaid` code with `payment_pending`: the web checkout writes the code row when the invoice is created, and settlement is what marks it paid. The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is shown cannot be recovered. A code that leaked, or that was refunded, is withdrawn the same way: ``` //revoke ``` A revoked code answers redemption with `code_invalid`, as if it had never existed, so its holder learns nothing from trying. Revoking is not repeatable: the second attempt says so. A code that was already redeemed cannot be revoked: its badge was issued, and the command answers with an error. Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is why issuing codes lives in the service rather than in core.