Payment Gateway API Reference
Version: 0.1.0 Date: 2026-04-19 Service: payment-gatewayRepository: Hello-World-Co-Op/payment-gatewayPort: 3200 Epic: PLATFORM-007 (payment unification — completed 2026-04-19)
Overview
The payment-gateway is the unified off-chain payment microservice for the Hello World DAO and FounderyOS products. It consolidates three distinct payment rails behind a single REST surface:
- Stripe — fiat (USD) payments for membership dues, donations, SaaS billing, education, and internal disbursements.
- Stripe Connect — marketplace buyer → vendor purchases with automatic fee splits and a 30-day dispute hold before vendor payout.
- ICP / DOM crypto — direct ICRC-1 transfers to per-payment subaccounts, price-quoted via the NNS exchange-rate canister (ICP) and the governance canister (DOM).
All endpoints live under /api/v1/* on port 3200 and sit behind bearer service-token auth (see Authentication). The service is reached in production through the platform API gateway at /payments/* (prefix stripped at the edge).
Key Features
- Single factory routes by
(payment_type, currency)to the correct provider. - Idempotent webhook ingestion — Stripe events deduplicated on
(provider, external_id)inwebhook_events; every incoming event is audit-logged before business logic runs. - Separate Connect webhook secret — standard Stripe and Stripe Connect webhooks use independent HMAC secrets on independent endpoints.
- 30-day held-until pattern on marketplace fee splits, mirroring dom-token's
HeldBurndispute window. - Banker's-rounded fee splits (round-half-to-even) guarantee
platform_fee + vendor_amount + stripe_fee === total_centsexactly. - Crypto quote locking — per-payment SHA-256 subaccount derivation, 10-minute TTL, ±2% slippage band at confirmation time, downward-only fee tolerance.
- Graceful notification degradation — receipt / refund / membership-confirm emails are fire-and-forget; delivery failures never fail a payment.
Current Status
Implemented (PLATFORM-007.1 → .7, completed 2026-04-19):
- Service scaffold + Express app factory + health probe
- Stripe provider (checkout sessions, webhooks, refunds, double-refund guard)
- Stripe Connect provider (destination charges, vendor onboarding,
account.updatedsync, fee splits) - ICP/DOM crypto provider (quotes, subaccount derivation, on-chain verifier, slippage enforcement)
- Notification wiring to notification-service for three template types
- Hourly payout scheduler (sweep released fee splits) + 60-second crypto quote expiry job
- Database schema through migration 005 (payments, webhook_events, refunds, fee_splits, vendors,
expiredstatus)
Pending / out of scope for 007:
- Stripe Connect refunds with
reverse_transfer: true(deferred — file BL-* when BL-078.2/3 unminting wallet flow is fully wired) - Direct ICP/DOM refunds (see Error Reference — always returns 501; refunds are manual
icrc1_transferadmin calls today) - Multi-replica distributed locking for the hourly sweep (single-replica assumption)
Authentication
Every route under /api/v1/* is guarded by a bearer service-token middleware except /api/v1/health (public, for k8s probes) and /api/v1/webhooks/* (verified by Stripe HMAC signature — see Webhooks).
Authorization: Bearer <TOKEN_PAYMENT_GATEWAY>Token Source
- k8s Secret:
platform/service-tokenskeyTOKEN_PAYMENT_GATEWAY(PLATFORM-006.4). - Pod env: injected as
SERVICE_TOKEN. - Callers: oracle-bridge (DAO), founderyos-core (FOS), internal scripts. Never exposed to browsers — callers proxy on behalf of their signed-in users.
Fail-Closed Semantics
If SERVICE_TOKEN is unset in the environment, the middleware fails closed with 503 { error: "service_unavailable" } — the service refuses all requests rather than accepting any traffic. This prevents accidentally deploying an open payment relay.
Token comparison uses constant-time equality to avoid timing side-channels on 401 paths.
Unauthenticated Endpoints
| Path | Reason |
|---|---|
GET /api/v1/health | k8s liveness/readiness probes cannot forward a service token |
POST /api/v1/webhooks/stripe | Stripe servers sign payloads; signature IS the auth mechanism |
POST /api/v1/webhooks/stripe-connect | Same — signed with a separate Connect-specific secret |
Provider Factory & Routing
A single factory selects the PaymentProvider implementation based on (payment_type, currency).
Currency → Provider Routing Table
| Currency | Provider | Notes |
|---|---|---|
usd | stripe or stripe-connect | Provider selected by payment type (below). Fiat via Stripe Checkout. |
icp | IcpProvider | ICRC-1 transfer to derived subaccount; quote from NNS XRC canister. |
dom | IcpProvider | ICRC-1 transfer + governance-canister price oracle. |
Payment Type → Default Provider Table
| Payment Type | Default Provider | Use Case |
|---|---|---|
membership-dues | stripe | DAO annual membership (USD $25/yr) |
marketplace-purchase | stripe-connect | Buyer → vendor (destination charges + held fee split) |
campaign-contribution | stripe | Otter Camp crowdfunding |
saas-billing | stripe | FounderyOS subscription billing |
federation-partner-fee | stripe | Federation / partner cross-payouts |
education-course | stripe | Rabbit Whole course payments |
kyc-refund | stripe | KYC-failure refund flow |
marketplace-vendor-payout | stripe-connect | Vendor-initiated payout (inverse of marketplace-purchase) |
treasury-disbursement | stripe | Treasury-canister-approved USD disbursements |
contributor-reward | stripe | Off-chain contributor reward payout |
icp-dom-direct | icp | Direct crypto payment (no ambiguity — always crypto) |
When the currency field on the request is icp or dom, the factory overrides the type-based default and routes to IcpProvider via getProviderForCurrency(type, currency). Otherwise, the type-based default applies.
PaymentProvider Interface
All providers implement the same shape:
interface PaymentProvider {
createSession(input: CreateSessionInput): Promise<CreateSessionResult>;
handleWebhook(rawBody: Buffer, signature: string): Promise<void>;
getStatus(paymentId: string): Promise<PaymentStatus>;
refund(paymentId: string, amount: number, reason: string): Promise<RefundResult>;
}REST API Reference
Health
GET /api/v1/health
Lightweight liveness/readiness probe. No DB or outbound calls.
Auth: Public (mounted before bearerAuth).
Response (200):
{
"status": "ok",
"service": "payment-gateway",
"version": "0.1.0",
"environment": "production",
"uptime_seconds": 12345,
"providers": {
"stripe": "pending",
"icp": "pending"
}
}The providers block carries static "pending" values today; future stories replace them with real ping probes.
Payments
POST /api/v1/payments
Create a new payment session. The request body shape is polymorphic by currency:
currency: "usd"→ fiat path (Stripe / Stripe Connect).currency: "icp" | "dom"→ crypto path (IcpProvider). Returns an enriched response withreceiving_account,quote_e8s, andexpires_at_ms.
Auth: Bearer service token.
Fiat request body (USD):
{
brand: "DAO" | "FOS" // required, non-nullable (BL-187 gate)
// ...remaining Stripe fields handled by the caller today (oracle-bridge, founderyos-core)
// invoke the provider directly; full fiat session schema on this route arrives in a
// follow-up when browser-initiated payment flows migrate off the caller.
}Under BL-187, brand is a required enum:
| Brand | Maps to domain |
|---|---|
DAO | helloworlddao.com |
FOS | founderyos.dev |
A missing or null brand returns 400 { error: "validation_error" }.
Crypto request body (ICP/DOM):
{
brand: "DAO" | "FOS",
type: PaymentType, // from the payment type table above
amount: number, // positive integer, USD cents
currency: "icp" | "dom",
user_id: string, // UUID
user_email: string, // valid email
metadata?: Record<string, unknown>,
success_url?: string, // optional URL
cancel_url?: string // optional URL
}Crypto response (201):
{
"session_id": "crypto_<uuid>",
"currency": "icp",
"quote_e8s": "117647058",
"quoted_price_usd": 8.50,
"expires_at_ms": 1745123456789,
"receiving_account": {
"owner": "ervli-tob4m-...",
"subaccount": [0, 1, 2, ... 31]
},
"domain": "helloworlddao.com"
}The client uses receiving_account as the ICRC-1 transfer target and polls POST /api/v1/payments/:id/verify to confirm the on-chain transfer before the quote TTL elapses (default 10 minutes, configurable via CRYPTO_QUOTE_TTL_MS).
Errors:
| Status | Body | Cause |
|---|---|---|
400 | { error: "validation_error", field_errors } | zod schema failure |
400 | { error: "unknown_payment_type" } | type not in allowed set |
500 | { error: "provider_returned_unexpected_shape" } | Factory routing bug |
503 | { error: "price_unavailable", currency } | XRC or governance oracle returned no price |
503 | { error: "ic_query_failed" } | ICP/DOM provider unconfigured (missing env vars) |
POST /api/v1/payments/:id/verify
Trigger on-chain transfer verification for a crypto payment. The :id path parameter accepts either the payment UUID or the gateway session_id (crypto_<uuid>).
Auth: Bearer service token.
Response (200):
{
"status": "confirmed" | "pending" | "expired" | "slippage_exceeded",
"observed_e8s": "117650000",
"quote_e8s": "117647058"
}Status values:
| Status | Meaning | Side effect |
|---|---|---|
confirmed | Balance ≥ quote_e8s - transfer_fee AND price within ±2% of quote | Row flipped to completed; external_id set to observed balance |
pending | Balance below minimum OR canister query failed transiently | No DB mutation; caller retries |
expired | Quote TTL elapsed before verification | Row flipped to expired |
slippage_exceeded | Balance cleared but current price deviates >2% from quote | Row flipped to expired |
Invariant order (hard-enforced in the verifier):
- Idempotent fast path: already-
completedrows returnconfirmedwithout canister query. - Expiry check before any canister call (TOCTOU prevention).
- ICRC-1
balance_ofon the derived subaccount. - Slippage check against a fresh oracle price (only after balance clears).
- Atomic flip to
completedwith SQL-level gate onstatus = 'pending'.
Errors:
| Status | Body | Cause |
|---|---|---|
400 | { error: "invalid_id" } | Empty or > 200-char id |
404 | { error: "payment_not_found" } | No matching payments row |
503 | { error: "ic_query_failed" } | IcpProvider unconfigured |
GET /api/v1/payments/:id
Fetch a payment's current status. For crypto payments, returns the quote metadata fields too.
Auth: Bearer service token.
Response (200 — fiat):
{
"id": "<uuid>",
"session_id": "cs_test_...",
"external_id": "pi_...",
"status": "completed",
"amount": 2500,
"currency": "usd",
"type": "membership-dues",
"completed_at": "2026-04-19T12:34:56.789Z"
}Response (200 — crypto adds):
{
"receiving_account": { "owner": "...", "subaccount": [...] },
"quote_e8s": "117647058",
"quoted_price_usd": 8.50,
"quote_expires_at_ms": 1745123456789
}Errors: 400 invalid_id, 404 payment_not_found.
POST /api/v1/payments/:id/refund
Initiate a refund. Shape depends on the underlying provider.
Auth: Bearer service token.
Response (200 — Stripe path, when implemented):
{
"refunded": true,
"payment_id": "<uuid>"
}Response (501 — ICP/DOM currency):
{
"error": "refund_not_implemented",
"message": "Direct refund is not supported for ICP/DOM payments. Marketplace vendor payouts use /payouts/release; direct crypto refunds require manual transfer.",
"provider": "icp"
}501 is a permanent capability boundary for the crypto provider — callers must never treat it as a transient error. Crypto refunds today are issued manually via an admin icrc1_transfer call.
Errors: 400 invalid_id, 404 payment_not_found, 501 refund_not_implemented, 500 internal_error.
Payouts
POST /api/v1/payouts/release
Manually release a marketplace fee split (mirrors refund_held_burn() on dom-token). The fee split must be past its held_until timestamp (default 30 days after creation). Flips released = TRUE atomically and issues a Stripe Connect transfer to the vendor's connected account.
Auth: Bearer service token.
Request body:
{
fee_split_id: string // UUID (accepts any valid UUID shape, v1–v5)
}Response (200):
{
"released": true,
"fee_split_id": "<uuid>",
"vendor_amount": 9250
}Flow:
- SELECT + 404 if fee split missing.
- 409 if already released (idempotent — duplicate calls return the same error rather than silently succeeding).
- 409 if
held_untilis still in the future (dispute window still active). - Atomic
UPDATE ... WHERE released = FALSE AND held_until < NOW()— race-safe gate. - Resolve vendor and verify
status = 'active' AND payouts_enabled. stripe.transfers.create({ destination: vendor.stripe_connect_account_id, ... })with idempotency keyfee_split_release:<id>.- If Stripe fails, roll back the DB flip so the row is eligible for the next sweep.
Errors:
| Status | Body | Cause |
|---|---|---|
400 | { error: "validation_error" } | fee_split_id malformed |
404 | { error: "not_found" } | No such fee split |
409 | { error: "already_released" } | Already released (idempotent) |
409 | { error: "dispute_window_active", held_until } | Still within 30-day hold |
409 | { error: "vendor_not_active", vendor_status } | Vendor is pending/restricted/payouts disabled |
500 | { error: "vendor_missing" } | fee_split has no vendor_id (backfill required) |
502 | { error: "stripe_transfer_failed" } | Stripe API error; DB flip already rolled back |
Vendors
POST /api/v1/vendors/onboard
Create (or refresh) a Stripe Connect Express account + onboarding link for a marketplace vendor. Safe to retry — an existing vendor row reuses the Stripe account and returns a fresh accountLinks.create URL (Stripe links expire ~5 minutes after issue).
Auth: Bearer service token.
Request body:
{
user_id: string, // UUID
email: string, // valid email
refresh_url: string, // URL — where Stripe sends the vendor if they abandon
return_url: string // URL — where Stripe sends the vendor after success
}Response (201 — new vendor):
{
"onboarding_url": "https://connect.stripe.com/setup/s/acct_...",
"stripe_account_id": "acct_1N...",
"status": "pending",
"charges_enabled": false,
"payouts_enabled": false,
"existing": false
}Response (200 — existing vendor, fresh link):
{
"onboarding_url": "https://connect.stripe.com/setup/s/acct_...",
"stripe_account_id": "acct_1N...",
"status": "active",
"charges_enabled": true,
"payouts_enabled": true,
"existing": true
}Vendor status lifecycle:
| Status | Meaning |
|---|---|
pending | Account created, onboarding link issued, vendor has not yet started |
details_submitted | Vendor completed onboarding form, awaiting Stripe verification |
active | charges_enabled = true AND payouts_enabled = true |
restricted | Stripe flagged the account (requirements.disabled_reason set) |
The status is updated in-place by the account.updated webhook handler — see Webhooks.
Errors: 400 validation_error, 502 stripe_call_failed, 500 vendor_insert_failed.
Webhooks
Critical middleware ordering: Stripe webhook routes mount before express.json() in app.ts because stripe.webhooks.constructEvent() requires the raw Buffer body for HMAC verification. Reordering breaks signature verification 100% of the time. See src/app.ts for the exact mount order and oracle-bridge's AI-R211 for the history of this lesson.
POST /api/v1/webhooks/stripe
Standard Stripe webhook endpoint (signed with STRIPE_WEBHOOK_SECRET).
Auth: Public. Stripe HMAC signature IS the auth mechanism.
Content-Type: application/json — parsed as raw Buffer (limit 1 MB).
Supported events:
| Event type | Handler behaviour |
|---|---|
checkout.session.completed | Flip payment row pending → completed. Fire payment-receipt email. If type = 'membership-dues', also fire membership-confirm email. |
| all other types | Audit-logged to webhook_events + 200. Stripe stops retrying. |
Invariants:
- Signature verification happens before any DB write.
- Duplicate events (replay) short-circuit on
webhook_events UNIQUE (provider, external_id)and return200 { received: true, duplicate: true }. markPaymentCompletedis idempotent viaWHERE status = 'pending'— a replayedcheckout.session.completedreturns null from the UPDATE and does not re-fire notifications.
Responses:
| Status | Body | Cause |
|---|---|---|
200 | { received: true, payment_id, status: "completed" } | Success |
200 | { received: true, duplicate: true } | Replayed event |
200 | { received: true, ignored: true } | Event type with no handler |
400 | { error: "missing_stripe_signature" } | Stripe-Signature header absent |
400 | { error: "invalid_signature" } | HMAC mismatch |
500 | { error: "stripe_webhook_not_configured" } | STRIPE_WEBHOOK_SECRET unset |
POST /api/v1/webhooks/stripe-connect
Stripe Connect webhook endpoint. Signed with a DIFFERENT secret (STRIPE_CONNECT_WEBHOOK_SECRET) — Stripe issues Connect webhook secrets from a separate Dashboard endpoint (Webhooks → Connect vs Webhooks → Standard). Re-using the standard-webhook secret for Connect events fails verification 100% of the time.
Auth: Public. Separate HMAC signature check.
Supported events:
| Event type | Handler behaviour |
|---|---|
payment_intent.succeeded | Flip payment row pending → completed. Insert fee_splits row with computed platform_fee / vendor_amount / stripe_fee and held_until = NOW() + 30 days. |
account.updated | Update vendor row with new status, charges_enabled, payouts_enabled. |
| all other types | Audit-logged + 200. |
Fee split amounts are computed from the authoritative amount_received on the PaymentIntent (not the caller's originally-requested amount) to handle partial captures and currency conversions correctly.
Errors: Same shape as /webhooks/stripe but with stripe_connect_webhook_not_configured / stripe_connect_not_configured codes when Connect env vars are missing.
Provider Flows
Flow 1 — DAO Membership Payment (Stripe)
sequenceDiagram
autonumber
participant Browser
participant OB as oracle-bridge
participant PG as payment-gateway
participant S as Stripe
participant DB as OVH Postgres
participant NS as notification-service
Browser->>OB: POST /membership/join
OB->>PG: POST /api/v1/payments (Bearer SERVICE_TOKEN)
PG->>S: stripe.checkout.sessions.create (idempotencyKey=user+type+amount)
S-->>PG: { id: cs_..., url: ... }
PG->>DB: INSERT pending payment row
PG-->>OB: 201 { session_id, checkout_url }
OB-->>Browser: 302 checkout_url
Browser->>S: complete checkout
S->>PG: POST /api/v1/webhooks/stripe (signed)
PG->>PG: constructEvent(rawBody, sig)
PG->>DB: INSERT webhook_events (dedup on provider+external_id)
PG->>DB: UPDATE payments SET status='completed' WHERE status='pending'
PG-->>S: 200 { received: true, payment_id }
PG--)NS: POST /api/v1/send payment-receipt (detached)
PG--)NS: POST /api/v1/send membership-confirm (detached, membership-dues only)Key invariants:
- Session creation writes the DB row after Stripe returns. If the DB insert fails, the gateway best-effort-calls
stripe.checkout.sessions.expire(id)to prevent an orphan session the user could still complete. - Idempotency keys prevent duplicate Stripe objects on network-retry.
- Both notification calls are fire-and-forget — failures log WARN and never block the
200back to Stripe.
Flow 2 — Marketplace Vendor Purchase (Stripe Connect)
sequenceDiagram
autonumber
participant Buyer
participant PG as payment-gateway
participant S as Stripe
participant DB as OVH Postgres
participant Sched as Payout Scheduler (hourly)
Note over PG,DB: Vendor already onboarded — vendor row status='active'
Buyer->>PG: POST /api/v1/payments (type=marketplace-purchase, metadata.vendor_user_id)
PG->>DB: SELECT vendors WHERE user_id=? AND status='active'
PG->>PG: calculateFeeSplit(total, rates) → banker's rounded
PG->>S: checkout.sessions.create (application_fee_amount, transfer_data.destination)
S-->>PG: session
PG->>DB: INSERT pending payment (provider=stripe-connect)
PG-->>Buyer: checkout_url
Buyer->>S: complete payment
S->>PG: POST /api/v1/webhooks/stripe-connect payment_intent.succeeded (Connect secret)
PG->>DB: UPDATE payments SET status='completed'
PG->>PG: calculateFeeSplit(amount_received) [authoritative]
PG->>DB: INSERT fee_splits (platform_fee, vendor_amount, stripe_fee, held_until=NOW()+30d)
Note over Sched,S: 30 days later
Sched->>DB: SELECT fee_splits WHERE released=FALSE AND held_until<NOW() LIMIT 50
loop each row
Sched->>DB: UPDATE fee_splits SET released=TRUE WHERE id=? AND released=FALSE AND held_until<NOW()
Sched->>S: stripe.transfers.create (destination=vendor.stripe_connect_account_id)
alt Stripe fails
Sched->>DB: UPDATE fee_splits SET released=FALSE
end
endDispute hold mirrors the HeldBurn pattern in dom-token: held_until = created_at + MARKETPLACE_HOLD_DAYS (default 30). A POST /api/v1/payouts/release call is the manual equivalent of refund_held_burn(); the hourly scheduler is the equivalent of finalize_expired_burns().
Fee split formula (banker's rounding, platform_fee + vendor_amount + stripe_fee === total exactly):
stripe_fee = bankersRound(total * stripeFeeRate + stripeFixedCents)
platform_fee = bankersRound((total - stripe_fee) * platformFeeRate)
vendor_amount = total - stripe_fee - platform_feeDefaults: PLATFORM_FEE_RATE=0.05, STRIPE_FEE_RATE=0.029, STRIPE_FEE_FIXED_CENTS=30. vendor_amount absorbs the rounding remainder so vendors see a penny-accurate payout.
Flow 3 — ICP / DOM Crypto Payment
sequenceDiagram
autonumber
participant Browser
participant OB as oracle-bridge
participant PG as payment-gateway
participant GOV as governance canister
participant XRC as NNS exchange rate
participant LEDGER as DOM / ICP ledger
participant DB as OVH Postgres
Browser->>OB: POST /pay (currency=dom, amount=1000 cents)
OB->>PG: POST /api/v1/payments (Bearer, currency=dom)
PG->>GOV: get_current_dom_price()
GOV-->>PG: usd_price: 0.085
PG->>PG: usdToE8s(10.00 USD, 0.085) → 11_764_705_882n
PG->>PG: deriveSubaccount(uuid) = SHA-256(uuid)
PG->>DB: INSERT pending payment (currency=dom, metadata={quote_e8s, quote_expires_at_ms, receiving_account})
PG-->>OB: 201 { session_id, receiving_account, quote_e8s, expires_at_ms, quoted_price_usd }
OB-->>Browser: render QR + countdown
Browser->>LEDGER: icrc1_transfer(to=receiving_account, amount=quote_e8s)
Browser->>OB: POST /verify
OB->>PG: POST /api/v1/payments/:id/verify
PG->>PG: check quote_expires_at_ms > now()
PG->>LEDGER: icrc1_balance_of(receiving_account)
LEDGER-->>PG: balance = 11_764_705_882n
PG->>GOV: get_current_dom_price() (slippage recheck)
PG->>PG: isWithinSlippageBand(quoted, current, 0.02)
PG->>DB: UPDATE payments SET status='completed', external_id=balance WHERE status='pending'
PG-->>OB: { status: "confirmed", observed_e8s, quote_e8s }Properties enforced by the verifier:
- Quote lock —
quote_e8simmutable after session creation (Invariant #1). - Subaccount uniqueness — SHA-256(UUID), collision-resistant.
- No auto-confirm on zero balance — balance must exceed
quote - transfer_fee. - Expiry before canister call — TTL check precedes the ledger query.
- Idempotent confirmation — already-completed rows return
confirmedwithout re-querying. - Downward fee tolerance only — accept
observed >= quote - transfer_fee; never accept less.
Transfer fee is a fixed 10_000 e8s for both ICP and DOM ICRC-1 ledgers.
Slippage fail-open — if the price oracle is unreachable at the slippage check but funds are already on chain, the verifier confirms rather than rejecting a paid transfer. A WARN is logged so ops can audit these cases. The alternative (rejecting a paid transfer because our oracle is flaky) is customer-hostile.
ICP/DOM smoke test pattern — AI-P7-06 prescribes a dedicated test wallet on staging with a small DOM balance. Smoke tests exercise the full quote → transfer → verify loop at a tiny amount and a deterministic price fixture. See the payment-gateway README for the wallet principal and fixture setup.
Notifications (PLATFORM-007.5 wiring)
On successful terminal state transitions, the gateway fires detached notifications via notification-service. Three template types are supported:
| Template | Triggered by | Recipient data |
|---|---|---|
payment-receipt | Every pending → completed transition (both providers) | payment_id, amount, currency, type, date |
refund-confirmation | Every completed → refunded (refund reaches completed on Stripe side) | refund_id, original_payment_id, amount, reason, processing_days |
membership-confirm | pending → completed AND type = 'membership-dues' | member_name, tier, activated_at, expires_at, optional sbt_token_id |
All notifications are fire-and-forget. fireNotification() calls .catch() on the returned promise — a notification failure logs WARN but never fails the payment (PLATFORM-007.5 AC #5, PLATFORM-002.5 AC #1).
If NOTIFICATION_SERVICE_URL or NOTIFICATION_SERVICE_TOKEN is unset, the factory returns a NoopNotificationClient that logs WARN on every call. Payments still complete.
Branding
The domain field on payments (populated from brand at create time, BL-187) drives receipt branding. helloworlddao.com → DAO templates. founderyos.dev → FOS templates. Legacy rows with null domain (pre-BL-187) fall back to DAO with a WARN log (resolveReceiptBrandDomain()).
Outbound call shape
POST ${NOTIFICATION_SERVICE_URL}/api/v1/send
Authorization: Bearer ${NOTIFICATION_SERVICE_TOKEN}
Content-Type: application/json
{
"type": "payment-receipt",
"to": "user@example.com",
"domain": "helloworlddao.com",
"data": { ... template-specific fields ... }
}Fetch timeout: 5000 ms. Non-2xx responses log WARN and return; never throw.
Database Schema
All migrations live in src/db/migrations/*.sql and run via node-pg-migrate (npm run migrate). Each file is transactional; every DDL statement uses IF NOT EXISTS / IF EXISTS for idempotent re-runs.
Migration 001 — Initial Schema
Creates four tables:
payments— canonical record of every payment attempt.status IN ('pending','completed','failed','refunded')(widened by 005). Integeramountin cents/e8s (no floats).metadata JSONBfor provider-specific context.webhook_events— audit log + dedup.UNIQUE (provider, external_id)is the dedup key.refunds— refund ledger, FK topayments. Statusprocessing|completed|failed.fee_splits— marketplace fee split withheld_until+releasedflag. Partial indexWHERE released = FALSEkeeps the release-sweep query fast.
Migration 002 — Unique Indexes (BL-185)
idx_payments_session_id_uniqueonsession_id WHERE session_id IS NOT NULL— enablesON CONFLICT (session_id) DO NOTHINGretry safety.idx_refunds_one_active_per_paymentonpayment_id WHERE status IN ('processing','completed')— DB-layer complement to the application double-refund guard. Allows retries afterfailedrefunds.
Migration 003 — payments.completed_at (BL-186)
Adds nullable completed_at TIMESTAMPTZ populated atomically when status flips to completed or failed. Partial index WHERE completed_at IS NOT NULL for time-window reconciliation queries. Legacy rows left NULL intentionally.
Migration 004 — Stripe Connect
vendorstable —user_id UNIQUE,stripe_connect_account_id UNIQUE,status IN ('pending','details_submitted','active','restricted'),charges_enabled,payouts_enabled.fee_splits.vendor_idFK →vendors(id)(nullable — webhook backfill permitted).idx_fee_splits_release_sweep ON (held_until) WHERE released = FALSE— hot-path index for the hourly sweep.
Migration 005 — Crypto Status (PLATFORM-007.7)
Widens the payments.status CHECK constraint to include expired, distinct from failed. expired means "quote window closed with no transfer"; failed means provider-reported terminal failure. Keeping them separate lets ops dashboards surface quote-expiry rate independently of provider error rates.
Environment Variables
Secrets live in the payment-gateway-secrets k8s Secret. Env var names only below — never commit values.
Core
| Env Var | Default | Notes |
|---|---|---|
PORT | 3200 | Service listen port |
LOG_LEVEL | info | pino log level |
NODE_ENV | development | test skips Stripe env assertion at boot |
SERVICE_TOKEN | — | required. Bearer token — fails closed if unset. |
DATABASE_URL | — | OVH Managed Postgres connection string |
GIT_SHA | — | Baked by Docker build; falls back to npm_package_version |
Stripe
| Env Var | Default | Notes |
|---|---|---|
STRIPE_SECRET_KEY | — | sk_test_... or sk_live_... — asserted at boot |
STRIPE_WEBHOOK_SECRET | — | whsec_... for standard webhook endpoint |
STRIPE_CONNECT_WEBHOOK_SECRET | — | Separate whsec_... for Connect webhook endpoint |
PLATFORM_FEE_RATE | 0.05 | Platform cut of (total - stripe_fee) |
STRIPE_FEE_RATE | 0.029 | Stripe processing rate (fraction of total) |
STRIPE_FEE_FIXED_CENTS | 30 | Stripe fixed per-transaction fee |
MARKETPLACE_HOLD_DAYS | 30 | Dispute hold window (mirrors dom-token HeldBurn) |
Notifications
| Env Var | Default | Notes |
|---|---|---|
NOTIFICATION_SERVICE_URL | — | e.g. http://notification-service.notification-service-staging.svc.cluster.local:3100 |
NOTIFICATION_SERVICE_TOKEN | — | Matches platform/service-tokens.TOKEN_NOTIFICATION_SERVICE |
When either is unset the gateway boots with a NoopNotificationClient and logs WARN.
ICP / DOM
| Env Var | Default | Notes |
|---|---|---|
IC_HOST | https://icp0.io | Local PocketIC: http://127.0.0.1:4943 |
DOM_TOKEN_CANISTER_ID | — | Staging njo7k-3qaaa-aaaau-aed4a-cai, prod ybuho-zyaaa-aaaal-qwsfq-cai |
GOVERNANCE_CANISTER_ID | — | Staging ayio2-biaaa-aaaas-qeb7a-cai, prod powzt-dyaaa-aaaaj-qrqra-cai |
ICP_EXCHANGE_RATE_CANISTER_ID | uf6dk-hyaaa-aaaaq-qaaaq-cai | NNS XRC; override only for local PocketIC |
ICP_LEDGER_CANISTER_ID | ryjl3-tyaaa-aaaaa-aaaba-cai | NNS ICP ledger; override only for local PocketIC |
ICP_RECEIVING_PRINCIPAL | — | Gateway's receiving principal; subaccounts derived per-payment |
ICP_PRIV_KEY_B64 / PRIV_KEY_B64 | — | Base64 Ed25519 private key; anonymous agent if unset |
CRYPTO_QUOTE_TTL_MS | 600000 | Quote lock window (10 min default) |
CRYPTO_SLIPPAGE_TOLERANCE | 0.02 | ±2% band at confirmation time |
Error Reference
All error responses use the shape { error: "<code>", ...extra }. Stack traces and internal IDs are NEVER included in 5xx response bodies — pg errors can embed DATABASE_URL credentials in messages.
| HTTP Status | Error Code | Endpoint(s) | Cause |
|---|---|---|---|
400 | validation_error | All POST endpoints | zod schema failure; field_errors included |
400 | invalid_id | /payments/:id/* | id empty or > 200 chars |
400 | unknown_payment_type | POST /payments | type not in allowed set |
400 | missing_stripe_signature | Webhooks | Stripe-Signature header absent |
400 | invalid_signature | Webhooks | HMAC verification failed |
401 | unauthorized | Bearer-auth routes | Missing / malformed / wrong bearer token |
404 | not_found | /payouts/release | Fee split id unknown |
404 | payment_not_found | /payments/:id/* | No matching payments row |
409 | already_released | /payouts/release | Fee split already released (idempotent) |
409 | dispute_window_active | /payouts/release | held_until still in future |
409 | vendor_not_active | /payouts/release | Vendor `pending |
500 | internal_error | Any | Unhandled error; logged internally |
500 | stripe_webhook_not_configured | Webhooks | STRIPE_WEBHOOK_SECRET unset |
500 | stripe_connect_webhook_not_configured | Connect webhooks | STRIPE_CONNECT_WEBHOOK_SECRET unset |
500 | stripe_not_configured | Webhooks | STRIPE_SECRET_KEY unset |
500 | vendor_missing | /payouts/release | fee_split has no vendor_id (backfill required) |
500 | vendor_insert_failed | /vendors/onboard | Rare UNIQUE-violation race without recovery |
501 | refund_not_implemented | /payments/:id/refund | ICP/DOM currency (permanent — manual admin refund only) |
502 | stripe_transfer_failed | /payouts/release | Stripe Connect API error (DB flip rolled back) |
502 | stripe_call_failed | /vendors/onboard | Stripe account / link creation failed |
503 | service_unavailable | Any | SERVICE_TOKEN unset (fail-closed) |
503 | price_unavailable | POST /payments (crypto) | Oracle returned no price |
503 | ic_query_failed | POST /payments (crypto) | IcpProvider unconfigured |
Scheduled Jobs
Hourly Payout Sweep
Module: src/services/payout-scheduler.ts. Mirrors finalize_expired_burns() in dom-token.
- Interval:
SWEEP_INTERVAL_MS = 3_600_000(1 hour). - Batch:
RELEASE_BATCH_SIZE = 50rows per tick (well under Stripe's 100 rps/transferslimit). - Query:
SELECT * FROM fee_splits WHERE released = FALSE AND held_until < NOW() ORDER BY held_until ASC LIMIT 50. - Per row: atomic flip
released = TRUEwith SQL-level gate, thenstripe.transfers.createwith idempotency keyfee_split_release:<id>. On Stripe failure, roll back DB flip and continue. - Does not sweep at boot (
runOnStart=falseby default) — avoids a bulk transfer batch on every pod restart. - Disabled in stub mode — if
STRIPE_SECRET_KEYunset, the scheduler logs WARN at boot and never ticks.
Crypto Quote Expiry Sweep
Module: src/jobs/expire-crypto-quotes.ts.
- Interval:
EXPIRE_INTERVAL_MS = 60_000(60 seconds). - Batch:
EXPIRE_BATCH_SIZE = 100. - Purpose: flip abandoned crypto quotes (no verify call after TTL) from
pendingtoexpired. Prevents permanentpendingrow accumulation. - Idempotent:
markPaymentExpiredis scoped toWHERE status = 'pending'— concurrent sweeps never double-flip. - Disabled in stub mode — if
DATABASE_URLunset, skip start.
Both jobs use setInterval().unref() so the event loop can exit cleanly on graceful shutdown.
Observability
Structured Logs
Pino JSON logs on stdout. Log levels match LOG_LEVEL. Secrets (tokens, API keys, DB URLs) NEVER appear in logs — only IDs (payment_id, session_id, external_id, refund_id, fee_split_id, stripe_event_id).
Key log events:
payment.session_created/stripe_connect.session_created/crypto_payment.session_createdpayment.completed/fee_split.created/fee_split.releasedcrypto_payment.confirmed/crypto_payment.expired/crypto_payment.slippage_exceededwebhook.signature_failed/webhook.duplicate/webhook.ignorednotification.sent/notification.non_2xx/notification.send_failedpayout_sweep.complete/expire_crypto_quotes.sweep_complete
Health Metrics
- k8s liveness/readiness:
GET /api/v1/health. - Prometheus metrics: not yet wired (future story).
Related Documentation
- Story source-of-truth:
bmad-artifacts/implementation-artifacts/platform-007-*.md(7 stories) - Epic retro:
bmad-artifacts/implementation-artifacts/epic-platform-007-retro-2026-04-19.md - Platform API gateway: PLATFORM-006 —
architecture/api-gateway.md(path routing + service-token secrets) - Notification service: notification-service (future doc — dual-branded email templates)
- dom-token HeldBurn: dom-token.md (the on-chain analogue to the 30-day fee-split hold)
- Governance price oracle: governance.md (
get_current_dom_price()method used by IcpProvider)
Changelog
Version 0.1.0 (2026-04-19)
Initial PLATFORM-007 epic delivery. All seven stories (.1 through .7) merged and deployed to staging.
Implemented:
- Service scaffold (Express, helmet, bearer auth, lazy singletons)
- Stripe provider: checkout, webhooks, refunds (with double-refund guard + reason mapping)
- Stripe Connect provider: destination charges, vendor onboarding,
account.updatedsync, fee splits with 30-day hold - IcpProvider: quotes, SHA-256 subaccount derivation, verifier with slippage band, expiry sweep job
- Notification wiring (payment-receipt, refund-confirmation, membership-confirm)
- Database schema through migration 005
Known follow-ups (filed as BL-*):
- Stripe Connect
refund()withreverse_transfer: true— deferred pending BL-078.2/3 unminting wallet completion - ICP/DOM direct refund path — currently 501 by design; manual admin flow documented
- Prometheus metrics — not yet wired (future OBS story)
- Multi-replica distributed lock for payout scheduler — single-replica assumption today
Maintainer: Payment Gateway Team Last Updated: 2026-04-19