user-stories
US-06 — Merchant API · QR Code Checkout
US-06 — Merchant API · QR Code Checkout
A merchant integrates Daxchain on their own website so a customer pays for goods/services in crypto without logging into Daxchain. The merchant's site calls the public API to mint a payment order; Daxchain hosts the payment page; the customer scans the QR or copies the address; once on-chain confirmations land, Daxchain credits the merchant, fires a signed webhook, and (optionally) bounces the customer back to the merchant's return URL.
Glossary
- Order — the merchant-facing concept. Uniquely identified by the merchant-supplied
orderId(api_orders.order_idcolumn). Idempotency key on(merchant, orderId). - Order id (
orderId) — opaque string the merchant chooses (ORD-1001, etc.). Replaces the legacysessionIdterminology in user-facing docs, UI labels, webhook payloads and email subjects. - Internal session row — the database row in
payment_intents(kind='order'). Its primary key is still the auto-generated UUID (payment_intents.id); the hosted checkout URL uses that UUID for collision-safety and lookup performance. The merchant only ever seesorderId.
Persona
- Merchant —
users.role = 'merchant_owner', KYBapproved, with API credentials issued via/api-orders/config. - Customer (payer) — anonymous; never authenticates with Daxchain. Lands on the hosted payment page from the merchant's site.
Goal
Merchant: "Our checkout calls one Daxchain endpoint, Daxchain runs the whole crypto-payment UX, and we get a signed webhook the moment the funds clear. I can browse every order I've created and reconcile paid / underpaid / overpaid amounts at a glance from
/api-orders."Customer: "I scan the QR or copy the address, send the exact amount, and the page tells me it's paid."
Preconditions
- Merchant has populated
/api-orders/config:- Public ID + Secret Key — at least one active
apiKeysrow (pk_…+sk_…). - Client return URL — public HTTPS URL the payer is bounced to after payment.
- Webhook URL — public HTTPS URL Daxchain POSTs the signed payload to.
- Webhook shared secret for HMAC verification.
- Per-currency enable list — only the assets the merchant wants to accept (lives on the merchant row + a future on/off toggle UI; see Open questions).
- Public ID + Secret Key — at least one active
- The chosen
(network, currency)is supported (see 03-Domains#External Providers for the asset matrix). - The receive-by-QR primitive (02-QR-Receive) is shipped — every order reuses the same QR-render + copy-address pattern.
Happy path
-
Merchant signs the request body with their
Secret Key(HMAC-SHA256 over the canonical body) and POSTs to/api/v1/payments/orders:{ "order_id": "ORD-1001", "amount": 100, "currency": "EUR", "asset": "BTC", "callback_url": "https://example.com/hooks/daxchain", "return_url": "https://example.com/thanks", "expires_in": 1800, "metadata": { "cart": "42" } }Auth is
Authorization: Bearer <api-key>(orx-api-key). Validated with the Zod schema inlib/openapi/schemas.ts:createSessionSchema(kept for type-name continuity; payload describes a payment order). -
Daxchain freezes the EUR/asset rate, allocates a deposit address (Fireblocks sub-vault per order, same primitive as 01-Invoicing), persists an
payment_intents(kind='order') row inpendingkeyed by(merchant, orderId), and returns the hosted checkout URL plus the deposit parameters. -
The merchant's site redirects the customer to
https://daxchain.com/pay/order/<internalUuid>(or embeds it in an<iframe>). The internal UUID is opaque to the merchant; the merchant always references the order by itsorderId. -
Customer sees the payment page with: order id/reference, creation timestamp, fiat amount + crypto amount, asset + network, QR code, deposit address, Copy button, expiry countdown, live status indicator, and (optional) "back to " / "go to shop" buttons.
-
Customer scans the QR with their wallet or copies the address and pastes into a wallet manually, then sends the exact crypto amount.
-
(Planned) A Daxchain deposit indexer sees the inbound transaction, attaches
txHash, and tracks confirmations. Whileconfirmations < required, the order staysdetectedand the page shows "received, waiting for N more confirmations". Today this code path doesn't exist forpayment_intents(kind='order') — the only state transition wired in the codebase is the admin-only dev simulator at/api/v1/_dev/simulate-session-payment, which jumps straight frompending → confirmed. See 09-Open Questions#Deposit indexer for api_orders. -
Once the asset's required-confirmation threshold is met, the order transitions to one of three terminal paid states based on amount comparison:
confirmed— payer sent exactly the expected crypto amount (within tolerance).underpaid— payer sent strictly less than the expected amount; merchant ledger is credited the actual delivered amount; webhook fires withevent: payment.underpaidanddelta_minor < 0.overpaid— payer sent strictly more than expected; merchant ledger is credited the actual delivered amount; webhook fires withevent: payment.overpaidanddelta_minor > 0.
In all three cases (planned — see Acceptance Criteria for what's actually wired today):
- A
ledger_entriescredit lands against the merchant's account with idempotency keyapi_session:paid:<sessionId>. - A
transactionsrow of typeapi_sessionis written so the order surfaces in/admin/transactionsfiltered totype = api_session. (Not yet written in code — see open question on the deposit indexer.) - A
webhook_eventsrow is enqueued and POSTed to the merchant'scallback_urlwith the signed payload (see schema below). - The order shows up in the merchant's
/api-orderslist with the matching status pill. - The customer is offered "Return to merchant" →
return_url(auto-redirect after a brief confirmation banner).
Surfaces (merchant)
/api-orders— list view, styled like/invoices. Status pill filters:pending,detected,confirmed,underpaid,overpaid,expired,hold. Date-range filter oncreatedAt. Search byorderId. Pagination (20/page). Bottom stats strip mirrors/invoices:- open · eur — sum of
amountEurfor orders inpending/detected. - paid · eur — sum of
amountEurfor orders inconfirmed∪underpaid∪overpaid. - overpaid · eur — total positive delta (paid more than expected) in EUR-equivalent at the locked rate.
- underpaid · eur — total negative delta (paid less than expected) in EUR-equivalent at the locked rate.
- expired · eur — sum of
amountEurfor orders that hitexpiredwithout payment.
- open · eur — sum of
/api-orders/config— the former/api-settingspage. Public/Secret Key issuance, webhook URL + shared secret, return URL, "Send test event" button, "Resend webhook" replay link./admin/transactionsfiltered totype = api_session— admin-side reconciliation view.
Alternate flows
- Manual transfer instead of QR scan — customer copies the address. The page still polls and reaches the same terminal state.
- No
return_url— the post-payment screen stays on the Daxchain hosted page with a "Paid ✓" banner; no redirect. - Same
orderIdre-submitted with the same body — idempotent: returns the existing order unchanged (mirrorsmerchantBatchIdon payouts; see reference/payouts). - Webhook test from
/api-orders/config— a dedicated "Send test event" button posts a synthetic payload so the merchant can validate signature handling before going live. - Dev-mode emulator — local development still needs a one-click "mark this order as paid" path. The endpoint at
/api/v1/_dev/simulate-session-payment(admin-only, blocked whenPROVIDER_MODE=live) flips an order toconfirmed, writes the ledger entries, and enqueues apayment.confirmedwebhook. The endpoint name is preserved for backwards compatibility; the body field stayssessionId(the internal UUID) but the simulated webhook payload usesorder_idper the public contract. A future cleanup may rename the endpoint to/api/v1/_dev/simulate-order-paymentonce all Bruno fixtures are migrated. - Multiple deposits to the same address (top-up) — supported by 01-Invoicing today; out of scope for this story but the same accumulation logic could extend to checkout orders later.
Edge cases & failure modes
| # | Condition | System behaviour |
|---|---|---|
| 1 | client_return_url blank |
Hosted page just shows "Paid ✓" — no redirect. Acceptable; not an error. |
| 2 | webhook_url blank |
No callback fired. Audit log records webhook.skipped. The merchant relies on polling /api-orders or GET /api/v1/payments/uuid/{id}. |
| 3 | Webhook URL unreachable / 5xx | webhook_events enters failed; in-process exponential backoff retry (60s → 2m → 4m → 8m → 16m → 32m → 6h, max 7 attempts, then dead). Caveat: today the retry loop runs inside the original Node process via fire-and-forget runJob. A deploy / crash / autoscale during the backoff loses in-flight retries — failed rows stay stuck and nothing currently scans for them to resume. Production fix tracked at 09-Open Questions#Webhook resumer + 09-Open Questions#Background queue; merchant manual replay tracked under the planned /api-orders/config "resend webhook" button below. |
| 4 | Bad signature on create-order | 400 invalid-request + audit_log row tagged signature.invalid. No order persisted. |
| 5 | Asset not enabled in merchant config | 400 unknown-network-currency-pair (extends the existing API error). Hosted page never loads. |
| 6 | Internal failure during order creation | 500, no row. Merchant retries with the same orderId (idempotent — succeeds once Daxchain recovers). |
| 7 | Customer sends on the wrong network | Funds are unrecoverable in MVP. Hosted page warns on render with a chain-specific banner (02-QR-Receive#Edge cases). |
| 8 | Underpaid | Order transitions detected → underpaid. Webhook fires payment.underpaid with delta_minor < 0. Merchant ledger is credited the actual amount delivered, not the expected amount. Listed in /api-orders with the underpaid pill and counted in the underpaid · eur stat. |
| 9 | Overpaid | Order transitions detected → overpaid. Webhook fires payment.overpaid with delta_minor > 0. Merchant ledger is credited the actual amount delivered. Listed in /api-orders with the overpaid pill and counted in the overpaid · eur stat. |
| 10 | Exactly paid | Order transitions detected → confirmed. Webhook fires payment.confirmed with delta_minor = 0. Counted in paid · eur with no delta contribution. |
| 11 | Order expired (no funds detected) | Hosted page shows "Payment expired — ask the merchant for a fresh link". Address is no longer monitored, but late funds are not lost — they stay in the sub-vault and ops can manually credit if a complaint is raised. Counted in expired · eur. |
| 12 | Tx detected but confirmations not yet met | Status detected. Webhook is not fired yet. Page renders "received, waiting for N confirmations". |
| 13 | Webhook delivery failed after a terminal state | Internal webhook_events row sits in failed/retrying. Money is not lost — the merchant can re-trigger via /api-orders/config "resend webhook" or fetch via GET /api/v1/payments/uuid/{id}. |
| 14 | Webhook payload missing fields | Treated as a Daxchain bug, not a merchant error. The contract is fixed (see schema below). |
| 15 | HMAC mismatch on the merchant side | The merchant must reject — Daxchain can't enforce that. Documented in the API browser at /docs. |
| 16 | History entry missing after a terminal state | Treated as a P0 internal bug; ops alert via audit_log divergence between transactions and webhook_events. |
| 17 | Copy button doesn't copy | Page falls back to a fully-selected <code> block so the customer can Ctrl+C. |
| 18 | QR encodes wrong amount/address | Not possible by construction — the QR is generated from the same (address, amount) tuple the page renders; covered by the existing receive-QR test. |
| 19 | Customer not redirected to return_url |
When return_url is missing or invalid the page stays on Daxchain. The auto-redirect uses a 3 s timeout to give the customer time to read "Paid ✓" first. |
Webhook payload (signed)
POSTed to the merchant's callback_url once the order reaches a terminal state.
Signature delivery: the HMAC is not in the body — it's in the x-daxchain-signature HTTP header as sha256=<hex>. The body is the raw JSON below. This matches the GitHub / Stripe convention so generic library helpers work out of the box.
Headers Daxchain sends:
content-type: application/json
x-daxchain-event: payment.confirmed
x-daxchain-signature: sha256=<hmac_sha256(webhook_secret, raw_body_bytes)>
Body (today — minimal shape, matches code in worker/jobs/webhook.ts + app/api/v1/_dev/simulate-session-payment/route.ts):
{
"event": "payment.confirmed", // or .underpaid / .overpaid / .expired (planned)
"order_id": "ORD-1001", // merchant-supplied id
"session_id": "uuid", // internal row id, kept for legacy clients
"amount": 100, // EUR major units
"currency": "EUR",
"crypto_amount": "161290000", // minor units, decimal-string (BigInt-safe)
"asset": "BTC",
"txn_id": "0x…",
"blockchain_txid": "0x…",
"confirmation_status": "confirmed",
"timestamp": "2026-05-03T12:34:56Z",
}
Body (planned — extended shape, tracked under "Open questions"):
{
"event": "payment.underpaid", // .confirmed / .underpaid / .overpaid / .expired
"order_id": "ORD-1001",
"session_id": "uuid",
"merchant_user_id": "uuid",
"amount_eur": 100.0,
"neto_eur": 99.5, // amount - platform fee
"currency": "EUR",
"crypto_amount_expected": "0.00161290", // major units, decimal-string
"crypto_amount_paid": "0.00150000", // actual on-chain delivery
"delta_minor": "-11290", // paid - expected, signed; minor units
"crypto_neto": "0.00149005",
"asset": "BTC",
"network": "BTC",
"deposit_address": "bc1q…",
"tx_hash": "0x…",
"confirmations": { "current": 6, "required": 3 },
"status": "underpaid",
"timestamp": "2026-05-03T12:34:56Z",
}
signature = "sha256=" + hmac_sha256(secret, raw_request_body). The merchant verifies by recomputing on the raw bytes received and comparing constant-time to the header value. Signature is omitted only when the merchant has no webhook_secret configured.
Acceptance criteria
- Idempotent on
(merchant, orderId)— re-POSTing while the existing row ispendingordetectedreturns the original session unchanged; a finalized row returns409 order-already-finalized. Backed by partial unique indexapi_orders_user_order_id_uidx. - Both
Authorization: Bearer <api-key>andx-api-keyaccepted onPOST /api/v1/payments/ordersandGET /api/v1/payments/uuid/{id}(vialib/payouts/auth.ts:authenticateApiCall). - Hosted payment page reuses the 02-QR-Receive QR + copy-address primitives.
- Hosted page distinguishes
detected(received, awaiting confirmations — yellow panel + auto-refresh) fromconfirmed | underpaid | overpaid(green Paid panel). - HMAC signature verifiable against the merchant's stored
webhook_secret. Signature is delivered inx-daxchain-signature: sha256=<hex>over the raw request body. - [~] Webhook delivery exponential backoff implemented in-process (
MIN_BACKOFF_MS=60s,MAX_BACKOFF_MS=6h,MAX_ATTEMPTS=7) with dead-letter on attempt exhaustion. Partial — retries don't survive process restarts:runJobis fire-and-forget; the backoff loop lives in the original Node process. A deploy / crash / autoscale mid-backoff loses in-flight retries andfailedrows are not resumed by anything. Hardening tracked under the next two items. - Webhook resumer — periodic worker (or
pg_cron) that picks upwebhook_events WHERE status='failed' AND next_attempt_at < now() AND attempts < MAX_ATTEMPTSand re-runs delivery. Closes the deploy-restart gap without waiting for the full background-queue cutover. See 09-Open Questions#Webhook resumer. - Durable background queue for webhook delivery — shipped as the
webhook.deliverpg-boss queue, so retries survive process restarts end-to-end. See 09-Open Questions#Background queue. - Manual webhook replay —
/api-orders/configbutton to re-fire anyfailedordeadevent for an order, plus a per-order admin replay path. Acknowledged in edge case 13 above. - Published Webhook delivery SLA — see 09-Open Questions#Webhook reliability.
- Dev-mode emulator (
POST /api/v1/_dev/simulate-session-payment) preserved — admin-only, blocked whenPROVIDER_MODE=live. -
/api-orderspage lists all orders for the merchant with status pills, date-range filter, search-by-orderId, pagination (20/page), and an aggregate stats strip mirroring/invoices(open / paid / overpaid / underpaid / expired). -
/api-orders/confighosts API key issuance, webhook URL/secret, and return URL (the legacy/api-settingssurface). - Status transitions
pending → detected → confirmed | underpaid | overpaid | expired | holdactually wired. The enum exists; today only the dev simulator emitsconfirmed. There is no production indexer that watches the deposit address, attachestxHash, tracks confirmations, or compares delivered vs expected to flipunderpaid/overpaid. See 09-Open Questions#Deposit indexer for api_orders. -
api_sessiontransactionsrow written on confirmation. Today the dev simulator only writesledger_entries+audit_log; the'api_session'value in thetransaction_typeenum is unused. Without atransactionsrow the order doesn't appear in/admin/transactionsfiltered totype = api_session. - Pending-order expirer. Worker cron and order read paths flip overdue rows from
pending → expired, so/api-ordersstats and API polling reflect the terminal state. -
/api-orders/configexposes per-currency enable toggle, "Send test event" button, last-delivery status + replay link. - OpenAPI doc at
/docsshows the webhook payload schema (currently only the request shapes are published). - Webhook payload extended with
crypto_amount_paid+delta_minor(and the rest of the "planned" body shape above) so merchants can reconcile under/over without a follow-up GET.
Out of scope
- In-app card → crypto on-ramp (that's 05-Mercuryo-Buy-Sell-Convert).
- Customer-side accounts / KYC (the payer is anonymous; KYC stays on the merchant side).
- Refunds (manual ops via
/admin/transactionsonly — see 09-Open Questions#Refunds). - Subscriptions / recurring orders.
- Webhook signing-key rotation UI (10-Roadmap#Scale).
- Renaming the schema column
payment_intents.idand the hosted-page route param[sessionId]to[orderId]. Terminology shift is user-visible only; column names stay for migration safety. (09-Open Questions#API column rename)
Drawbacks
- One Fireblocks sub-vault per order — same cost trade-off as invoices (08-Risks and Drawbacks#Invoices).
- Expired-but-paid funds aren't auto-refunded; ops triage today.
- No background queue for webhook delivery yet → spiky traffic can starve the request lifecycle (08-Risks and Drawbacks#Architecture).
- TLS, CSP, and CSRF on the hosted
/pay/order/<internalUuid>page need a security pass before public launch (07-Non-Functional#Security). - Mixed terminology —
orderIdis the public-facing identifier but the schema, internal jobs, and hosted-page route still usesession*. The doc and UI labels normalize on order; the engineering layer cuts over later.
Open questions
- 09-Open Questions#Webhook reliability — published SLA atop the existing exponential backoff + dead-letter.
- 09-Open Questions#Deposit indexer for api_orders — productionising
pending → detected → confirmed | underpaid | overpaidvia a watcher that joinsobserved_deposits(or the live chain provider) againstpayment_intents.deposit_address. Today only the admin-only dev simulator advances state. - 09-Open Questions#Pending-order expirer — cron that flips
pendingrows pastexpires_attoexpired, idempotent and safe to re-run. Without it,/api-orders"expired · eur" stat under-counts. - 09-Open Questions#Refunds — official handling of underpaid / overpaid / expired-but-paid.
- 09-Open Questions#PII storage —
client_ipin the webhook payload (GDPR scope). - 09-Open Questions#API column rename — eventual rename of
payment_intents.id→payment_intents.idand the hosted-page route param[sessionId]→[orderId]. Pending until the dev simulator + Bruno fixtures are migrated. - Per-currency toggle UX — checkbox grid on
/api-orders/configvs an admin-side allowlist. - Underpaid policy — credit actual delivered amount (current behaviour) vs hold the order in
pendinguntil the payer tops up. The latter mirrors invoice top-up but extends order TTL semantics.
Implementing surfaces
- Merchant config (live):
app/my/orders/config/page.tsx— Public ID, Secret Key (mint + revoke), webhook URL/secret, return URL. - Merchant orders list (live):
app/my/orders/page.tsx— paginatedpayment_intents(kind='order') for the current merchant with status filter, date range, search-by-orderId, and the under/over/exact paid stats strip. - Public REST:
app/api/v1/payments/orders/route.ts(POST = create),app/api/v1/payments/uuid/[id]/route.ts(GET = read). Auth vialib/payouts/auth.ts(bearer /x-api-key). - Hosted checkout:
app/(public)/pay/order/[sessionId]/page.tsx— renders the QR + address, reuses the design primitives from 02-QR-Receive, and (in dev mode only,APP_ENV=development) exposes an inline "emulate payment" form that flips the order toconfirmed | underpaid | overpaidbased on the entered amount and fires a webhook. The[sessionId]route segment is the internal UUID; the page surfaces the merchant-suppliedorderIdin the heading. - Webhook delivery:
webhook_eventstable (lib/db/schema/merchant.ts); job inworker/jobs/(today fires from the request lifecycle, queue cutover at Beta). - History:
app/admin/transactions/page.tsx(admin) and the merchant-facing/api-orderslist (filtertype = api_sessionon the admin side). - Schema:
apiKeys,paymentIntents(withkind = 'order'),webhookEvents,observed_deposits(deposit indexer source),transactions,ledgerEntries,auditLog. - Provider mode:
_devsimulator at/api/v1/_dev/simulate-session-paymentflips an order toconfirmedfor local testing (admin-only, blocked whenPROVIDER_MODE=live). Endpoint name preserved for compatibility; payload field is stillsessionId(the internal UUID) — a future cleanup will alias it toorderId.
Cross-references
- Domain entry: 03-Domains (Payments row).
- Engineering reference: this story is the user-facing companion to
docs/reference/api-checkout.md. - Bruno scenarios:
tests/bruno/payments/(TODO — add alongside the existingpayouts/collection).