CryptoGateway — Merchant API

Merchant API

Putting an existing storefront onto this gateway? Read the Storefront Integration Playbook first — it is the order of work, the checkout behaviour and the verification checklist. This page is the API reference it refers to.

This API lets a store create payment orders, show the customer how to pay, poll payment status, and receive signed webhooks — across seven rails through mostly the same endpoints:

RailWhat it isWhose provider accountWho holds the moneyOne-timeSubscriptions
Cryptoon-chain deposit address, unique per orderthe platform's walletsthe platform (custodial — see below)yesno
PayPalcard / Apple Pay / Google Pay / Trustlythe platform'srouted to your PayPal at captureyesno
Stripecard / Apple Pay / Google Payyoursyouyesno
Lemon Squeezyhosted checkoutyoursyou (Lemon Squeezy is Merchant of Record)noyes
FastSpringhosted checkoutthe platform'sthe platform (FastSpring is Merchant of Record)yesyes
Whophosted checkout, priced per order by youyoursyou (Whop is Merchant of Record)yesnot yet
Manual Transferyour own bank / cash instructions, confirmed by younoneyouyesno

Crypto, PayPal, Stripe and Manual Transfer orders all go through the same endpoint, POST /v1/payment-orders, using one merchant API key — only the method field changes. Lemon Squeezy and FastSpring are different in kind (they hand the buyer to a page hosted by that provider) and have their own checkout endpoints — see Subscription & Hosted-Checkout Rails.

No new API keys are needed for PayPal or Stripe. PayPal orders are processed with the platform's own PayPal Business account — you never provide PayPal credentials, only a payout email (see PayPal Rail). Stripe is the opposite: you provide your own Stripe account credentials, so charges settle directly to you (see Stripe Rail).

How crypto custody works

Crypto is custodial: the platform generates every deposit address from its own keys, and the funds a buyer sends are never left sitting on that address. The flow for each order is:

  1. A unique deposit address is derived for the order (never reused).
  2. The buyer's transfer is observed on-chain → payment.deposit.matched.
  3. Once it has enough confirmations, the funds are swept from the deposit address into the platform's hot wallet, and the order becomes PAID → payment.paid. Sweeping is what finalises the order, so "paid" always means "the money has actually moved and is under the platform's control", not merely "we saw a transaction".
  4. Hot-wallet balances are periodically swept onward to the platform's cold wallet for storage.

What the platform owes you is tracked per merchant, per asset, in a double-entry ledger from the moment a deposit is observed. Ask your platform operator how and when settlement to you is made — the schedule and rails for that are a commercial arrangement, not part of this API.

Base URL:

https://innestovarco.space

Every example in this document uses that base URL — it is the origin this page is being served from.

Getting Started

  1. Register: POST /v1/merchants/register — no admin action needed. Your merchant record starts in PENDING, and the response includes a TEST API key immediately — start integrating right away.
  2. Integrate with the TEST key: it authenticates every endpoint in this doc while you're still PENDING. Use crypto testnets and PAYPAL_ENV=sandbox behavior on this key — see Authentication for what TEST vs LIVE actually gates.
  3. Wait for admin approval: an operator reviews your submission in the admin dashboard and calls approve or reject. There's no fixed SLA in this codebase today — check with your platform operator for turnaround expectations.
  4. Approved → LIVE credentials: once ACTIVE, an operator issues you a LIVE API key (POST /v1/merchants/{merchant_id}/api-keys, admin-only — ask your operator). Both TEST and LIVE keys keep working after approval; use LIVE for real transactions.
  5. Rejected: your merchant record moves to REJECTED with a reason, and your TEST key stops authenticating immediately. Contact your platform operator.
POST /v1/merchants/register
Content-Type: application/json

{
  "name": "Example Store",
  "contact_email": "payments@example.com",
  "requested_rails": ["CRYPTO", "CARD", "TRUSTLY"],
  "webhook_url": "https://example.com/webhooks/gateway"
}

requested_rails is informational only — it does not itself enable anything; it's what the admin approval queue sees. name and contact_email are required. Response:

{
  "merchant": { "id": "...", "name": "Example Store", "status": "PENDING", "webhook_url": "...", "created_at": "..." },
  "test_api_key": { "api_key": "8f2a1b3c_...", "key_prefix": "8f2a1b3c", "merchant_id": "..." }
}

Authentication

Merchant endpoints require a bearer API key:

Authorization: Bearer <api_key>

API keys are issued by the platform and returned once. Store the key securely. Never expose it in browser JavaScript.

Every key is either TEST or LIVE (GET /v1/api-keys shows which). This is enforced at the authentication layer itself, not by convention:

TEST keys are issued automatically at self-registration. LIVE keys are issued by an admin (POST /v1/merchants/{merchant_id}/api-keys), normally right after approval.

Operators must configure a stable API_KEY_PEPPER. Changing it invalidates newly issued API keys that use keyed hashes. Operators must also configure ADMIN_API_TOKEN. This token is required for control-plane admin routes (approve/reject merchants, issue LIVE API keys, suspend, etc.). Do not share it with merchants. Operators must configure WEBHOOK_SECRET_ENCRYPTION_KEY as a stable 32-byte hex key. Changing it prevents decrypting existing encrypted webhook secrets.

Public (no auth) endpoint:

POST  /v1/merchants/register

Current authenticated (merchant API key) endpoints:

GET   /v1/merchant
PATCH /v1/merchant/webhook
GET   /v1/api-keys
POST  /v1/api-keys/{key_id}/revoke
POST  /v1/merchant/api-keys/live
GET   /v1/assets
GET   /v1/merchant/methods
POST  /v1/payment-orders
GET   /v1/payment-orders
GET   /v1/payment-orders/{id}
GET   /v1/payment-orders/{id}/transitions
POST  /v1/payment-orders/{id}/cancel
POST  /v1/merchant/paypal/payee
GET   /v1/merchant/paypal/payee
GET   /internal/paypal/session-token
POST  /v1/payment-orders/{id}/paypal/capture
POST  /v1/merchant/stripe/credentials
GET   /v1/merchant/stripe/credentials
POST  /v1/merchant/lemonsqueezy/credentials
GET   /v1/merchant/lemonsqueezy/credentials
POST  /v1/merchant/fastspring/enroll
GET   /v1/merchant/fastspring/enroll
GET   /v1/subscriptions
POST  /v1/merchant/set-password

There is no POST /v1/merchant/paypal/connect / OAuth flow — that design was superseded before it shipped. PayPal routing is a single stored email (PayPal Rail), not a connected account.

Dashboard-session endpoints (Authorization: Bearer <session_token>, from POST /v1/merchant/session/login — see Dashboard Login):

POST  /v1/merchant/session/logout
POST  /v1/merchant/session/2fa/setup
POST  /v1/merchant/session/2fa/verify
POST  /v1/merchant/session/forgot-password
POST  /v1/merchant/session/reset-password
GET   /v1/merchant/session/overview
GET   /v1/merchant/session/methods
PATCH /v1/merchant/session/methods/{method}

GET /v1/payment-orders, GET /v1/payment-orders/{id} and the credential endpoints above also accept a dashboard session, so the merchant dashboard web UI can call them without holding an API key in the browser.

Admin-only endpoints (Authorization: Bearer <ADMIN_API_TOKEN>, or a super_admin dashboard session for the ones noted):

GET   /admin/api/overview
POST  /v1/merchants
POST  /v1/merchants/{merchant_id}/api-keys
GET   /v1/admin/merchants
PATCH /v1/admin/merchants/{merchant_id}/status
POST  /v1/admin/merchants/{merchant_id}/approve
POST  /v1/admin/merchants/{merchant_id}/reject
GET   /v1/admin/merchants/{merchant_id}/approval-history
GET   /v1/admin/merchants/{merchant_id}/api-keys
POST  /v1/admin/api-keys/{key_id}/revoke
GET   /admin/api/transactions
GET   /admin/api/transactions/{id}
POST  /v1/admin/merchants/{merchant_id}/paypal/approve   (super_admin)
POST  /v1/admin/merchants/{merchant_id}/paypal/reject    (super_admin)
GET   /v1/admin/paypal/payees
POST  /v1/admin/merchants/{merchant_id}/stripe/activate
POST  /v1/admin/merchants/{merchant_id}/stripe/deactivate
GET   /v1/admin/stripe/credentials
POST  /v1/admin/merchants/{merchant_id}/lemonsqueezy/activate
POST  /v1/admin/merchants/{merchant_id}/lemonsqueezy/deactivate
GET   /v1/admin/lemonsqueezy/stores
POST  /v1/admin/merchants/{merchant_id}/fastspring/activate
POST  /v1/admin/merchants/{merchant_id}/fastspring/deactivate
GET   /v1/admin/fastspring/stores

Suspending or closing a merchant (PATCH /v1/admin/merchants/{merchant_id}/status) immediately blocks authentication for all of that merchant's API keys — the lookup in AuthenticatedMerchant requires merchants.status = 'ACTIVE'. It does not itself revoke the keys; use the API key endpoints for a separate, auditable revocation.

Sandbox-only endpoint:

POST /sandbox/payment-orders/{id}/simulate-payment

This route is disabled by default. It exists only for local checkout testing and requires both:

Admin dashboard:

Admin Merchant Management

GET /v1/admin/merchants?status=PENDING&limit=20&offset=0
Authorization: Bearer <ADMIN_API_TOKEN>

Lists merchants newest-first with per-merchant active_api_keys and open_payment_orders counts, for support/ops triage. status filter accepts any of PENDING, ACTIVE, REJECTED, SUSPENDED, CLOSED — ?status=PENDING is the approval queue. Pagination works the same as order listing.

POST /v1/admin/merchants/{merchant_id}/approve
Authorization: Bearer <ADMIN_API_TOKEN>

PENDING → ACTIVE. Only valid from PENDING — 409 Conflict otherwise (including calling it twice). Does not itself issue a LIVE API key; call POST /v1/merchants/{merchant_id}/api-keys next.

POST /v1/admin/merchants/{merchant_id}/reject
Authorization: Bearer <ADMIN_API_TOKEN>
Content-Type: application/json

{ "reason": "Incomplete business verification" }

PENDING → REJECTED. reason is required and stored. Only valid from PENDING — 409 Conflict otherwise. The merchant's TEST key stops authenticating immediately; no LIVE key is ever issued.

GET /v1/admin/merchants/{merchant_id}/approval-history
Authorization: Bearer <ADMIN_API_TOKEN>

Full audit trail: SUBMITTED (at registration), APPROVED, REJECTED, SUSPENDED, REACTIVATED — each with actor, reason (when set), and created_at.

PATCH /v1/admin/merchants/{merchant_id}/status
Authorization: Bearer <ADMIN_API_TOKEN>
Content-Type: application/json

{ "status": "SUSPENDED" }

status must be ACTIVE, SUSPENDED, or CLOSED — deliberately excludes PENDING/REJECTED, which only happen through the audited approve/reject endpoints above. Suspending/closing a merchant blocks authentication for all of that merchant's API keys (TEST and LIVE) on the very next request — see the authentication note above. Transitions through this endpoint are also recorded in the approval history as SUSPENDED/REACTIVATED.

GET /v1/admin/merchants/{merchant_id}/api-keys
Authorization: Bearer <ADMIN_API_TOKEN>

Lists every API key for one merchant, for audit purposes. 404 if the merchant does not exist.

POST /v1/admin/api-keys/{key_id}/revoke
Authorization: Bearer <ADMIN_API_TOKEN>

Revokes any merchant's API key, independent of that merchant's own self-service. Use this for incident response (a leaked key) or offboarding. 409 if the key is already revoked.

GET /admin/api/transactions?rail=PAYPAL&method=CARD&status=PAID&merchant_id=...&order_id=...&limit=50&offset=0
Authorization: Bearer <ADMIN_API_TOKEN>

Unified transaction list across both rails — every payment_orders row, filterable/searchable by rail, method, order_id, merchant_id, and status. Not a separate PayPal page: crypto and PayPal orders appear in the same table, distinguished by the rail column.

GET /admin/api/transactions/{id}
Authorization: Bearer <ADMIN_API_TOKEN>

Full detail for one order: identifiers (including PayPal order_id/capture correlation ID when applicable), the complete status timeline (from order_state_transitions), and a redacted raw provider request/response log with plain-language error summaries (from payment_provider_events). Visible in /admin/dashboard under Transactions. 404 if the order does not exist.

Access control note: both endpoints reuse the single shared ADMIN_API_TOKEN role — there is no per-admin-user or per-permission-tier auth in this codebase. Restricting raw-log access to a subset of operators would require a real multi-admin auth model, which does not exist yet.

Rate limiting:

Amounts

All amounts are strings in atomic units. Do not send floats.

Examples:

ETH 1.0       = "1000000000000000000" wei
USDC 25.00    = "25000000" with 6 decimals
USDT 12.3456  = "12345600" with 6 decimals

Use GET /v1/assets to read the asset decimals.

For PayPal-rail orders, expected_amount_atomic is still an integer minor-unit string — no floats on either rail — but interpreted as the currency's minor unit (e.g. cents): "4999" for $49.99 USD. Limited to 2-decimal currencies today (USD, EUR, GBP, ...); 0/3-decimal currencies (JPY, BHD) are not yet supported.

Payment Method Icons

You do not need to source, host, or maintain logos for the methods you accept — ask this gateway for them. The endpoints are public (they are embedded in your storefront), unauthenticated, and cached for an hour.

GET https://innestovarco.space/method-assets/for/{method}.svg   # by method key or asset id
GET https://innestovarco.space/method-assets/{key}.svg          # by icon key
GET https://innestovarco.space/method-assets/manifest.json      # everything available

The for/ form accepts any identifier this API already uses, so nothing needs normalising on your side:

<img src="https://innestovarco.space/method-assets/for/eth-mainnet:usdc.svg" width="20" height="20" alt="">
<img src="https://innestovarco.space/method-assets/for/STRIPE_CARD.svg"      width="20" height="20" alt="">
<img src="https://innestovarco.space/method-assets/for/MANUAL_TRANSFER.svg"  width="20" height="20" alt="">

It resolves crypto symbols (BTC), full asset ids (tron-mainnet:usdt), native-coin ids (bitcoin-mainnet:native → Bitcoin), and every method key (CARD, STRIPE_CARD, APPLE_PAY, TRUSTLY, WHOP, FASTSPRING…). A method it does not recognise returns a neutral payment glyph rather than 404, so a new rail never renders as a broken image in your checkout.

Every icon is a self-contained SVG: no external references, no scripts, so it renders under a strict Content-Security-Policy.

Licensing — and two you must replace

manifest.json marks each icon's provenance:

provenanceMeaning
cc0Public domain (the cryptocurrency-icons set). Use freely.
originalDrawn for this platform. Yours to use as part of this integration.
placeholderNot the real mark — see below.

apple-pay and google-pay are deliberately neutral glyphs, not the official marks. Apple and Google both require their wallet marks to be reproduced from artwork they supply and forbid redrawing them, so shipping a copy would put you out of compliance rather than help you. If you display those wallets, download the official assets (Apple, Google) and serve them from your own site, or ask your platform operator to drop them into the gateway's assets/methods/ directory under the same filenames — no code change is needed and the endpoints then serve the real marks.

The same applies to card-network acceptance marks (Visa, Mastercard): obtain them from the network's brand portal. The bundled card icon is a generic card glyph, safe to use anywhere.

Operator overrides

Your platform operator can replace any icon, and set the title, subtitle and consent text for any method, without a release. When they do, the change reaches you automatically:

subtitle and consent_text are absent unless set, so treat them as optional. If consent_text is present, show it to the buyer before they confirm payment with that method — it carries wording the operator (or the method's provider) requires, such as an irreversibility notice for crypto or mandate wording for a bank debit. It is plain text: escape it, never render it as HTML.

{
  "method": "bitcoin-mainnet:native",
  "rail": "CRYPTO",
  "label": "Bitcoin",
  "subtitle": "Network fees apply. Confirmation takes ~20 minutes.",
  "consent_text": "Crypto payments cannot be reversed once broadcast.",
  "enabled": true,
  "connected": true
}

List Assets

Returns enabled assets a merchant can offer at checkout.

GET /v1/assets
Authorization: Bearer <api_key>

Optional filter:

GET /v1/assets?network=eth-mainnet

Response:

[
  {
    "network": "eth-mainnet",
    "chain_id": "1",
    "asset_id": "eth-mainnet:usdc",
    "symbol": "USDC",
    "name": "USD Coin",
    "decimals": 6,
    "asset_type": "ERC20",
    "token_contract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
    "deposit_available": true,
    "available_deposit_addresses": 0
  },
  {
    "network": "eth-mainnet",
    "chain_id": "1",
    "asset_id": "eth-mainnet:native",
    "symbol": "ETH",
    "name": "Ether",
    "decimals": 18,
    "asset_type": "NATIVE",
    "token_contract": null,
    "deposit_available": true,
    "available_deposit_addresses": 12
  }
]

Checkout guidance:

Create Payment Order

Creates a crypto invoice and allocates a gateway-controlled deposit address.

POST /v1/payment-orders
Authorization: Bearer <api_key>
Content-Type: application/json

Request:

{
  "idempotency_key": "cart_123_checkout_1",
  "external_reference": "cart_123",
  "network": "eth-mainnet",
  "asset_id": "eth-mainnet:usdc",
  "expected_amount_atomic": "25000000",
  "expires_at": "2026-07-06T20:00:00Z"
}

Fields:

FieldRequiredMeaning
idempotency_keyyesStore-generated key. Reusing it returns the same order.
external_referenceyesStore order/cart/invoice reference.
networkcrypto onlyNetwork from GET /v1/assets. Omit for PayPal-rail orders.
asset_idcrypto onlyAsset from GET /v1/assets. Omit for PayPal-rail orders.
methodnoOmit (or use a crypto asset_id) for the crypto rail. "CARD", "APPLE_PAY", "GOOGLE_PAY", or "TRUSTLY" for PayPal. "STRIPE_CARD", "STRIPE_APPLE_PAY", or "STRIPE_GOOGLE_PAY" for Stripe.
currencyPayPal/Stripe onlyISO 4217 currency code (e.g. "USD"), required when method names a PayPal or Stripe method.
expected_amount_atomicyesExact amount in atomic units — see Amounts.
expires_atnoRFC3339 timestamp. Defaults to 24 hours from creation.

Do not send deposit_destination or derivation_reference. The gateway allocates those.

For a PayPal or Stripe method, the response additionally includes a checkout_payload — see PayPal Rail or Stripe Rail. It also carries hosted_checkout_path and gateway_order_id on every rail that has one (PayPal and Stripe today) — see Hosted Checkout for why you would use it instead of mounting fields on your own page. On the PayPal rail, checkout_payload.order_id is PayPal's order id (what the PayPal JS SDK needs), not the gateway's — the gateway's own id is the top-level id and is repeated as gateway_order_id for convenience.

Success response:

{
  "id": "2f8f6a8b-8db5-4ff1-8a9c-8b67a6c38fd1",
  "merchant_id": "3abf4e49-4cd2-4be9-a0ef-7d201bbad890",
  "external_reference": "cart_123",
  "idempotency_key": "cart_123_checkout_1",
  "network": "eth-mainnet",
  "asset_id": "eth-mainnet:usdc",
  "expected_amount_atomic": "25000000",
  "received_amount_atomic": "0",
  "remaining_amount_atomic": "25000000",
  "overpaid_amount_atomic": "0",
  "deposit_destination": "0x1111111111111111111111111111111111111111",
  "status": "AWAITING_PAYMENT",
  "expires_at": "2026-07-06T20:00:00Z",
  "created_at": "2026-07-06T19:00:00Z",
  "updated_at": "2026-07-06T19:00:00Z"
}

HTTP status:

201 Created  new payment order
200 OK       existing order returned for same idempotency key
400          invalid amount, disabled asset/method, no available deposit
             address, or missing rail-specific required field (currency,
             Trustly fields, etc.)
401          missing/invalid API key
502          provider (PayPal/Stripe) reached but rejected the request
503          provider unreachable, not configured, or (Stripe) your
             credentials are not yet operator-activated

400 is also what you get for using a method you have not connected or have switched off — the error message names the exact toggle endpoint to fix it, e.g. "STRIPE_CARD is disabled for this merchant — re-enable it at PATCH /v1/merchant/session/methods/STRIPE_CARD". See Payment Methods.

Get Payment Order

Polls the payment order status.

GET /v1/payment-orders/{id}
Authorization: Bearer <api_key>

Response shape is the same as create order.

Store guidance:

List Payment Orders

GET /v1/payment-orders?status=AWAITING_PAYMENT&network=eth-mainnet&limit=20&offset=0
Authorization: Bearer <api_key>

All query parameters are optional. status must be one of the values in Order Statuses below or the request is rejected with 400. limit defaults to 20 and is capped at 100; offset defaults to 0.

external_reference filters on your own reference from order creation, as an exact match:

GET /v1/payment-orders?external_reference=cart_123
Authorization: Bearer <api_key>

This is the supported way to find an order from your own records, and it matters most on the hosted-checkout rails (Lemon Squeezy, FastSpring): those return no order id at checkout time, because the order does not exist until the provider's webhook creates it. Expect zero results until then — an empty list means "not paid yet", not "lost".

It returns a list rather than a single order because nothing stops you reusing a reference across orders. If yours are unique, read the first element.

Response:

{
  "orders": [ { "...": "same shape as Get Payment Order" } ],
  "total": 42,
  "limit": 20,
  "offset": 0
}

Get Order Transition History

Returns the full, ordered audit trail of state transitions for one order — useful for support/dispute investigation.

GET /v1/payment-orders/{id}/transitions
Authorization: Bearer <api_key>
[
  {
    "previous_status": "AWAITING_PAYMENT",
    "new_status": "PAYMENT_OBSERVED",
    "triggering_event": "chain_event_matched",
    "actor": "payment-core",
    "correlation_id": "b6b1...",
    "reason_code": null,
    "created_at": "2026-07-06T19:05:00Z"
  }
]

404 if the order does not exist or does not belong to the authenticated merchant.

Cancel Payment Order

POST /v1/payment-orders/{id}/cancel
Authorization: Bearer <api_key>

Cancellable only from a pre-paid/pre-terminal state: CREATED, AWAITING_PAYMENT, PARTIALLY_PAID, PAYMENT_OBSERVED, CONFIRMING, MANUAL_REVIEW, or REORG_REVIEW. Attempting to cancel a PAID, OVERPAID, EXPIRED, REFUNDED, or already-CANCELLED order returns 409 Conflict.

If no funds were ever received, the allocated deposit address is released back to the available pool, mirroring expiry behavior. A payment.cancelled webhook is delivered.

Dashboard Login

A merchant dashboard exists at /merchant/dashboard — status, PayPal payout, Stripe/Lemon Squeezy/FastSpring connections, payment-method toggles, and recent orders. It uses a separate credential from your API key: an email + password (+ optional TOTP 2FA) login, scoped to account-management actions only. A stolen dashboard session cannot create or capture a payment — that still requires an API key.

POST /v1/merchant/set-password
Authorization: Bearer <api_key>
Content-Type: application/json

{ "password": "at least 10 characters" }

Bootstraps or resets your dashboard password using your API key as proof of identity (works with a TEST or LIVE key, at any approval status). You can also set a password at registration — see password in Getting Started.

POST /v1/merchant/session/login
Content-Type: application/json

{ "email": "you@example.com", "password": "...", "totp_code": "123456" }

totp_code is only required once you've enabled 2FA. Wrong email, wrong password, and missing/wrong 2FA code all return the same generic 401 — the response never discloses which check failed. Returns { "session_token": "...", "expires_at": "...", "merchant_id": "...", "status": "..." }. Sessions last 24 hours.

POST /v1/merchant/session/logout                  — revoke the current session
POST /v1/merchant/session/2fa/setup                — generate a pending TOTP secret + otpauth:// URL for your authenticator app
POST /v1/merchant/session/2fa/verify  { "code": "123456" }  — confirm the code, enable 2FA
GET  /v1/merchant/session/overview                 — status, PayPal payout, order counts, recent orders
GET  /v1/merchant/session/methods                  — every method across every rail, with your toggle state
PATCH /v1/merchant/session/methods/{method} { "enabled": true|false }  — toggle one method

All of the above (except login) require Authorization: Bearer <session_token>.

Payment Methods

GET /v1/merchant/methods (API key) and the equivalent GET /v1/merchant/session/methods (dashboard session) return every method on every rail as one flat list — not just the ones you have set up — so your integration (or the dashboard) can always show what exists, not just what is already connected:

[
  { "method": "eth-mainnet:usdc", "rail": "CRYPTO", "label": "USDC (eth-mainnet)", "enabled": true, "connected": true },
  { "method": "CARD", "rail": "PAYPAL", "label": "Card (PayPal Card Fields)", "enabled": true, "connected": true },
  { "method": "STRIPE_CARD", "rail": "STRIPE", "label": "Card (Stripe)", "enabled": true, "connected": false, "setup_panel": "stripe" },
  { "method": "LEMONSQUEEZY", "rail": "SUBSCRIPTION", "label": "Subscriptions (Lemon Squeezy)", "enabled": true, "connected": false, "setup_panel": "subscriptions" }
]

Both fields are informational only — the real gate is server-side. Creating an order with a connected: false or enabled: false method returns 400, regardless of what your integration does with these flags. Use them to build your own checkout UI (show only what is both enabled and connected), not as a substitute for handling that 400.

Checkout config — mount every method before any order exists

GET /v1/merchant/checkout-config (API key) returns, in one call, the usable methods (already filtered to enabled && connected) plus the browser-side bootstrap for each rail, so a storefront can draw every payment method the moment the checkout opens and create the order only when the buyer submits — one order per cart, never one per method:

{
  "methods": [
    { "method": "CARD",        "rail": "PAYPAL", "label": "Card (PayPal Card Fields)" },
    { "method": "STRIPE_CARD", "rail": "STRIPE", "label": "Card (Stripe)" },
    { "method": "eth-sepolia:native", "rail": "CRYPTO", "label": "ETH (eth-sepolia)" }
  ],
  "stripe": { "publishable_key": "pk_test_…", "environment": "test" },
  "paypal": { "client_id": "…", "environment": "sandbox", "platform_merchant_id": "…" },
  "brand_name": "InnestoVarco"
}

brand_name is what the buyer sees as the payee in a Google Pay / Apple Pay sheet. Pass it to the widget as totalLabel. It is the platform's name — the platform is the merchant of record — so do not substitute your store's name there.

stripe / paypal are omitted when that rail has no usable method. Crypto methods appear only while their chain can actually issue a deposit address, so a coin whose signer is down is left out rather than offered and then refused at the last step. Nothing in the response is secret — publishable keys and client ids are built to ship to browsers.

Pass them to gateway.js with createOrder instead of an order id:

const handle = CryptoGatewayCheckout.mount('#stripe-card', {
  method: 'STRIPE_CARD',
  publishableKey: config.stripe.publishable_key,
  amountMinor: 1199, currency: 'USD',
  totalLabel: config.brand_name,
  showButton: false,                       // you own the Pay button
  createOrder: () => fetch('/my-server/orders', { method: 'POST', … })
                       .then(r => r.json()).then(o => o.checkout_payload),
  onReady: () => enablePayButton(),
  onValidity: ({ complete }) => setPayEnabled(complete),
  onApproved: (gatewayOrderId) => verifyThenRedirect(gatewayOrderId),
  onError: (err) => showInline(err.message),
});
// Your Pay button:  handle.submit()

createOrder runs once per mount, at submit; the widget confirms against that same order on any retry. With showButton: false every method, Google Pay and Apple Pay included, is driven by handle.submit() from your one button — the wallet sheet opens inside that click. Pass onUnavailable: (err) => hideOption() to drop a wallet this browser cannot offer. The original contract (pass checkout_payload from an already-created order) keeps working unchanged.

PayPal Rail

Status: structurally complete, not yet verified against a live PayPal sandbox. This adapter is written directly against PayPal's documented Orders v2 / Webhooks REST contracts, but this build has not had access to real PAYPAL_CLIENT_ID/PAYPAL_CLIENT_SECRET to exercise it end-to-end. Treat error-code mappings and exact response shapes as best-effort until validated against a real sandbox account.

Single-account, payee-routing model. The platform has one standard PayPal Business account with Advanced Checkout approved — nothing more, no Partner/Platform status. Every PayPal API call (create order, capture) authenticates with the platform's own Client ID/Secret. There is no merchant-side PayPal login, OAuth, or onboarding flow with PayPal at all. Funds are routed to a merchant by setting PayPal's payee.email_address on the order to that merchant's own stored, operator-validated email — their PayPal Business account must also have Advanced Checkout approved on their side for payee routing to work. Card, Apple Pay, Google Pay, and Trustly all go through the same POST /v1/payment-orders endpoint your crypto integration already calls — set method and currency instead of network/asset_id.

Set your PayPal payout email (one-time, per merchant)

POST /v1/merchant/paypal/payee
Authorization: Bearer <api_key>
Content-Type: application/json

{ "email": "payments@yourbusiness.com" }

Pure local write — no PayPal API call happens here. Always resets to PENDING_VALIDATION. An operator confirms the email belongs to an approved Advanced Checkout Business account (a manual review, or a test transaction) before PayPal-rail orders can be created for you. Check status anytime:

GET /v1/merchant/paypal/payee
Authorization: Bearer <api_key>
{ "email": "payments@yourbusiness.com", "status": "PENDING_VALIDATION", "last_error": null, "validated_at": null }

status is one of NOT_SET, PENDING_VALIDATION, ACTIVE, FAILED, SUSPENDED. Orders on the PayPal rail are rejected with 400 until this is ACTIVE — the gateway makes this check before ever calling PayPal's API. If a capture later fails, the platform automatically flags your payee back to FAILED (see Capture below) and you'll need a fresh approval.

Create a PayPal-rail order

{
  "idempotency_key": "cart_123_checkout_1",
  "external_reference": "cart_123",
  "method": "CARD",
  "currency": "USD",
  "expected_amount_atomic": "4999"
}

method is one of CARD, APPLE_PAY, GOOGLE_PAY, TRUSTLY. Response adds checkout_payload, shaped per method:

// CARD / APPLE_PAY / GOOGLE_PAY
{ "checkout_payload": { "order_id": "5O190127TN364715T" } }

// TRUSTLY (bank redirect)
{ "checkout_payload": { "order_id": "5O190127TN364715T", "redirect_url": "https://www.paypal.com/..." } }

checkout_payload is only present on the creation response — it is not persisted, so GET/list responses omit it. Capture it at creation time.

Client-side integration (loading the PayPal JS SDK, mounting Card Fields, handling Apple Pay/Google Pay buttons) happens in your own storefront frontend — this backend does not ship a merchant-facing checkout widget. Use GET /internal/paypal/session-token for the client_id/environment your frontend needs to initialize the PayPal JS SDK.

Capture

POST /v1/payment-orders/{id}/paypal/capture
Authorization: Bearer <api_key>

Call this after your client-side flow confirms the buyer's payment (Card Fields' cardSession.submit, or an Apple Pay/Google Pay onApprove). Transitions the order to PAID on success, MANUAL_REVIEW if PayPal's capture response isn't a clean COMPLETED. 409 if the order isn't in a capturable state; 503 if PayPal itself is unreachable.

Fail-closed on capture failure. If the capture call itself fails — which can mean the payee email is no longer valid (restricted, no longer Advanced-Checkout-approved, or a personal account) — the order fails with a clear 503 error, is never silently retried, and never falls back to receiving funds in the platform's own account. Your merchant_paypal_payee status is automatically downgraded from ACTIVE to FAILED at the same time, blocking further PayPal-rail orders until an operator re-approves it (POST /v1/admin/merchants/{id}/paypal/approve). The same downgrade happens if PayPal later reports a denial via webhook (PAYMENT.CAPTURE.DENIED).

Admin: validate a merchant's payee email

POST /v1/admin/merchants/{merchant_id}/paypal/approve
Authorization: Bearer <ADMIN_API_TOKEN>

Marks the merchant's payee ACTIVE — required before their PayPal-rail orders will work. 400 if the merchant has never set an email. Restricted to super_admin (see Dashboard Login) since this directly controls where real funds get routed.

POST /v1/admin/merchants/{merchant_id}/paypal/reject
Authorization: Bearer <ADMIN_API_TOKEN>
Content-Type: application/json

{ "reason": "email is a personal account, not Advanced Checkout" }

Marks it FAILED with a stored reason, visible to the merchant via GET /v1/merchant/paypal/payee.

Error codes

PayPal decline/error reasons are mapped to plain-language summaries, visible in the admin transaction detail view (not yet in the merchant API response body):

PayPal reasonMeaning
INSTRUMENT_DECLINEDCard was declined by the issuing bank.
PAYER_ACTION_REQUIRED3-D Secure authentication was not completed.
TRANSACTION_REFUSEDTransaction refused by PayPal risk controls.
INSUFFICIENT_FUNDSPayment method had insufficient funds.
CARD_EXPIREDCard has expired.
(Trustly bank timeout)Surfaces as PAYMENT.CAPTURE.PENDING persisting in PENDING_SETTLEMENT; no distinct PayPal "timeout" event code has been confirmed against a live sandbox yet.

Stripe Rail

Status: implemented, not yet exercised against a live Stripe account. Written directly against Stripe's documented PaymentIntents/Webhooks contracts, but this build has not had real Stripe credentials to run it end-to-end. Treat error-code mappings as best-effort until validated.

Merchant-owned account. Unlike PayPal, Stripe is not the platform's account — you connect your own, so charges settle directly to your Stripe balance and you are the Merchant of Record for them (tax and chargeback liability sit with you, same as a normal Stripe integration would). The platform never holds these funds and posts no ledger entry for them. This is the same ownership model as Lemon Squeezy, wearing the PayPal rail's request shape: POST /v1/payment-orders, fields rendered inline (or via Hosted Checkout), no redirect to a Stripe-hosted page.

Because the Payment Element can run on your own storefront domain, you must register that domain for Apple Pay / Google Pay in your own Stripe dashboard (Settings → Payments → Payment methods) — there is no platform-wide registration that covers every merchant. If you use Hosted Checkout instead, this step is unnecessary: the fields run on the gateway's domain, which is registered once, by the operator, for everyone.

Connect your Stripe account (one-time)

POST /v1/merchant/stripe/credentials
Authorization: Bearer <api_key>
Content-Type: application/json

{
  "secret_key": "sk_test_...",
  "publishable_key": "pk_test_...",
  "webhook_secret": "whsec_..."
}

All three come from your own Stripe dashboard (Developers → API keys; Developers → Webhooks for the signing secret, after you register https://innestovarco.space/webhooks/stripe there). The secret key and webhook secret are encrypted at rest and never echoed back; the publishable key is stored in plaintext because it is meant for browsers. Validation before storage:

Saving always resets the connection to inactive (rotating a key is exactly the moment a mistake would otherwise go live silently) — an operator must activate it (POST /v1/admin/merchants/{id}/stripe/activate) before orders succeed. Check your status any time:

GET /v1/merchant/stripe/credentials
Authorization: Bearer <api_key>
{ "publishable_key": "pk_test_...", "environment": "test", "is_active": false, "has_secret_key": true, "has_webhook_secret": true, "webhook_url_path": "/webhooks/stripe" }

404 if you have never submitted credentials. Subscribe your webhook endpoint to: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.canceled, charge.refunded, charge.dispute.created.

Create a Stripe-rail order

{
  "idempotency_key": "cart_123_checkout_1",
  "external_reference": "cart_123",
  "method": "STRIPE_CARD",
  "currency": "USD",
  "expected_amount_atomic": "4999"
}

method is one of STRIPE_CARD, STRIPE_APPLE_PAY, STRIPE_GOOGLE_PAY — namespaced with a STRIPE_ prefix specifically so toggling them is independent of PayPal's unprefixed CARD/APPLE_PAY/GOOGLE_PAY (see Payment Methods). All three are the same PaymentIntent under the hood — Apple Pay and Google Pay are wallets that tokenize into a card to Stripe, not separate payment method types — the prefix only controls which wallet button the Payment Element shows.

Response adds:

{
  "id": "2f8f6a8b-...",
  "checkout_payload": {
    "provider": "STRIPE",
    "method": "STRIPE_CARD",
    "client_secret": "pi_..._secret_...",
    "publishable_key": "pk_test_...",
    "environment": "test",
    "hosted_checkout_path": "/checkout/2f8f6a8b-.../?t=<token>",
    "gateway_order_id": "2f8f6a8b-..."
  }
}

checkout_payload is only present on the creation response — capture it then; GET/list responses omit it (a client_secret has no business being persisted or replayed). An idempotent replay with the same idempotency_key re-fetches a fresh payload from Stripe rather than reusing a stale one. Mount it with gateway.js:

<script src="https://innestovarco.space/gateway.js"></script>
<div id="payment-container"></div>
<script>
  CryptoGatewayCheckout.mount('#payment-container', {
    method: order.checkout_payload.method,
    clientSecret: order.checkout_payload.client_secret,
    publishableKey: order.checkout_payload.publishable_key,
    orderId: order.id,
    onApproved: () => { /* poll GET /v1/payment-orders/{id}, or wait for your webhook */ },
    onError: (err) => { /* show err.message */ },
  });
</script>

Mounting this way runs Stripe's script on your domain — see Hosted Checkout if you want Stripe to see only the gateway's domain instead.

Do not treat onApproved as proof of payment. It fires on a client-side confirmation; your webhook (Stripe's signed payment_intent.succeeded, normalized and re-signed by this gateway) is the only trustworthy source of truth, and 3-D Secure or bank delays can land the real confirmation seconds to minutes later.

Manual Transfer Rail

A payment method with no processor behind it — you are the processor. The buyer is shown instructions you wrote (bank details, an IBAN, "pay at the counter", anything), sends the money entirely outside the gateway, then tells the gateway their email. You check your own account and confirm or reject each order from your dashboard. The gateway records your decision, audits it, and fires the same payment.paid / payment.cancelled webhooks as every other rail — but it cannot verify the payment itself; only you can.

Because nothing external needs vetting, there is no credential to connect and no operator activation. Writing your instructions is the entire setup.

One-time setup (dashboard)

In the merchant dashboard → Manual transfer, set:

Then switch MANUAL_TRANSFER on under Payment methods. Both are also available as endpoints for your own tooling:

GET  /v1/merchant/session/manual-transfer/config     (dashboard session)
POST /v1/merchant/session/manual-transfer/config     (dashboard session)
  { "display_name": "Bank transfer", "instructions": "IBAN DE89 … reference: your order code" }

Optionally, replace the generic bank icon shown at checkout with your own (your bank's logo, a wallet mark — SVG, 64 KB max, self-contained: no scripts, no external references). This is the one method icon that's yours alone — unlike every other method's artwork, which is a single image shared by every merchant, your Manual Transfer setup really is only yours, so this doesn't touch what any other merchant's buyers see.

PUT    /v1/merchant/session/manual-transfer/icon     (dashboard session)
Content-Type: image/svg+xml

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">…</svg>
DELETE /v1/merchant/session/manual-transfer/icon     (dashboard session)

Reverts to the platform default — your own admin's global override if they set one, otherwise the bundled artwork.

Create a manual-transfer order

Same endpoint as every other rail; method selects it. currency is required, expected_amount_atomic is in minor units (cents).

POST /v1/payment-orders
Authorization: Bearer <api_key>
Content-Type: application/json

{
  "idempotency_key": "inv-10422-manual",
  "external_reference": "INV-10422",
  "method": "MANUAL_TRANSFER",
  "currency": "EUR",
  "expected_amount_atomic": "1199"
}

The response's checkout_payload carries everything the buyer needs:

{
  "provider": "MANUAL",
  "display_name": "Bank transfer",
  "instructions": "IBAN DE89 … reference: your order code",
  "icon_svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" …>…</svg>",
  "reference": "MT-7F3A9C2E1B",
  "hosted_checkout_path": "/checkout/<order_id>?t=<token>",
  "gateway_order_id": "<order_id>"
}

icon_svg is already resolved (your own upload, else the platform default) and already the raw, safe-to-render markup — inline it directly if you build your own UI instead of the hosted checkout page, which does exactly that.

reference is a short code unique to this order. Tell the buyer to put it on their transfer — it is how you will recognise the payment in your bank statement, and it is echoed back to you as transfer_reference on the payment.paid webhook.

Simplest integration: embed hosted_checkout_path in an iframe (see Hosted Checkout). That page shows your instructions and the reference, collects the buyer's email, and posts the "I've sent it" confirmation for you. If you render your own UI instead, post the buyer's email yourself:

POST /v1/checkout/manual/{order_id}/submit?t=<token from hosted_checkout_path>
Content-Type: application/json

{ "email": "buyer@example.com" }

This moves the order from AWAITING_PAYMENT to PAYMENT_OBSERVED ("the buyer says they paid") and records the email against the order. It is public and rate-limited, authorised by the same per-order token the hosted checkout link carries — never by your API key, which must not reach a browser.

Confirm or reject

Orders waiting on you appear in the dashboard's Manual transfer → Awaiting confirmation list (both not-yet-submitted and buyer-submitted ones, so you can also confirm a payment you spotted before the buyer clicked anything). Each has Confirm paid and Mark unpaid. The same actions are available to your backend:

GET  /v1/merchant/session/manual-transfer/orders            (dashboard session)
POST /v1/merchant/manual-transfer/orders/{order_id}/confirm  (api key or session)  → PAID
POST /v1/merchant/manual-transfer/orders/{order_id}/reject   (api key or session)  → CANCELLED

Every decision is written to the order's transition history with actor merchant-api and a reason code (MANUAL_CONFIRMED / MANUAL_REJECTED), visible via GET /v1/payment-orders/{id}/transitions.

Subscription & Hosted-Checkout Rails

Status: implemented, not yet exercised against a live provider account. Both adapters are written against the providers' documented REST contracts, but neither has run end-to-end with real credentials. Treat error-code mappings and exact payload shapes as best-effort until validated.

Lemon Squeezy, FastSpring and Whop are genuinely different from every rail above: the buyer is handed to a page hosted by the provider, not fields rendered on yours or the gateway's domain. That page is where the provider's branding appears — it cannot be hidden the way Stripe's can, because for these the provider's checkout page is the product.

Lemon SqueezyFastSpringWhop
Whose accountyours — you supply API key + webhook secretthe platform's — you supply nothingyours — you supply API key, company id + webhook secret
Merchant of Recordthe providerthe providerthe provider
Ledger entry postednone — funds never touch the gatewaynonenone
One-time purchasesno (subscriptions only)yes, as regular payment ordersyes, as regular payment orders
Subscriptionsyesyesnot yet
Checkout endpointPOST /v1/checkout/createPOST /v1/fastspring/checkout/createPOST /v1/whop/checkout/create
Returnsa hosted checkout_url to redirect toa session_id for FastSpring's Store Builder Library, and sometimes also a checkout_urla hosted checkout_url and a gateway_order_id to poll

All three checkout endpoints are public — called from your storefront's browser, where no API key can safely live. Each is protected by an opaque public store_key, an Origin check against your registered domain, and a strict per-(store, IP) rate limit (CHECKOUT_RATE_LIMIT_PER_MINUTE, default 10/min — separate from and stricter than the general API rate limit). Enable or disable each rail under Payment methods in your dashboard; the toggle is enforced server-side, not just hidden in the UI.

Whop, specifically

Whop is the simplest of the three to integrate because you price each checkout yourself — there is no product catalogue to set up on the provider's side. The gateway creates a one-time Whop plan for the exact amount, under your Whop company, and hands you a URL.

Setup: dashboard → Whop → enter your own Whop API key, company id (biz_…) and the webhook signing secret (ws_…) of a webhook endpoint you create in your Whop dashboard pointing at https://innestovarco.space/webhooks/whop (subscribe to payment.succeeded, payment.failed, payment.canceled, payment.pending, refund.created, dispute.created). Both secrets are encrypted at rest and never shown again. An operator activates the connection; until then the checkout endpoint returns 404 for your store_key. Then switch Whop checkout on under Payment methods. Saving new credentials switches the rail off again until re-approved.

Because it is your Whop company, every checkout is created under it and Whop settles to you directly — the gateway never holds these funds. The same endpoints are available to your backend:

POST /v1/merchant/whop/credentials   (api key or dashboard session)
  { "api_key": "…", "company_id": "biz_…", "webhook_secret": "ws_…", "domain": "shop.example.com" }
GET  /v1/merchant/whop/credentials   → store_key, company_id, is_active, webhook_url_path

Create a checkout (from your storefront's browser, or your server):

POST https://innestovarco.space/v1/whop/checkout/create
Content-Type: application/json
Origin: https://shop.example.com

{
  "store_id": "wh_…",                 // your public store key from the dashboard
  "idempotency_key": "cart-8841-v1",
  "external_reference": "INV-10422",
  "amount_atomic": "1199",            // minor units — 11.99
  "currency": "USD",
  "title": "Order INV-10422",         // optional, shown on the Whop page
  "customer_email": "buyer@example.com",
  "redirect_url": "https://shop.example.com/thanks?order=INV-10422"  // optional; never shown to Whop
}
{
  "hosted_checkout_path": "/checkout/2f8f6a8b-…?t=3f8b2462…",
  "checkout_url": "https://whop.com/checkout/ch_…/",
  "gateway_order_id": "2f8f6a8b-…",
  "provider_reference": "ch_…",
  "internal_session_id": "…"
}

Send the buyer to https://innestovarco.space + hosted_checkout_path, not to checkout_url. That page is on the gateway's domain and forwards the buyer to Whop with no referrer; and the redirect_url you gave is kept on the gateway — Whop is told to send the buyer back to a gateway URL, which then bounces them on to yours. Whop therefore never sees your domain in either direction, the same property the card rails get from the iframe hosted checkout. (checkout_url is still returned for integrations that do not care; a browser navigating there directly tells Whop where it came from.)

The order already exists at that point (GET /v1/payment-orders/{gateway_order_id} → AWAITING_PAYMENT), so you can poll it exactly as you would a card or crypto order. Repeating the call with the same idempotency_key returns the same order and URLs rather than creating a second Whop plan. Arriving back at your redirect_url is not proof of payment — fulfil on payment.paid or on the order reading PAID.

Outcome: Whop's signed webhook reaches the gateway, which moves the order and delivers your webhook with the same vocabulary as every other rail — payment.paid (fulfil on this), payment.pending (bank transfer still clearing), payment.cancelled, payment.refunded, payment.disputed. A failed card attempt does not cancel the order; the buyer can retry the same checkout.

Only two-decimal currencies (USD, EUR, GBP, …) are accepted for amount_atomic; JPY and other zero-decimal currencies return 400 rather than risk a 100× mis-charge.

One-time setup

Both start in your dashboard and need an operator to activate them before checkout requests succeed.

Lemon Squeezy — supply your own credentials (encrypted at rest before storage, never echoed back):

POST /v1/merchant/lemonsqueezy/credentials
Authorization: Bearer <api_key>

{ "ls_store_id": "45678", "api_key": "...", "webhook_secret": "...", "domain": "shop.example.com" }

Then register https://innestovarco.space/webhooks/lemonsqueezy in your Lemon Squeezy dashboard, subscribed to the subscription_* events.

FastSpring — no credentials at all; you are enrolling onto the platform's central account:

POST /v1/merchant/fastspring/enroll
Authorization: Bearer <api_key>

{ "domain": "shop.example.com" }

Both return a public store_key (ls_… / fs_…). It is safe to embed in your storefront's page source and is deliberately not your merchant id.

Creating a checkout

FastSpring example — a one-time purchase or a subscription, decided by the product you pass, not by a field you set:

POST /v1/fastspring/checkout/create
Content-Type: application/json

{
  "store_id": "fs_a3f91c8e2b7d4f6a9c1e5b3d7f2a8c4e",
  "product_path": "my-store/pro-monthly",
  "customer_email": "buyer@company.com",
  "external_reference": "cart_123"
}

Lemon Squeezy is subscriptions-only and has no equivalent one-time-purchase concept, so it has no mode and no external_reference field at all — its request body is exactly store_id + variant_id + customer_email:

POST /v1/checkout/create
Content-Type: application/json

{
  "store_id": "ls_a3f91c8e2b7d4f6a9c1e5b3d7f2a8c4e",
  "variant_id": "912345",
  "customer_email": "buyer@company.com"
}

external_reference on FastSpring is optional but supply it for one-time purchases — it becomes the resulting order's external_reference, which is how you tie a gateway order back to your own records. It has no effect on a FastSpring subscription purchase.

Reading state

GET /v1/subscriptions
Authorization: Bearer <api_key>

Returns subscriptions from both rails in one list, each row carrying provider (LEMONSQUEEZY / FASTSPRING). Status values are canonical across providers — on_trial, active, paused, past_due, unpaid, cancelled, expired — so you never branch on which provider a subscription came from; FastSpring's own vocabulary (e.g. deactivated) is normalized into this set.

FastSpring one-time purchases do not appear here. They are ordinary payment orders with rail: "FASTSPRING", so read them with GET /v1/payment-orders alongside your crypto, PayPal, and Stripe orders.

Getting pushed updates

Do not grant access based on the buyer returning to your success/redirect URL — that is a URL anyone can type. Entitlement comes from the signed webhook, always.

Subscription changes and FastSpring one-time-purchase orders arrive on your existing webhook endpoint, with the same HMAC-SHA256(secret, timestamp.body) signature you already verify for crypto/PayPal/Stripe — one handler, one signature scheme, every rail. (This is the signature the gateway sends you; internally it verifies each provider's own inbound scheme — Lemon Squeezy's hex HMAC, FastSpring's base64 HMAC — which you never touch.) Subscription events are named subscription.<status>; FastSpring one-time purchases emit payment.paid / payment.pending / payment.updated.

Hosted Checkout

What it is for: any payment method rendered on your storefront page — the gateway.js inline mount shown above for Stripe and PayPal — makes the processor's own script (js.stripe.com, PayPal's SDK) run in the buyer's browser on your domain, and that script can read window.location directly. There is no way to prevent this from your API payloads alone. Hosted Checkout moves the payment fields onto the gateway's own origin instead, so the processor sees only the gateway's domain.

Every Stripe, PayPal (except Trustly — see below) and Manual Transfer order response includes a ready-to-use link inside checkout_payload. For Manual Transfer the page shows your instructions and the order's transfer reference, collects the buyer's email, and posts their "I've sent it" confirmation for you (postMessage type "submitted"):

{ "checkout_payload": { "hosted_checkout_path": "/checkout/2f8f6a8b-.../?t=3f8b246218ff9d0723ebc012", "gateway_order_id": "2f8f6a8b-...", "...": "..." } }

Embed it in an iframe so the buyer never visibly leaves your page:

<iframe src="https://innestovarco.space{{hosted_checkout_path}}" allow="payment *" style="width:100%;border:0"></iframe>

or redirect the full page to it — both work; the iframe is what keeps the buyer's URL bar on your storefront.

Known limitation: PayPal Trustly is excluded from Hosted Checkout — hosted_checkout_path still appears on a Trustly order, but the page it points to renders "payment unavailable." Trustly is a bank redirect, and the order was already created with your own return_url/cancel_url baked into PayPal's redirect link at creation time (see Create a PayPal-rail order) — your domain is already exposed to PayPal before Hosted Checkout would ever run, so routing it through this page would add nothing. Use the redirect_url from checkout_payload directly for Trustly.

Lemon Squeezy and FastSpring are not supported by Hosted Checkout at all — both hand the buyer to a page the provider itself hosts, which is a different model (see Subscription & Hosted-Checkout Rails) and cannot be proxied through the gateway's origin.

Checkout Wording

Every sentence the gateway shows a buyer — on the hosted checkout page, in the widget, after a manual-transfer confirmation — has a platform default and can be replaced with your own. Dashboard: Checkout wording. API:

GET  /v1/merchant/checkout-messages
PUT  /v1/merchant/checkout-messages     { "invalid_email": "Adresse e-mail invalide." }

GET returns three objects: messages (what buyers actually see — defaults with your overrides applied; this is the one to render), overrides (only what you set) and defaults (the platform wording, so a UI can offer "reset" without hardcoding a copy).

PUT replaces the whole override set, so leaving an id out — or sending "" — puts it back to the platform default. Unknown ids and text over 400 characters are rejected.

idshown when
payment_succeededthe payment went through
payment_pendingit is taken but not yet confirmed
payment_failedit did not go through
payment_cancelledthe buyer or the processor cancelled it
manual_transfer_submittedthe buyer said they sent a manual transfer
validation_requireda required field is empty
invalid_emailthe email address is not valid
method_unavailablethat method cannot be used right now
order_failedthe order could not be created

The same resolved messages object is returned by GET /v1/merchant/checkout-config, so your own storefront screens can use the wording you configured once rather than keeping a second copy. Responses that report an outcome are structured the same way:

{ "type": "manual_transfer_submitted", "status": "PAYMENT_OBSERVED",
  "order_id": "2f8f6a8b-…", "message": "Thanks — we've let the seller know." }

Merchant Profile And Webhook Self-Service

GET /v1/merchant
Authorization: Bearer <api_key>

Returns the authenticated merchant's own id, name, status, webhook_url, and created_at.

PATCH /v1/merchant/webhook
Authorization: Bearer <api_key>
Content-Type: application/json

{ "webhook_url": "https://example.com/webhooks/gateway", "webhook_secret": "new-secret" }

Both fields are optional but at least one must be provided; omitted fields are left unchanged. webhook_url must start with http:// or https://. This endpoint only sets non-empty values — it cannot be used to clear a webhook configuration back to unset; contact the operator for that.

API Key Self-Service

GET /v1/api-keys
Authorization: Bearer <api_key>

Lists the authenticated merchant's own API keys (prefix, status, scopes, timestamps). Raw secrets are never returned after issuance.

POST /v1/api-keys/{key_id}/revoke
Authorization: Bearer <api_key>

Revokes one of the merchant's own keys. Returns 409 Conflict if the key is already revoked, 404 if it does not belong to the caller. Revoking the key used to authenticate the request itself takes effect immediately — the very next request with that key returns 401. Issue a replacement key first if you need continuous access.

Order Statuses

Current and planned status values:

StatusMeaning
AWAITING_PAYMENTOrder is open and waiting for funds.
PARTIALLY_PAIDSome funds received, amount still short.
PAYMENT_OBSERVEDFull expected amount observed on-chain, not final yet.
CONFIRMINGPayment is being confirmed/finality-checked.
PAIDPayment is final. Store can fulfill.
OVERPAIDMore than expected amount was paid.
EXPIREDOrder expired before payment.
LATE_PAIDPayment arrived after expiry.
REORG_REVIEWPossible chain reorg needs review.
PENDING_SETTLEMENTPayPal rail only (e.g. Trustly): confirmed at the provider, funds not yet settled — can lag several days.
MANUAL_REVIEWOperator review required.
CANCELLEDOrder cancelled.

Production rule: stores should fulfill only on PAID, unless they explicitly accept the risk of earlier statuses.

Sandbox Payment Test

Use this only in local/dev environments. It updates a real payment order to PAID, or to PARTIALLY_PAID if you send less than the expected amount, without requiring real crypto balance.

Enable sandbox mode before starting control-api:

$env:SANDBOX_ENABLED="true"

After creating a checkout order, get the latest order id:

& "C:\Program Files\Docker\Docker\resources\bin\docker.exe" exec gateway-postgres psql -U gateway -d gateway -c "SELECT id, external_reference, status, expected_amount_atomic, received_amount_atomic FROM payment_orders ORDER BY created_at DESC LIMIT 5;"

Simulate exact payment:

$orderId = "<payment_order_id>"
Invoke-RestMethod -Method Post `
  -Uri "http://127.0.0.1:8080/sandbox/payment-orders/$orderId/simulate-payment" `
  -Headers @{ Authorization = "Bearer admin-dev-token" } `
  -ContentType "application/json" `
  -Body "{}"

Simulate a partial or overpaid payment:

Invoke-RestMethod -Method Post `
  -Uri "http://127.0.0.1:8080/sandbox/payment-orders/$orderId/simulate-payment" `
  -Headers @{ Authorization = "Bearer admin-dev-token" } `
  -ContentType "application/json" `
  -Body (@{ amount_atomic = "5000000" } | ConvertTo-Json)

Webhooks

Webhooks are HTTP POSTs to the webhook URL on your merchant profile (set at registration, or later via Merchant Profile And Webhook Self-Service). Your webhook secret is encrypted at rest.

One event vocabulary for every rail. Whatever a provider sends us (PayPal's PAYMENT.CAPTURE.COMPLETED, Stripe's payment_intent.succeeded, an on-chain sweep confirmation, your own click on "Confirm paid" for a manual transfer) is verified, then normalised into the events below before it is queued for you. Write one handler.

Headers and signature

Content-Type: application/json
X-Webhook-Timestamp: 1783383600
X-Webhook-Signature: sha256=<hex_hmac>
X-Webhook-Event: payment.paid
signature = HMAC-SHA256(webhook_secret, X-Webhook-Timestamp + "." + raw_body)

Compute it over the exact raw request bytes, before any JSON parsing, and compare with a constant-time comparison. Reject timestamps older than a few minutes to limit replay.

// Node.js
const crypto = require('crypto');
function verify(rawBody, headers, secret) {
  const ts = headers['x-webhook-timestamp'];
  const expected = 'sha256=' + crypto.createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`).digest('hex');
  const given = headers['x-webhook-signature'] || '';
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  return given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

Respond 2xx once you have durably recorded the event. Anything else — or a timeout — is treated as a failed delivery and retried.

Events

Every payment event carries at least these fields, so one handler can be written against them regardless of rail. Subscription events have a different shape — see Subscription events below:

FieldMeaning
order_idthe gateway order (UUID)
merchant_idyour merchant id
external_referenceyour reference from order creation — map on this
railCRYPTO, PAYPAL, STRIPE, FASTSPRING, WHOP, MANUAL
new_statusthe order's status after this event (see Order Statuses)
X-Webhook-EventRailsFires when
payment.paidallThe order is paid. Fulfil on this. Crypto: emitted once the sweep that moves the buyer's funds into the platform wallet has confirmed. new_status is PAID, or OVERPAID if the buyer sent more than requested.
payment.deposit.matchedCryptoA transfer to the order's deposit address was seen on-chain and matched to the order. Informational — the order is not paid yet; use it to show "payment detected, confirming…" to the buyer. May fire more than once for one order (partial payments).
payment.expiredCryptoAn unpaid order passed expires_at. If nothing was received the deposit address is released; if a partial amount was received it is not.
payment.cancelledallThe order was cancelled — by you via POST /v1/payment-orders/{id}/cancel, by the buyer abandoning a Stripe payment, or by you marking a manual transfer unpaid.
payment.refundedStripeA refund was recorded in the merchant's Stripe account.
payment.disputedStripeA chargeback / dispute was opened; the order moves to MANUAL_REVIEW.
payment.pendingFastSpringFastSpring accepted the order but funds are still clearing (bank transfer).
payment.updatedFastSpringAny other FastSpring order state change.

payment.paid — PayPal / Stripe / Manual Transfer:

{
  "order_id": "2f8f6a8b-8db5-4ff1-8a9c-8b67a6c38fd1",
  "merchant_id": "3abf4e49-4cd2-4be9-a0ef-7d201bbad890",
  "external_reference": "INV-10422",
  "rail": "PAYPAL",
  "payment_method": "CARD",
  "previous_status": "AWAITING_PAYMENT",
  "new_status": "PAID",
  "currency": "USD",
  "expected_amount_atomic": "1199",
  "correlation_id": "…"
}

payment.paid — Crypto (same core fields, plus the on-chain detail):

{
  "order_id": "…",
  "merchant_id": "…",
  "external_reference": "INV-10422",
  "rail": "CRYPTO",
  "previous_status": "CONFIRMING",
  "new_status": "PAID",
  "network": "eth-mainnet",
  "asset_id": "eth-mainnet:usdc",
  "expected_amount_atomic": "11990000",
  "received_amount_atomic": "11990000",
  "sweep_tx": "0x…",
  "block_height": 21034567
}

payment.deposit.matched:

{
  "order_id": "…",
  "merchant_id": "…",
  "external_reference": "INV-10422",
  "rail": "CRYPTO",
  "event_id": "eth-mainnet:0xabc…:12",
  "network": "eth-mainnet",
  "asset_id": "eth-mainnet:usdc",
  "amount_atomic": "11990000",
  "new_status": "CONFIRMING",
  "received_total": "11990000"
}

Rail-specific extras (payment_method, provider_reference, transfer_reference, sweep_tx, sandbox, …) are additive — ignore what you do not need.

Subscription events

Emitted by the Lemon Squeezy and FastSpring rails whenever a subscription changes state. The event name is subscription.<status>, where the status is one of the canonical values listed under Reading state — on_trial, active, paused, past_due, unpaid, cancelled, expired. Each provider's own vocabulary is normalised into that set before the event is emitted, so you never branch on which provider it came from.

These are a different shape from payment events. A subscription is not an order, so there is no order_id, no external_reference, no rail and no new_status. Do not write one handler that assumes those fields across both families — branch on the X-Webhook-Event prefix first.

FieldMeaning
merchant_idyour merchant id
subscription_idthe gateway's own subscription id (UUID) — the same id GET /v1/subscriptions returns
providerLEMONSQUEEZY or FASTSPRING
provider_subscription_idthe id in the provider's own system
statusthe canonical status, matching the part after the dot in the event name
{
  "merchant_id": "3abf4e49-4cd2-4be9-a0ef-7d201bbad890",
  "subscription_id": "9c21f0de-7b3a-4a02-9f51-1c7d9a0b4e88",
  "provider": "FASTSPRING",
  "provider_subscription_id": "sub_8812f0",
  "status": "active"
}

Grant entitlement on subscription.active and subscription.on_trial; revoke on subscription.cancelled, subscription.expired and subscription.unpaid. Treat subscription.past_due as a grace period and decide your own policy — the gateway does not decide it for you.

FastSpring one-time purchases do not appear here. They are ordinary payment orders and emit payment.* events like every other rail.

Delivery guarantees

Every attempt — URL, HTTP status, timestamp — is recorded on the platform side, so if your endpoint is failing your operator can tell you exactly what was sent and what your server answered.

Errors

Error response:

{
  "error": "expected_amount_atomic must be > 0"
}

Common statuses:

400 Bad Request           malformed input, disabled method, unmet rail precondition
401 Unauthorized          missing/invalid API key or session token; webhook signature failed
404 Not Found             resource does not exist, or belongs to another merchant
409 Conflict              action not valid from the resource's current state (e.g. re-approving)
423 Locked                account locked from repeated failed dashboard logins (Retry-After set)
429 Too Many Requests     rate limit exceeded (Retry-After set)
502 Bad Gateway           a payment provider was reached but rejected the request
503 Service Unavailable   a rail is not configured/activated, or its provider is unreachable
500 Internal Server Error unexpected server-side failure

502 vs 503 is a deliberate distinction on the Stripe and PayPal rails: 502 means the provider responded with an error (check your request), 503 means the provider could not be reached, or the rail/your credentials are not active yet (check configuration, not your request). Both are safe to retry after fixing the underlying issue; retrying 502 unchanged will fail again.

Request JSON bodies are limited to 64 KiB.

Integration Flow

The shape is the same on every rail; only step 4's method and step 5's presentation differ.

  1. Discover what you can offer. GET /v1/merchant/methods tells you which methods you have switched on (and which are actually connected); GET /v1/assets lists crypto assets with deposit_available. Show only what both say is usable.

  2. Buyer picks a method at your checkout.

  3. Create the order — POST /v1/payment-orders with a unique idempotency_key and your own external_reference. For Lemon Squeezy / FastSpring use their checkout endpoints instead.

  4. Let the buyer pay. Easiest: put checkout_payload.hosted_checkout_path in an iframe — that one page handles card fields, wallets, crypto addresses and manual-transfer instructions, on the gateway's domain, and postMessages the outcome back to you. Or render checkout_payload yourself (crypto: show deposit_destination + exact amount).

  5. Wait for the gateway, not the browser. Fulfil on the signed payment.paid webhook, or on GET /v1/payment-orders/{id} returning PAID / OVERPAID. Never on a redirect, a success callback, or a client-side event.

    On the hosted-checkout rails (Lemon Squeezy, FastSpring) you have no order id to poll — the order is created by the provider's webhook, not by your checkout call. Use the webhook, or poll GET /v1/payment-orders?external_reference=<yours> and expect an empty list until payment completes. Subscriptions are webhook-only: they are not orders at all, and their events have their own shape (Subscription events).

  6. Handle the unhappy paths — payment.expired, payment.cancelled, payment.refunded, payment.disputed — from the same handler.

A minimal, complete integration is: one server-side call to create the order, one iframe, one webhook handler.

Operations

Running the gateway itself (signers, listeners, sweepers, pool provisioning, custody configuration) is documented separately for operators in docs/operations.md. Nothing there is something a merchant integrating against this API needs to do.