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:
| Rail | What it is | Whose provider account | Who holds the money | One-time | Subscriptions |
|---|---|---|---|---|---|
| Crypto | on-chain deposit address, unique per order | the platform's wallets | the platform (custodial — see below) | yes | no |
| PayPal | card / Apple Pay / Google Pay / Trustly | the platform's | routed to your PayPal at capture | yes | no |
| Stripe | card / Apple Pay / Google Pay | yours | you | yes | no |
| Lemon Squeezy | hosted checkout | yours | you (Lemon Squeezy is Merchant of Record) | no | yes |
| FastSpring | hosted checkout | the platform's | the platform (FastSpring is Merchant of Record) | yes | yes |
| Whop | hosted checkout, priced per order by you | yours | you (Whop is Merchant of Record) | yes | not yet |
| Manual Transfer | your own bank / cash instructions, confirmed by you | none | you | yes | no |
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:
- A unique deposit address is derived for the order (never reused).
- The buyer's transfer is observed on-chain →
payment.deposit.matched. - 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". - 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
- Register:
POST /v1/merchants/register— no admin action needed. Your merchant record starts inPENDING, and the response includes a TEST API key immediately — start integrating right away. - Integrate with the TEST key: it authenticates every endpoint in this doc while you're still
PENDING. Use crypto testnets andPAYPAL_ENV=sandboxbehavior on this key — see Authentication for what TEST vs LIVE actually gates. - 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.
- 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. - Rejected: your merchant record moves to
REJECTEDwith 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:
- A TEST key authenticates while its merchant is
PENDINGorACTIVE— neverREJECTED,SUSPENDED, orCLOSED. - A LIVE key authenticates only while its merchant is
ACTIVE. - There is no other combination that authenticates. A rejected/suspended/closed merchant's keys — TEST or LIVE — stop working on the very next request, with no separate revoke step needed.
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:
SANDBOX_ENABLED=trueAuthorization: Bearer <ADMIN_API_TOKEN>
Admin dashboard:
- Open
/admin/dashboard. - Enter
ADMIN_API_TOKEN. - The page calls
/admin/api/overviewand shows merchant/order/asset/sweep/webhook status plus the endpoint catalog.
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:
- The control API applies a fixed-window rate limit.
- Default:
120requests per minute per bearer token prefix or client IP. - Exceeded requests return HTTP
429withRetry-After: 60. - Operators can tune this with
RATE_LIMIT_PER_MINUTE.
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:
provenance | Meaning |
|---|---|
cc0 | Public domain (the cryptocurrency-icons set). Use freely. |
original | Drawn for this platform. Yours to use as part of this integration. |
placeholder | Not 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:
GET /method-assets/for/{method}.svgserves the replacement.GET /v1/merchant/methodsreturns the newlabel, plussubtitleandconsent_textwhen they are set.
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:
- Offer an asset when
deposit_availableistrue. That is the gateway's own answer to "can I take a payment in this right now". - Do not use
available_deposit_addressesto decide anything. It is the number of addresses sitting pre-derived in the gateway's cache — operational visibility only, not a capacity limit. Deposit addresses are HD-derived, so every order gets its own fresh address on demand; a cache count of0simply means the next order derives one inline. A storefront that hides or blocks assets at0will show a perfectly healthy network as "sold out" after a handful of orders. - Every order receives a unique
deposit_destination. Never reuse or cache one between orders. - Use
asset_idexactly as returned when creating a payment order. token_contractis present only for tokens (ERC-20 / TRC-20 / SPL).
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:
| Field | Required | Meaning |
|---|---|---|
idempotency_key | yes | Store-generated key. Reusing it returns the same order. |
external_reference | yes | Store order/cart/invoice reference. |
network | crypto only | Network from GET /v1/assets. Omit for PayPal-rail orders. |
asset_id | crypto only | Asset from GET /v1/assets. Omit for PayPal-rail orders. |
method | no | Omit (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. |
currency | PayPal/Stripe only | ISO 4217 currency code (e.g. "USD"), required when method names a PayPal or Stripe method. |
expected_amount_atomic | yes | Exact amount in atomic units — see Amounts. |
expires_at | no | RFC3339 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:
- Show
deposit_destination,expected_amount_atomic,network, andasset_idto the customer. - Keep polling while status is payment-in-progress.
- Mark the store order paid only after final paid status is emitted. The current implementation has matching foundations; final paid/finality behavior should be completed before production use.
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" }
]
enabled— your own on/off preference, toggled withPATCH .../methods/{method}. Defaults totrueuntil you touch it.connected— whether the rail actually has what it needs to take a payment right now.CRYPTOandPAYPALare alwaystrue(nothing merchant-specific to set up).STRIPE,LEMONSQUEEZY, and theSUBSCRIPTIONrail'sFASTSPRINGentry startfalseuntil you submit credentials/enroll and an operator activates the connection.setup_panel— present only whenconnectedisfalse; names the dashboard panel ("stripe"or"subscriptions") that finishes setup.
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_SECRETto 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 reason | Meaning |
|---|---|
INSTRUMENT_DECLINED | Card was declined by the issuing bank. |
PAYER_ACTION_REQUIRED | 3-D Secure authentication was not completed. |
TRANSACTION_REFUSED | Transaction refused by PayPal risk controls. |
INSUFFICIENT_FUNDS | Payment method had insufficient funds. |
CARD_EXPIRED | Card 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:
secret_keymust start withsk_,publishable_keywithpk_,webhook_secretwithwhsec_—400otherwise.secret_keyandpublishable_keymust be the same mode (bothsk_live_pk_live_, or bothsk_test_+pk_test_) —400on a mismatch, since a mixed pair creates orders live while the browser confirms against test, and the payment silently never completes.
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:
- Method name shown to buyers — e.g.
Bank transfer,Pay by IBAN,Cash on collection(max 80 characters). - Instructions — free text shown to the buyer after they pick this method: account details, what reference to write, expected delay (max 4000 characters; rendered as plain text, never HTML).
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
- Confirm →
PAID, and apayment.paidwebhook (withtransfer_referenceand the buyer'spayer_email). Fulfil on that webhook exactly as for any other rail, even though it was your own click that produced it — that keeps one code path. - Reject →
CANCELLED, and apayment.cancelledwebhook. There is no separate "unpaid" status: a manual order that was never paid ends in the same terminal state every other unpaid order does. - Both are refused (
404) on an order that is already terminal, so a stray double-click can never overwrite a decision.
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 Squeezy | FastSpring | Whop | |
|---|---|---|---|
| Whose account | yours — you supply API key + webhook secret | the platform's — you supply nothing | yours — you supply API key, company id + webhook secret |
| Merchant of Record | the provider | the provider | the provider |
| Ledger entry posted | none — funds never touch the gateway | none | none |
| One-time purchases | no (subscriptions only) | yes, as regular payment orders | yes, as regular payment orders |
| Subscriptions | yes | yes | not yet |
| Checkout endpoint | POST /v1/checkout/create | POST /v1/fastspring/checkout/create | POST /v1/whop/checkout/create |
| Returns | a hosted checkout_url to redirect to | a session_id for FastSpring's Store Builder Library, and sometimes also a checkout_url | a 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.
- The page is unbranded — no gateway name, no processor name — since it is meant to look like part of your checkout.
- The link is authorized by a token (
?t=...), not a session: a wrong or missing token returns404, identical to an unknown order, so order ids cannot be enumerated by guessing tokens. - Add
&hb=1when you embed it and want to keep your own pay button: the page then draws no button of its own, submits when you post{ source: "merchant-checkout", type: "submit" }into the frame, and reports backsubmitting, thensubmittedorerror. Anerrorabout a field on that page carrieshandled: true— the page has already shown it next to the field, so showing it again in your own UI prints the same sentence twice. - The page reports outcomes to the embedding iframe via
postMessage({ source: "gateway-checkout", type: "succeeded" | "submitted" | "error" | "resize", detail }). Treat"succeeded"as a hint to start polling, never as proof of payment — confirm the real status withGET /checkout/{order_id}/status?t=<token>(also token-gated) or, better, your webhook. - On Stripe, 3-D Secure redirects return to this same hosted page — never to your domain — so the processor does not learn it even mid-flow.
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.
| id | shown when |
|---|---|
payment_succeeded | the payment went through |
payment_pending | it is taken but not yet confirmed |
payment_failed | it did not go through |
payment_cancelled | the buyer or the processor cancelled it |
manual_transfer_submitted | the buyer said they sent a manual transfer |
validation_required | a required field is empty |
invalid_email | the email address is not valid |
method_unavailable | that method cannot be used right now |
order_failed | the 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:
| Status | Meaning |
|---|---|
AWAITING_PAYMENT | Order is open and waiting for funds. |
PARTIALLY_PAID | Some funds received, amount still short. |
PAYMENT_OBSERVED | Full expected amount observed on-chain, not final yet. |
CONFIRMING | Payment is being confirmed/finality-checked. |
PAID | Payment is final. Store can fulfill. |
OVERPAID | More than expected amount was paid. |
EXPIRED | Order expired before payment. |
LATE_PAID | Payment arrived after expiry. |
REORG_REVIEW | Possible chain reorg needs review. |
PENDING_SETTLEMENT | PayPal rail only (e.g. Trustly): confirmed at the provider, funds not yet settled — can lag several days. |
MANUAL_REVIEW | Operator review required. |
CANCELLED | Order 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:
| Field | Meaning |
|---|---|
order_id | the gateway order (UUID) |
merchant_id | your merchant id |
external_reference | your reference from order creation — map on this |
rail | CRYPTO, PAYPAL, STRIPE, FASTSPRING, WHOP, MANUAL |
new_status | the order's status after this event (see Order Statuses) |
X-Webhook-Event | Rails | Fires when |
|---|---|---|
payment.paid | all | The 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.matched | Crypto | A 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.expired | Crypto | An unpaid order passed expires_at. If nothing was received the deposit address is released; if a partial amount was received it is not. |
payment.cancelled | all | The 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.refunded | Stripe | A refund was recorded in the merchant's Stripe account. |
payment.disputed | Stripe | A chargeback / dispute was opened; the order moves to MANUAL_REVIEW. |
payment.pending | FastSpring | FastSpring accepted the order but funds are still clearing (bank transfer). |
payment.updated | FastSpring | Any 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.
| Field | Meaning |
|---|---|
merchant_id | your merchant id |
subscription_id | the gateway's own subscription id (UUID) — the same id GET /v1/subscriptions returns |
provider | LEMONSQUEEZY or FASTSPRING |
provider_subscription_id | the id in the provider's own system |
status | the 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
- At-least-once. The same event can be delivered more than once. Treat
order_id + X-Webhook-Eventas the idempotency key (crypto events also carry a uniqueevent_id). - Retried with exponential backoff on any non-
2xxresponse or timeout: roughly 2 s, 4 s, 8 s … doubling up to a maximum of one hour between attempts. There is currently no cap on the number of attempts. - Ordering is not guaranteed across events. Always read
new_statusand treat a later-arriving earlier state as stale (e.g. ignore apayment.deposit.matchedthat arrives after you have already handledpayment.paidfor the same order). - Never fulfil from a client-side redirect or success callback. The
signed webhook (or
GET /v1/payment-orders/{id}showingPAID) is the only source of truth. - Webhooks are only delivered while your merchant account is
ACTIVE. While you are stillPENDINGapproval, test your handler with the Sandbox Payment Test endpoint after approval, or by pollingGET /v1/payment-orders/{id}in the meantime.
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.
-
Discover what you can offer.
GET /v1/merchant/methodstells you which methods you have switched on (and which are actually connected);GET /v1/assetslists crypto assets withdeposit_available. Show only what both say is usable. -
Buyer picks a method at your checkout.
-
Create the order —
POST /v1/payment-orderswith a uniqueidempotency_keyand your ownexternal_reference. For Lemon Squeezy / FastSpring use their checkout endpoints instead. -
Let the buyer pay. Easiest: put
checkout_payload.hosted_checkout_pathin an iframe — that one page handles card fields, wallets, crypto addresses and manual-transfer instructions, on the gateway's domain, andpostMessages the outcome back to you. Or rendercheckout_payloadyourself (crypto: showdeposit_destination+ exact amount). -
Wait for the gateway, not the browser. Fulfil on the signed
payment.paidwebhook, or onGET /v1/payment-orders/{id}returningPAID/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). -
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.