user-stories

US-06 — Merchant API · QR Code Checkout

docs/user-stories/06-Merchant-API-QR-Payments.mdtype: user-storyupdated: 2026-05-03

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_id column). Idempotency key on (merchant, orderId).
  • Order id (orderId) — opaque string the merchant chooses (ORD-1001, etc.). Replaces the legacy sessionId terminology 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 sees orderId.

Persona

  • Merchantusers.role = 'merchant_owner', KYB approved, 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 apiKeys row (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).
  • 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

  1. 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> (or x-api-key). Validated with the Zod schema in lib/openapi/schemas.ts:createSessionSchema (kept for type-name continuity; payload describes a payment order).

  2. 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 in pending keyed by (merchant, orderId), and returns the hosted checkout URL plus the deposit parameters.

  3. 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 its orderId.

  4. 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.

  5. Customer scans the QR with their wallet or copies the address and pastes into a wallet manually, then sends the exact crypto amount.

  6. (Planned) A Daxchain deposit indexer sees the inbound transaction, attaches txHash, and tracks confirmations. While confirmations < required, the order stays detected and the page shows "received, waiting for N more confirmations". Today this code path doesn't exist for payment_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 from pending → confirmed. See 09-Open Questions#Deposit indexer for api_orders.

  7. 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 with event: payment.underpaid and delta_minor < 0.
    • overpaid — payer sent strictly more than expected; merchant ledger is credited the actual delivered amount; webhook fires with event: payment.overpaid and delta_minor > 0.

    In all three cases (planned — see Acceptance Criteria for what's actually wired today):

    • A ledger_entries credit lands against the merchant's account with idempotency key api_session:paid:<sessionId>.
    • A transactions row of type api_session is written so the order surfaces in /admin/transactions filtered to type = api_session. (Not yet written in code — see open question on the deposit indexer.)
    • A webhook_events row is enqueued and POSTed to the merchant's callback_url with the signed payload (see schema below).
    • The order shows up in the merchant's /api-orders list 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 on createdAt. Search by orderId. Pagination (20/page). Bottom stats strip mirrors /invoices:
    • open · eur — sum of amountEur for orders in pending / detected.
    • paid · eur — sum of amountEur for orders in confirmedunderpaidoverpaid.
    • 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 amountEur for orders that hit expired without payment.
  • /api-orders/config — the former /api-settings page. Public/Secret Key issuance, webhook URL + shared secret, return URL, "Send test event" button, "Resend webhook" replay link.
  • /admin/transactions filtered to type = 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 orderId re-submitted with the same body — idempotent: returns the existing order unchanged (mirrors merchantBatchId on 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 when PROVIDER_MODE=live) flips an order to confirmed, writes the ledger entries, and enqueues a payment.confirmed webhook. The endpoint name is preserved for backwards compatibility; the body field stays sessionId (the internal UUID) but the simulated webhook payload uses order_id per the public contract. A future cleanup may rename the endpoint to /api/v1/_dev/simulate-order-payment once 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 is pending or detected returns the original session unchanged; a finalized row returns 409 order-already-finalized. Backed by partial unique index api_orders_user_order_id_uidx.
  • Both Authorization: Bearer <api-key> and x-api-key accepted on POST /api/v1/payments/orders and GET /api/v1/payments/uuid/{id} (via lib/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) from confirmed | underpaid | overpaid (green Paid panel).
  • HMAC signature verifiable against the merchant's stored webhook_secret. Signature is delivered in x-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: runJob is fire-and-forget; the backoff loop lives in the original Node process. A deploy / crash / autoscale mid-backoff loses in-flight retries and failed rows are not resumed by anything. Hardening tracked under the next two items.
  • Webhook resumer — periodic worker (or pg_cron) that picks up webhook_events WHERE status='failed' AND next_attempt_at < now() AND attempts < MAX_ATTEMPTS and 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.deliver pg-boss queue, so retries survive process restarts end-to-end. See 09-Open Questions#Background queue.
  • Manual webhook replay/api-orders/config button to re-fire any failed or dead event 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 when PROVIDER_MODE=live.
  • /api-orders page 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/config hosts API key issuance, webhook URL/secret, and return URL (the legacy /api-settings surface).
  • Status transitions pending → detected → confirmed | underpaid | overpaid | expired | hold actually wired. The enum exists; today only the dev simulator emits confirmed. There is no production indexer that watches the deposit address, attaches txHash, tracks confirmations, or compares delivered vs expected to flip underpaid / overpaid. See 09-Open Questions#Deposit indexer for api_orders.
  • api_session transactions row written on confirmation. Today the dev simulator only writes ledger_entries + audit_log; the 'api_session' value in the transaction_type enum is unused. Without a transactions row the order doesn't appear in /admin/transactions filtered to type = api_session.
  • Pending-order expirer. Worker cron and order read paths flip overdue rows from pending → expired, so /api-orders stats and API polling reflect the terminal state.
  • /api-orders/config exposes per-currency enable toggle, "Send test event" button, last-delivery status + replay link.
  • OpenAPI doc at /docs shows 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/transactions only — see 09-Open Questions#Refunds).
  • Subscriptions / recurring orders.
  • Webhook signing-key rotation UI (10-Roadmap#Scale).
  • Renaming the schema column payment_intents.id and 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 — orderId is the public-facing identifier but the schema, internal jobs, and hosted-page route still use session*. 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 | overpaid via a watcher that joins observed_deposits (or the live chain provider) against payment_intents.deposit_address. Today only the admin-only dev simulator advances state.
  • 09-Open Questions#Pending-order expirer — cron that flips pending rows past expires_at to expired, 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 storageclient_ip in the webhook payload (GDPR scope).
  • 09-Open Questions#API column rename — eventual rename of payment_intents.idpayment_intents.id and 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/config vs an admin-side allowlist.
  • Underpaid policy — credit actual delivered amount (current behaviour) vs hold the order in pending until 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 — paginated payment_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 via lib/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 to confirmed | underpaid | overpaid based on the entered amount and fires a webhook. The [sessionId] route segment is the internal UUID; the page surfaces the merchant-supplied orderId in the heading.
  • Webhook delivery: webhook_events table (lib/db/schema/merchant.ts); job in worker/jobs/ (today fires from the request lifecycle, queue cutover at Beta).
  • History: app/admin/transactions/page.tsx (admin) and the merchant-facing /api-orders list (filter type = api_session on the admin side).
  • Schema: apiKeys, paymentIntents (with kind = 'order'), webhookEvents, observed_deposits (deposit indexer source), transactions, ledgerEntries, auditLog.
  • Provider mode: _dev simulator at /api/v1/_dev/simulate-session-payment flips an order to confirmed for local testing (admin-only, blocked when PROVIDER_MODE=live). Endpoint name preserved for compatibility; payload field is still sessionId (the internal UUID) — a future cleanup will alias it to orderId.

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 existing payouts/ collection).