make check, exits 0. Nothing is deployed and no environment holds merchant money.NineRivers collects a payment through a channel, converts it to dollars and settles to the merchant.
A payer pays in the currency of their own country, through a third-party channel: a bank QR code, a wallet, a redirect. The channel remits the money to NineRivers. NineRivers converts the amount to US dollars at an aggregated market rate plus a spread, holds it until the channel's own statement confirms that the money arrived, releases it three days after that by default, and settles it to the merchant over USDC or SWIFT.
It is one Go binary over one PostgreSQL database, started once per role: api, admin, callback and worker. Every process is stateless, so any of them can be run N times behind a balancer. Redis holds only what can be lost and rebuilt: a rate limit, a rate cache, a success window. Everything that decides money is in the database, as a transaction, a constraint or a trigger.
A merchant reaches it through a REST API of the Stripe shape, a hosted checkout page and a dashboard. An operator reaches it through an Admin API and its console. There is one production channel adapter today, for the Philippines, and both payout rails are worked by hand; § 14 lists that and everything else that is not yet built.
NineRivers is Jiuchuan, the nine watercourses of the Yu Gong chapter of the Book of Documents, which Yu the Great cleared so that the water reached the sea. It is a name about channelling a flow to where it must arrive.
The private banks of the Ming and Qing, the qianzhuang, and the piaohao of Shanxi, did one thing above all: huidui, remittance between distant cities. Money paid in at one house was paid out at another, and the two houses settled between themselves later. Ri Sheng Chang, in Pingyao, from about 1823, is the canonical first, and its motto was "remittance that reaches everywhere."
That is the same business as this one. A payer hands local currency to a channel in one place. The merchant is paid elsewhere, in another currency. A network settles in between. An aggregated payment gateway is a correspondent network with better clocks. We were not founded in 1823; that house was. What we take from it is the form of the book, and two ways of checking that the book is right. Both are set out below, in § 6 and § 7, beside the code that does the checking.
Everything on this page is true of the code as it stands. Where the old form and the code differ, the code is described and the difference is said.
A payments company is judged on its API, so here is the call that starts everything, and what it answers. Every call carries a bearer key. Every POST carries an Idempotency-Key that the merchant chooses. Api-Version is optional; if it is sent, the only value this build accepts is 2026-09-01, and any other is refused with 400 invalid_api_version rather than served today's shape under another name.
POST /v1/payment_intents HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE
Idempotency-Key: 5EKP4SXV75NVPQ67TQWJCKI6TQ
Api-Version: 2026-09-01
Content-Type: application/json
{
"amount": 100000,
"currency": "THB",
"merchant_order_id": "ORD-1001",
"payment_method": "promptpay",
"return_url": "https://shop.example/orders/1001",
"expires_in": 1800,
"metadata": { "cart": "c_8821" }
}
api/merchant.openapi.yaml, PaymentIntentCreate. The amounts throughout this document are the repository's own worked example: 1,000.00 THB, a 2.5 percent merchant fee, a 1.2 percent channel cost.
This is the body a test key gets back, through the test channel, with its fields in the order the wire carries them.
HTTP/1.1 201 Created
Content-Type: application/json
{
"amount": 100000,
"checkout_url": "https://pay.example.com/c/Y5FOKCTO66SXWC3IEQU7JPVDQY",
"created": "2026-09-05T08:14:02Z",
"currency": "THB",
"expires_at": "2026-09-05T08:44:02Z",
"fee": { "amount": 2500, "currency": "THB" },
"fx": { "locked": false },
"id": "pi_AGQHAVEX7J327MRWOPILG46G6Y",
"merchant_order_id": "ORD-1001",
"metadata": { "cart": "c_8821" },
"mode": "test",
"next_action": { "qr_data": "mock://pay/mck_IP2J2X5GUJCW7F3FWGPPWW5K74", "type": "qr_code" },
"object": "payment_intent",
"payment_method": "promptpay",
"status": "requires_payment"
}
api/merchant.openapi.yaml, PaymentIntent; internal/api/dto.go; internal/channel/mock.
crypto/rand and never encodes a key.locked: false until the merchant's lock point. After it: lock_point, rate as a decimal string, quote_currency and locked_at. A rate is never a number on the wire.{amount, currency} in USD. Present once the rate is locked.redirect with a url, or qr_code with qr_data. What the payer does next. This one is the test channel's, because the key was sk_test_; a live key returns the channel's own credential in the same shape.Z. One time format, formatted once, at the edge. expires_at is absent when no expires_in was sent.live or test, from the key. Test money never reaches a live balance: the mode is part of every ledger account's unique key.The same key with the same body answers 201 again, with the stored response. The same key with a different body is a mistake, and it answers 422:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_reused",
"message": "This Idempotency-Key was already used with a different request body.",
"param": "Idempotency-Key",
"request_id": "req_ZQ5MD7DUT4E2WZAOOTBOHNCJIQ"
}
}
A new key with the same merchant_order_id is the other mistake, and it answers 409:
HTTP/1.1 409 Conflict
{
"error": {
"type": "invalid_request_error",
"code": "merchant_order_id_taken",
"message": "This merchant_order_id already belongs to another payment intent.",
"param": "merchant_order_id",
"request_id": "req_HB3KX7YQ2ZM6NP4WEA5TVJ2C6S"
}
}
internal/apierr/apierr.go. The messages are the ones the service sends.
Every failure is the same envelope, { "error": { type, code, message, param, request_id } }. type is one of five: invalid_request_error, authentication_error, idempotency_error, rate_limit_error, api_error. code is a stable string a program can branch on. param names the field or header when there is one. request_id is the thing to quote. A constraint name or a driver message never reaches a client; an error nothing recognises is a 500 that says only that something went wrong on our side.
| Status | What it means, and the codes you will see |
|---|---|
| 400 | A bad parameter. amount_below_minimum, invalid_currency, merchant_order_id_required, payment_method_required, idempotency_key_required, invalid_json, request_too_large, invalid_api_version, invalid_identifier, rate_unavailable. |
| 401 | The key is missing, unknown or revoked: invalid_api_key. A signed request with a bad signature: invalid_signature. |
| 402 | No channel serves this amount, method and currency: no_channel_available. |
| 403 | The merchant's IP allow-list did not include the caller: source_not_allowed. A settlement asked for by a merchant paid on a schedule: settlement_schedule_not_manual. |
| 404 | resource_missing. |
| 409 | The idempotency key is still in flight, request_in_progress; the order id is taken, merchant_order_id_taken; the payment is not in a state that allows the move, invalid_state_transition; a manual settlement was asked for with nothing waiting, nothing_to_settle. |
| 410 | The hosted checkout session expired: checkout_session_expired. Nothing was charged. |
| 422 | The idempotency key was reused with a different body: idempotency_key_reused. |
| 429 | rate_limit_exceeded. 100 requests a second per key, with 200 in reserve. |
| 5xx | api_error, with nothing else in the message. A 5xx is never stored against the key, so a retry with the same key is a fresh attempt and never a second charge. |
| Method | Path | What it does |
|---|---|---|
| POST | /v1/ | Create. 201, or the replay of an earlier identical request. |
| GET | /v1/ | A page, newest first. Filters: status, merchant_order_id, created[gte]. Cursor pagination with limit and starting_after. |
| GET | /v1/ | One intent. |
| POST | /v1/ | Test mode only. Tell the channel what to report, then deliver its callback on the real path, signature check included. |
| GET | /v1/ | The indicative effective rate and an estimate. indicative: true, always. It locks nothing. |
| POST | /v1/ | Settle the available balance now. Only a merchant on the manual schedule may ask. |
| GET | /v1/ | The merchant's balance: one {amount, currency} per currency in each of available, pending and dispute_hold. |
| GET | /v1/ | Disputes, newest first, and one dispute. |
| POST | /v1/ | Merge evidence into an open dispute, key by key. |
| GET | /v1/ | The events behind the webhook deliveries, newest first. |
| GET | /v1/ | The platform Ed25519 public keys. |
| GET | /c/ | The hosted checkout page. No key; the token is the whole authority. |
| POST, GET | /checkout/ | What the page calls. |
| POST | /callbacks/ | Where a channel delivers. Served by the callback role, never by api. |
Three paths are in the specification and not served: POST /v1/payment_intents/{id}/cancel and the two /attempts routes. A test holds that list against the router, so it cannot drift. See § 14.
A payment intent has exactly eight statuses: created, requires_payment, processing, succeeded, channel_settled, failed, expired, canceled. The moves between them are rows in a table, and a BEFORE UPDATE trigger refuses any pair that is not a row. The table is what makes the rule hold for a repair script and for an operator's manual mark, which never pass through Go.
| From | May move to |
|---|---|
| created | requires_payment, processing, failed, expired, canceled |
| requires_payment | processing, succeeded, failed, expired, canceled |
| processing | succeeded, failed, expired, canceled |
| succeeded | channel_settled |
| channel_settled, failed, expired, canceled | Nothing. These are terminal. |
migrations/00030_payment_transitions.sql, payment_intent_transitions_allowed.
An intent made with a payment_method is routed to a channel account at once and answers requires_payment with a credential. One made without a method stops at created and carries a checkout session, and the payer picks the method on the page. Routing is by data: a channel is a row, its accounts are rows, and the score weights and the circuit-breaker thresholds are columns. Adding a channel that speaks a protocol the binary already knows is an INSERT.
succeeded is never taken from a callback. The callback process stores the bytes and answers 200; the worker then calls the channel's own query, and the intent succeeds only when the query agrees with the callback on amount, currency and status. The database holds that rule too: a succeeded row must carry a confirmation_source, which is query, recon or manual. A channel with no query capability is not routed to unless the merchant opts in, and for those merchants an intent waits in processing for the statement instead of being expired.
A success cannot become a failure. Money that has to go back goes back through a dispute, § 11.
Money is amount_minor bigint beside currency text with a CHECK that it matches ^[A-Z]{3}$, in every table. A decimal carries no currency, so adding 100 USD to 100 JPY compiles, and the loss appears later in a ledger that no longer balances. A test reads the schema and fails if a float column or an amount without a currency beside it ever appears.
An exchange rate is numeric(24,12) in PostgreSQL. The conversion runs in SQL, in exact arithmetic. Go holds a rate only as a string, for display and for the wire. There is no decimal library and there is no float64, anywhere.
A worker fetches quotes from each enabled source on that source's own interval, under an advisory lock so one replica does it. Every thirty seconds it aggregates each pair: quotes older than the pair's staleness window are ignored; a quote further from the unweighted median than the pair's outlier threshold is dropped; the survivors are averaged by source weight. Fewer than the pair's minimum number of sources writes no snapshot, and the previous snapshot stands. A snapshot has an id, a source count, the sources it used and the method, and it is never updated. A payment locks a snapshot id, not a bare number.
The effective rate is the mid rate plus a spread in basis points, configured per payment method and currency, with a merchant override and a lock-point dimension. The merchant sees the effective rate only. The mid rate, the spread and the spread revenue are Admin and ledger surfaces.
A merchant setting, one of three. on_create fixes it when the intent is made, so the merchant knows the dollars before the payer has paid. on_paid, the default, fixes it when the payment succeeds. on_channel_settled fixes it when the channel statement confirms the money. A lock is written once and cannot be rewritten. An intent that expires or fails with an on_create lock posts nothing.
| Item | Value | How |
|---|---|---|
| Payment | 100000 THB | 1,000.00 THB, minor units |
| Merchant fee | 2500 THB | 250 bps, rounded down, from merchant_fee_configs |
| Channel cost | 1200 THB | 120 bps, rounded down, from channel_fee_configs |
| Net to convert | 97500 THB | payment less the merchant fee |
| Mid rate | 35.000000000000 | the snapshot: 1 USD = 35.00 THB |
| Spread | 80 bps | fx_spread_configs |
| Effective rate | 35.280000000000 | mid × (1 + 80 / 10000), stored as numeric(24,12) |
| USD at mid | 2785 USD | floor(97500 / 35.00) |
| USD to the merchant | 2763 USD | floor(97500 / 35.28) |
| Spread revenue | 22 USD | 2785 − 2763 |
internal/fx/convert.go; docs/DECISIONS.md P3 and P4; docs/ROADMAP.md M6 A3. Measured on the live database.
Both dollar amounts are floored against their own rate and the revenue is their difference, so the identity usd_at_mid = usd_to_merchant + spread_revenue holds on integers and the database keeps it as a CHECK. A second CHECK keeps the spread revenue at or above zero. The remainder of every conversion has an account; the platform never creates money and never loses a fraction of a cent to rounding.
The ledger is double entry. Every account belongs to a merchant, the platform or a channel, has one currency, one mode and one purpose: pending, available, dispute_hold, settling, receivable, cash, fee_revenue, fx_revenue, channel_cost, fx_position. Balances are signed debit-positive on every account, so the sign rule is stated once and the whole book sums to zero. One package writes the ledger tables, and one function is the only way in. It takes the caller's transaction and never opens its own, so a journal and the state change it records commit together or not at all.
A journal is one business event. Its entries each carry a positive amount and a direction; a negative amount is refused by a CHECK. There is no running balance stored beside an entry, because a stored value that is derivable is a value that is wrong under a serialisation the reader cannot see; a statement computes it at read time. The ledger tables cannot be updated, deleted or truncated: the application role holds INSERT alone, PUBLIC has the other three revoked, a row trigger refuses the table owner and the superuser, and a statement trigger refuses TRUNCATE, which fires no row trigger at all. All four measures are live and were measured as both roles.
| Account | Debit | Credit | |
|---|---|---|---|
| channel.receivable | 100000 | THB | |
| merchant.pending | 97500 | THB | |
| platform.fee_revenue | 2500 | THB | |
| platform.channel_cost | 1200 | THB | |
| channel.receivable | 1200 | THB | |
| 101200 | 101200 | THB |
| Account | Debit | Credit | |
|---|---|---|---|
| merchant.pending | 97500 | THB | |
| platform.fx_position | 97500 | THB | |
| 97500 | 97500 | THB | |
| and in the settlement currency | |||
| platform.fx_position | 2785 | USD | |
| merchant.pending | 2763 | USD | |
| platform.fx_revenue | 22 | USD | |
| 2785 | 2785 | USD | |
| Journal | When | Debit | Credit | Amount |
|---|---|---|---|---|
| J3 | the statement matches | platform.cash | channel.receivable | 98800 THB |
| J4 | the T+3 clock passes | merchant.pending | merchant.available | 2763 USD |
| J5 | the period closes | merchant.available | merchant.settling | 2763 USD |
| payout | the payout is confirmed | merchant.settling | platform.cash | 2763 USD |
docs/ROADMAP.md M3 A10; docs/DECISIONS.md P2, P15, N30; internal/recon/import.go; internal/recon/release.go; internal/settlement.
J3 moves what the channel actually remitted, the statement amount less the fee it kept, which is exactly what J1 left standing in the receivable. J5 stops at merchant.settling and not at cash, so money in flight is visible on the book: the settling balance equals the sum of settlements still pending or processing, and an hourly job checks that it does. A failed payout is never rolled back by a machine; a person chooses retry or reverse, and either one is a new journal that leaves the old entries untouched.
Longmen accounting, traditionally attributed to Fu Shan in the early Qing, is a Chinese double-entry method. Its four categories are income, expenditure, assets and liabilities. The books are right when the two sides agree, and their agreeing was called closing the dragon gate.
Σ debit = Σ credit
per journal, per currency, at COMMIT
Σ balance = 0
per currency, whole book, every hour
Our book has the same test, three hundred years later and in SQL, and the database does the checking. A deferred constraint trigger fires at COMMIT, sums the debits and the credits of the journal in each currency, and refuses the whole transaction when they differ. It coalesces a missing side to zero, because NULL <> NULL is NULL and a one-sided journal would otherwise pass. The hourly job then reads the whole book: with balances signed debit-positive, every currency sums to zero across all accounts, and a half-written journal that the first check cannot see shows up as a currency that does not.
SELECT coalesce(sum(amount_minor) FILTER (WHERE direction = 'debit'), 0),
coalesce(sum(amount_minor) FILTER (WHERE direction = 'credit'), 0)
INTO debit, credit
FROM ledger_entries
WHERE journal_id = NEW.journal_id
AND currency = NEW.currency;
IF debit <> credit THEN
RAISE EXCEPTION 'journal % unbalanced in %', NEW.journal_id, NEW.currency
USING ERRCODE = 'integrity_constraint_violation',
CONSTRAINT = 'ledger_entries_balanced';
END IF;
migrations/00016_ledger_balance_trigger.sql. Measured: a journal of 1000 against 999 fails at COMMIT with journal <id> unbalanced in THB.
The four categories are not the ten purposes of our accounts. The identity is the same; the names are not, and they are not made to look the same here. The other difference is where the gate closes. The old closing was done when the books were ruled off. Ours closes at every commit, and again every hour, and a journal that cannot close is refused before it exists.
Every day, for every channel account, the channel's own settlement statement is imported: uploaded as a CSV by finance, or pulled from the channel when it has an API. One batch reconciles one account, one day and one currency, and it keeps the uploaded bytes exactly as they arrived, so a disputed line can always be read against what the channel actually sent. The parser reads five columns, and money arrives in minor units beside its currency, never as a decimal:
channel_txn_id,amount_minor,currency,fee_minor,settled_at
internal/recon/match.go, Columns.
Each line is matched to the platform's record by the channel transaction id and classified as one of five results. Only the first moves money.
| Result | Meaning | What happens |
|---|---|---|
| matched | Amount, currency and fee agree. | The intent moves to channel_settled, J3 posts, the T+3 clock starts. J2 posts in the same transaction for a merchant that locks at on_channel_settled. |
| platform_only | We hold a success the statement omits. | Nothing posts. The open item is the queue a person works. |
| channel_only | The statement holds a transaction we do not. | Nothing posts. An exception is filed. |
| amount_mismatch | Amount or currency differs. | Nothing posts. An exception is filed with both sides. |
| fee_mismatch | Only the fee differs. | Nothing posts. The open item is the queue. |
A statement can be imported again; every journal claim replays and nothing posts twice. A statement the channel later restates is reversed by a second person, and every intent it touched is held out of release. An adjustment a resolution implies is a separate, gated journal with the resolved item as its reference.
The four-pillar method is older than the dragon gate. Its four pillars are opening, what was carried in; receipts, what came in; disbursements, what went out; and closing, what is actually there. A period is closed when the first two equal the last two.
receivable carried + J1 − J3
open succeeded, unsettled, net of cost
receivable − open = 0
on a reconciled book
That is what a reconciliation batch computes for a channel account, and it computes the two sides independently. The ledger side is the balance of channel.receivable: what was carried in, plus every payment J1 added that day, less every remittance J3 cleared. The record side is recomputed from the payment tables: every succeeded attempt the channel has not yet settled, net of the cost J1 already booked. The batch answers the upload with the count per result, the statement total, the settled total, both balances and their difference. That difference is zero on a reconciled book, and it is the number that alerts.
internal/recon/import.go, Summary and balanceQuery. Nothing pages on the difference yet; that is a carried item, § 14.
The code does not write the identity as a sum. It writes it as a difference between two balances read after the batch, which is the same test taken from the other end. And it keeps one thing the old method did not: the statement itself, so a disputed batch can be re-read.
Every hour, under an advisory lock so one replica of the fleet does the work, the release job takes every intent whose available_at has passed, locks the rows, then re-reads three holds under that lock: an open dispute, an unresolved reconciliation item, a reversed batch. Any one of them keeps the money in pending. The rest post J4, in USD. The journal key is the intent, so two workers that both wake up post it once.
A merchant is paid on a schedule: daily, weekly or manual. The sweep runs every hour and closes the period by the database clock in the merchant's own timezone, never by a replica's wall clock; a fleet that was down for a day closes the missed period on its next tick instead of skipping it. One settlement per merchant, per mode, per period end, claimed by one INSERT ... ON CONFLICT DO NOTHING, and its items, its J5 and its outbox row commit with it. An intent enters one settlement in its life, held by a unique constraint on the items. A merchant whose available balance is zero or negative is skipped, and one whose items sum to more than the balance is refused, so a dispute that landed mid-sweep is never paid out.
A merchant on the manual schedule asks with POST /v1/settlements. Any other merchant gets 403 settlement_schedule_not_manual; a merchant with nothing waiting gets 409 nothing_to_settle. A settlement is st_ and an id, with a status of pending, processing, paid or failed, an amount in USD, the number of payments it covers and its period.
There is no hot wallet, no custodian API, no chain key and no bank API in this system. That is a ruling, not an omission. A payout is an instruction written into the finance queue in the same transaction as the settlement, and a person sends the money. USDC and SWIFT write the same instruction; only the destination and the receipt differ.
{
"id": "st_C3Q7ZJH2WXK5PMB4NA6ETYD2VE",
"object": "payout_instruction",
"merchant": "mch_R6WZKQ2H7JX3TB5NAP4MDE2Y6C",
"mode": "live",
"amount": { "amount": 2763, "currency": "USD" },
"method": "swift",
"destination": { the bank details on the merchant's payout method },
"idempotency_key": "st_C3Q7ZJH2WXK5PMB4NA6ETYD2VE"
}
internal/settlement/payout.go, instruct. The key is the settlement id, so the same instruction acted on twice is one payment.
The rail decides when the payout is final. A USDC payout is final when the transaction hash has reached the configured confirmation depth. A SWIFT payout is final on the bank reference alone, because a wire has no depth to count. One person files the receipt and a second person signs it; a settlement cannot reach paid without two distinct approvers and a reference. Then the payout journal moves the money from merchant.settling to platform.cash, keyed on the settlement, so one settlement is paid once, forever. The instruction claim is a column written in the same statement that selects the work, so a worker killed between the instruction and the record does not instruct twice.
An idempotency claim is one statement, and PostgreSQL is the only judge of it. A SELECT followed by an INSERT lets two concurrent retries both read nothing, both proceed, and charge the payer twice. A cache in front of the claim has the same defect in another place: if it can answer "already completed", a flush turns a replay into a second charge. So there is no cache.
INSERT INTO idempotency_keys (merchant_id, key, request_hash, locked_at) VALUES ($1, $2, $3, now()) ON CONFLICT DO NOTHING RETURNING key
internal/idempotency/idempotency.go, claim.
The request hash covers the method, the path and the body. A retry that loses the claim reads the stored row: a different hash is 422 idempotency_key_reused; a matching hash with a stored response replays it; a matching hash with no response yet waits up to ten seconds for the first request to finish and then answers 409 request_in_progress. The first request commits its claim before it calls the channel, so the wait is one channel round trip and never a held row lock. Keys never expire: a key reused after a day replays the original response instead of creating a new payment, which fails closed.
The merchant order id is the second guard, independent of the first. It is claimed by one INSERT into a narrow, unpartitioned table, so a retry with a new key and an old order answers 409 merchant_order_id_taken rather than making a second payment. The ledger's own journal key is the third, § 6; the settlement key is the fourth, § 8. Fifty goroutines posting one journal key leave one journal, and the balance moved once. That is a test that runs against the real database, not a mock.
A channel delivers to /callbacks/{channel_code}/{channel_account_id}, which the callback role serves and the api role does not. That process verifies the signature, stores the raw body with the verdict beside it, and answers 200. It processes nothing and holds no master key material. A body whose signature fails is stored too, marked invalid and never processed, and the endpoint caps a body at 64 KiB because it has agreed to keep attacker-controlled bytes. A repeat of a body already stored is the same answer, which is what makes a channel's retry safe.
The worker then takes each stored callback, locks the intent, checks the move is allowed, and calls the channel's query. A callback that reports a success for an amount other than the order's leaves the intent short of succeeded and files an exception; so does a success that arrives for an intent already expired. A callback amount is never trusted, and the page that says so is the code that does it.
A state change writes an outbox row in the same transaction, and a relay publishes it after the commit. Publishing before the commit would announce work that rolled back; publishing outside the transaction would lose work. Delivery is at-least-once, so an event id is stable and globally unique and a merchant deduplicates on it. Each event is one signed POST per subscribed endpoint, retried on the 1m, 5m, 30m, 2h, 8h, 24h schedule, with one delivery row per endpoint and event however many times the relay publishes.
X-Webhook-Signature: t=1757059200,v1=<hex HMAC-SHA256>,v2=<base64 Ed25519>
internal/webhook/sign.go. Both halves sign the timestamp, a dot, and the exact bytes on the wire.
A merchant verifies either half. v1 needs the endpoint secret. v2 needs only a platform public key, published at GET /v1/webhook_public_keys, so a merchant can verify a delivery without holding any secret at all. A timestamp more than 300 seconds from the receiver's clock is a replay and is rejected however good the signature is. Neither the endpoint secret nor the platform private key is stored anywhere: each is derived from one master key with HKDF, one purpose string per use, and rotation publishes a new key id with no row to keep in step. The events themselves are readable at GET /v1/events, newest first.
There is no refund. A payment that succeeded stays succeeded, and money that has to go back goes back through a dispute, opened from a channel's notice or entered by an operator. A dispute is opened, under_review, and then won or lost. The same channel dispute opened twice is one row.
Opening it freezes the merchant's dollars: J6 moves them from available to dispute_hold. The dollars are derived from what the merchant was already credited for that payment, in proportion to the claim, with no second conversion, so a dispute never freezes more than the payment's net. A loss moves the hold to platform.cash and charges the dispute fee from the merchant's fee window in one journal; a win releases the hold. under_review posts nothing.
There is no security deposit, so available may go negative. A negative balance blocks settlement and is offset by the merchant's next receipts, and one that stays negative for thirty days raises an alert. A merchant answers with POST /v1/disputes/{id}/evidence, which merges into the evidence already on the dispute, key by key; a dispute that already carries a ruling accepts none.
GET /c/{token} serves one page: the amount, the methods routing can serve for it, a countdown. The payer picks a method, the page starts the attempt, shows the redirect or draws the QR code, and polls for the outcome. On success it sends the payer to the merchant's return_url.
The page loads no script, no stylesheet and no font from any host. Its style and its code are inline, the QR code is drawn by the page itself as an SVG, and every call it makes is to the origin that served it. A payment method may carry a brand mark, which is the one image the page fetches and the only thing it will ever fetch; the method name comes from the row and an inline glyph stands in, so a mark that is slow or gone costs a picture and never a payment. A page that WAITS on a third party is a page that is down when they are, and this one waits on nothing.
The page holds no key. The session token in the URL is the whole authority: 26 characters from crypto/rand, unrelated to the intent id and to time, and the store keeps its SHA-256 digest alone, so a dump of the store yields no live payment page. An expired session answers 410 and creates no attempt. The pay route takes no idempotency key, because a payer has no client to mint one; the guard is that an intent holds one pending attempt at a time, and four concurrent taps leave exactly one.
sk_live_ or sk_test_ and 26 characters from crypto/rand, 130 bits. It is shown once. The store keeps the prefix and a SHA-256 digest, which is correct for a value that cannot be enumerated offline, and a test reads the row back and finds no secret in it.none, hmac or ed25519. The canonical string is the timestamp, the method, the path with its query and the hex SHA-256 of the body, joined by newlines; the headers are X-Timestamp, X-Key-Id and X-Signature. A merchant that registers Ed25519 public keys holds the private half alone, so the platform's database leaking cannot forge its requests. A timestamp more than 300 seconds out is refused, and a signature replayed inside that window is refused by a nonce in Redis. If Redis is unreachable the nonce fails open, which widens the replay window to no more than those 300 seconds. That is stated here because it is true, not assumed away.403 approval_required with an Approval-Id header and applies nothing; the database refuses the maker as their own checker; the approved request replays exactly once.A document that admits a limit is a document a person can believe about everything else. These are the limits, as of the issue date.
POST /v1/payment_intents/{id}/cancel and the two /attempts routes. Cancel needs a transition to canceled under an idempotency claim that does not exist yet; the attempts routes need an attempt object the specification has not written. Not in the specification at all: GET /v1/payment_methods, the settlement list and item routes, GET /v1/balance_transactions, webhook endpoint management over the API, and the expand[] parameter. A schema is not written ahead of the code that serves it.GET/POST /dashboard/v1/webhook_endpoints and PATCH/DELETE /dashboard/v1/webhook_endpoints/{id} serve it behind the session cookie, so no API key can add or redirect an endpoint. What is still absent is the delivery log and the manual resend./dashboard/v1 behind a session cookie. The screens do not exist yet, so the dashboard still authenticates through its own server's configured key. The Admin console has its decision screens and five lists, and lacks the read and list screens for merchants, payments, channels, ledger accounts, settlements and rate snapshots.Measured on 2026-09-05 against the live database.
| What | Measured |
|---|---|
| Milestones | All nineteen, M0 to M18, landed. |
make check | gofmt, vet, staticcheck, build, test: exits 0. |
make e2e, make e2e-checkout, make check-web | Each exits 0. |
| Migrations | 44 files, forward-only, at goose version 230. |
| Tables | 44 in public, plus 112 partition children under 7 partitioned parents. |
Packages under internal/ | 31. |
| Paths in the two OpenAPI files | 16 merchant, 37 Admin. |
README.md, Status. That section is the one home of these numbers.