Skip to content

Checking access...

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) in webhook_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 HeldBurn dispute window.
  • Banker's-rounded fee splits (round-half-to-even) guarantee platform_fee + vendor_amount + stripe_fee === total_cents exactly.
  • 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.updated sync, 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, expired status)

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_transfer admin 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).

http
Authorization: Bearer <TOKEN_PAYMENT_GATEWAY>

Token Source ​

  • k8s Secret: platform/service-tokens key TOKEN_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 ​

PathReason
GET /api/v1/healthk8s liveness/readiness probes cannot forward a service token
POST /api/v1/webhooks/stripeStripe servers sign payloads; signature IS the auth mechanism
POST /api/v1/webhooks/stripe-connectSame — 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 ​

CurrencyProviderNotes
usdstripe or stripe-connectProvider selected by payment type (below). Fiat via Stripe Checkout.
icpIcpProviderICRC-1 transfer to derived subaccount; quote from NNS XRC canister.
domIcpProviderICRC-1 transfer + governance-canister price oracle.

Payment Type → Default Provider Table ​

Payment TypeDefault ProviderUse Case
membership-duesstripeDAO annual membership (USD $25/yr)
marketplace-purchasestripe-connectBuyer → vendor (destination charges + held fee split)
campaign-contributionstripeOtter Camp crowdfunding
saas-billingstripeFounderyOS subscription billing
federation-partner-feestripeFederation / partner cross-payouts
education-coursestripeRabbit Whole course payments
kyc-refundstripeKYC-failure refund flow
marketplace-vendor-payoutstripe-connectVendor-initiated payout (inverse of marketplace-purchase)
treasury-disbursementstripeTreasury-canister-approved USD disbursements
contributor-rewardstripeOff-chain contributor reward payout
icp-dom-directicpDirect 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:

typescript
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):

json
{
  "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 with receiving_account, quote_e8s, and expires_at_ms.

Auth: Bearer service token.

Fiat request body (USD):

typescript
{
  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:

BrandMaps to domain
DAOhelloworlddao.com
FOSfounderyos.dev

A missing or null brand returns 400 { error: "validation_error" }.

Crypto request body (ICP/DOM):

typescript
{
  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):

json
{
  "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:

StatusBodyCause
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):

json
{
  "status": "confirmed" | "pending" | "expired" | "slippage_exceeded",
  "observed_e8s": "117650000",
  "quote_e8s": "117647058"
}

Status values:

StatusMeaningSide effect
confirmedBalance ≥ quote_e8s - transfer_fee AND price within ±2% of quoteRow flipped to completed; external_id set to observed balance
pendingBalance below minimum OR canister query failed transientlyNo DB mutation; caller retries
expiredQuote TTL elapsed before verificationRow flipped to expired
slippage_exceededBalance cleared but current price deviates >2% from quoteRow flipped to expired

Invariant order (hard-enforced in the verifier):

  1. Idempotent fast path: already-completed rows return confirmed without canister query.
  2. Expiry check before any canister call (TOCTOU prevention).
  3. ICRC-1 balance_of on the derived subaccount.
  4. Slippage check against a fresh oracle price (only after balance clears).
  5. Atomic flip to completed with SQL-level gate on status = 'pending'.

Errors:

StatusBodyCause
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):

json
{
  "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):

json
{
  "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):

json
{
  "refunded": true,
  "payment_id": "<uuid>"
}

Response (501 — ICP/DOM currency):

json
{
  "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:

typescript
{
  fee_split_id: string   // UUID (accepts any valid UUID shape, v1–v5)
}

Response (200):

json
{
  "released": true,
  "fee_split_id": "<uuid>",
  "vendor_amount": 9250
}

Flow:

  1. SELECT + 404 if fee split missing.
  2. 409 if already released (idempotent — duplicate calls return the same error rather than silently succeeding).
  3. 409 if held_until is still in the future (dispute window still active).
  4. Atomic UPDATE ... WHERE released = FALSE AND held_until < NOW() — race-safe gate.
  5. Resolve vendor and verify status = 'active' AND payouts_enabled.
  6. stripe.transfers.create({ destination: vendor.stripe_connect_account_id, ... }) with idempotency key fee_split_release:<id>.
  7. If Stripe fails, roll back the DB flip so the row is eligible for the next sweep.

Errors:

StatusBodyCause
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:

typescript
{
  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):

json
{
  "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):

json
{
  "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:

StatusMeaning
pendingAccount created, onboarding link issued, vendor has not yet started
details_submittedVendor completed onboarding form, awaiting Stripe verification
activecharges_enabled = true AND payouts_enabled = true
restrictedStripe 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 typeHandler behaviour
checkout.session.completedFlip payment row pending → completed. Fire payment-receipt email. If type = 'membership-dues', also fire membership-confirm email.
all other typesAudit-logged to webhook_events + 200. Stripe stops retrying.

Invariants:

  1. Signature verification happens before any DB write.
  2. Duplicate events (replay) short-circuit on webhook_events UNIQUE (provider, external_id) and return 200 { received: true, duplicate: true }.
  3. markPaymentCompleted is idempotent via WHERE status = 'pending' — a replayed checkout.session.completed returns null from the UPDATE and does not re-fire notifications.

Responses:

StatusBodyCause
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 typeHandler behaviour
payment_intent.succeededFlip payment row pending → completed. Insert fee_splits row with computed platform_fee / vendor_amount / stripe_fee and held_until = NOW() + 30 days.
account.updatedUpdate vendor row with new status, charges_enabled, payouts_enabled.
all other typesAudit-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) ​

mermaid
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 200 back to Stripe.

Flow 2 — Marketplace Vendor Purchase (Stripe Connect) ​

mermaid
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
    end

Dispute 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_fee

Defaults: 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 ​

mermaid
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:

  1. Quote lock — quote_e8s immutable after session creation (Invariant #1).
  2. Subaccount uniqueness — SHA-256(UUID), collision-resistant.
  3. No auto-confirm on zero balance — balance must exceed quote - transfer_fee.
  4. Expiry before canister call — TTL check precedes the ledger query.
  5. Idempotent confirmation — already-completed rows return confirmed without re-querying.
  6. 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:

TemplateTriggered byRecipient data
payment-receiptEvery pending → completed transition (both providers)payment_id, amount, currency, type, date
refund-confirmationEvery completed → refunded (refund reaches completed on Stripe side)refund_id, original_payment_id, amount, reason, processing_days
membership-confirmpending → 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 ​

http
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). Integer amount in cents/e8s (no floats). metadata JSONB for provider-specific context.
  • webhook_events — audit log + dedup. UNIQUE (provider, external_id) is the dedup key.
  • refunds — refund ledger, FK to payments. Status processing|completed|failed.
  • fee_splits — marketplace fee split with held_until + released flag. Partial index WHERE released = FALSE keeps the release-sweep query fast.

Migration 002 — Unique Indexes (BL-185) ​

  • idx_payments_session_id_unique on session_id WHERE session_id IS NOT NULL — enables ON CONFLICT (session_id) DO NOTHING retry safety.
  • idx_refunds_one_active_per_payment on payment_id WHERE status IN ('processing','completed') — DB-layer complement to the application double-refund guard. Allows retries after failed refunds.

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 ​

  • vendors table — user_id UNIQUE, stripe_connect_account_id UNIQUE, status IN ('pending','details_submitted','active','restricted'), charges_enabled, payouts_enabled.
  • fee_splits.vendor_id FK → 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 VarDefaultNotes
PORT3200Service listen port
LOG_LEVELinfopino log level
NODE_ENVdevelopmenttest 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 VarDefaultNotes
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_RATE0.05Platform cut of (total - stripe_fee)
STRIPE_FEE_RATE0.029Stripe processing rate (fraction of total)
STRIPE_FEE_FIXED_CENTS30Stripe fixed per-transaction fee
MARKETPLACE_HOLD_DAYS30Dispute hold window (mirrors dom-token HeldBurn)

Notifications ​

Env VarDefaultNotes
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 VarDefaultNotes
IC_HOSThttps://icp0.ioLocal 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_IDuf6dk-hyaaa-aaaaq-qaaaq-caiNNS XRC; override only for local PocketIC
ICP_LEDGER_CANISTER_IDryjl3-tyaaa-aaaaa-aaaba-caiNNS 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_MS600000Quote lock window (10 min default)
CRYPTO_SLIPPAGE_TOLERANCE0.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 StatusError CodeEndpoint(s)Cause
400validation_errorAll POST endpointszod schema failure; field_errors included
400invalid_id/payments/:id/*id empty or > 200 chars
400unknown_payment_typePOST /paymentstype not in allowed set
400missing_stripe_signatureWebhooksStripe-Signature header absent
400invalid_signatureWebhooksHMAC verification failed
401unauthorizedBearer-auth routesMissing / malformed / wrong bearer token
404not_found/payouts/releaseFee split id unknown
404payment_not_found/payments/:id/*No matching payments row
409already_released/payouts/releaseFee split already released (idempotent)
409dispute_window_active/payouts/releaseheld_until still in future
409vendor_not_active/payouts/releaseVendor `pending
500internal_errorAnyUnhandled error; logged internally
500stripe_webhook_not_configuredWebhooksSTRIPE_WEBHOOK_SECRET unset
500stripe_connect_webhook_not_configuredConnect webhooksSTRIPE_CONNECT_WEBHOOK_SECRET unset
500stripe_not_configuredWebhooksSTRIPE_SECRET_KEY unset
500vendor_missing/payouts/releasefee_split has no vendor_id (backfill required)
500vendor_insert_failed/vendors/onboardRare UNIQUE-violation race without recovery
501refund_not_implemented/payments/:id/refundICP/DOM currency (permanent — manual admin refund only)
502stripe_transfer_failed/payouts/releaseStripe Connect API error (DB flip rolled back)
502stripe_call_failed/vendors/onboardStripe account / link creation failed
503service_unavailableAnySERVICE_TOKEN unset (fail-closed)
503price_unavailablePOST /payments (crypto)Oracle returned no price
503ic_query_failedPOST /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 = 50 rows per tick (well under Stripe's 100 rps /transfers limit).
  • Query: SELECT * FROM fee_splits WHERE released = FALSE AND held_until < NOW() ORDER BY held_until ASC LIMIT 50.
  • Per row: atomic flip released = TRUE with SQL-level gate, then stripe.transfers.create with idempotency key fee_split_release:<id>. On Stripe failure, roll back DB flip and continue.
  • Does not sweep at boot (runOnStart=false by default) — avoids a bulk transfer batch on every pod restart.
  • Disabled in stub mode — if STRIPE_SECRET_KEY unset, 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 pending to expired. Prevents permanent pending row accumulation.
  • Idempotent: markPaymentExpired is scoped to WHERE status = 'pending' — concurrent sweeps never double-flip.
  • Disabled in stub mode — if DATABASE_URL unset, 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_created
  • payment.completed / fee_split.created / fee_split.released
  • crypto_payment.confirmed / crypto_payment.expired / crypto_payment.slippage_exceeded
  • webhook.signature_failed / webhook.duplicate / webhook.ignored
  • notification.sent / notification.non_2xx / notification.send_failed
  • payout_sweep.complete / expire_crypto_quotes.sweep_complete

Health Metrics ​

  • k8s liveness/readiness: GET /api/v1/health.
  • Prometheus metrics: not yet wired (future story).

  • 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.updated sync, 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() with reverse_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

Hello World DAO