Skip to main content

Embedded Checkout / API Mode

By default Finomesh hosts the payment page for you (Hosted Checkout). API checkout mode inverts that: you render the payment page on your own site, in your own design, and drive the whole flow from your backend. The customer never leaves your domain.

The flow is strictly server-to-server: your backend talks to the Finomesh API; the customer's browser never calls Finomesh directly. Your frontend talks only to your own server.

New to the trade-off? Checkout Modes compares hosted and API mode side by side.

Hosted (default)API mode
Payment pageFinomesh-hosted checkoutYour own UI
checkoutMode"HOSTED" (or omitted)"API"
callbackUrlRequiredForbidden (no hosted page to redirect from)
webhookUrlOptionalRequired — the only push channel
Server IP allowlistNot neededRequired (configured in the dashboard)
Outcome channelsCallback redirect + webhookWebhook only (+ verify polling)

The security model

Two things stand in for the API key on the public calls:

  1. The payment UUID is the credential — unguessable, single-payment scope, same model as the rest of the public endpoints.
  2. Your server's IP must be on the gateway's allowlist — API-mode payments cannot be created, read, selected against, expired, or sandbox-simulated from an address you have not whitelisted. An off-allowlist caller gets 403 — an API-mode payment is completely invisible to anyone but your servers, even with the UUID.

Keep the whole flow server-side: your API key, the create call, and the select call all belong on your backend. Never expose the API key to the browser, and never have the browser call the select endpoint itself — it would fail the IP check anyway (the customer's IP is not on your allowlist), and that is by design.

Step 0 — configure the IP allowlist

In the dashboard, open your gateway's settings (the same area where you generate the webhook signing secret) and add your server's public IPs. Entries can be single IPs or CIDR ranges — a bare IP is treated as /32.

The allowlist gates every API-mode call: creation, the public payment read, offer selection, expire, and sandbox simulate. Creating a payment with checkoutMode: "API" while the allowlist is empty fails with 422 and a message pointing you to this setting.

Step 1 — create the payment

Same create endpoint, with checkoutMode: "API", a required webhookUrl, and no callbackUrl:

curl -X POST https://api-staging.finomesh.com/api/v1/payments \
-H "X-Api-Key: $FINOMESH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"currencyCode": "USD",
"amount": "49.99",
"checkoutMode": "API",
"webhookUrl": "https://shop.example.com/api/finomesh-webhook"
}'

Unlike the slim hosted response, the API-mode response inlines the full payment object — the same shape GET /public/payments/{id} serves — so you can render your payment page immediately without a second round-trip:

{
"success": true,
"data": {
"paymentUid": "0d9f3a64-4f0c-4b6e-9f6e-2f4f4c1b8a21",
"paymentUrl": "https://checkout-staging.finomesh.com/p/0d9f3a64-…",
"payment": {
"id": "0d9f3a64-4f0c-4b6e-9f6e-2f4f4c1b8a21",
"status": "PENDING",
"checkoutMode": "API",
"amount": "49.99",
"amountUsd": "49.99",
"currencyCode": "USD",
"timeoutSeconds": 3600,
"createdAt": "2026-07-03T10:21:43Z",
"offers": [
{
"id": "7c2e1f30-…",
"assetId": "a1b2c3d4-…",
"assetSymbol": "USDT",
"assetNetwork": "TRON",
"assetLogoUrl": "https://…/usdt.png",
"networkLogoUrl": "https://…/tron.png",
"assetPriceUsd": "1.00",
"amount": "49.99",
"txFee": "1.20",
"commissionFee": "0.50",
"totalValue": "51.69"
}
]
}
}
}

The payment object is the full public payment object (same shape GET /public/payments/{id} serves) — see its complete field table.

Store paymentUid — it keys the select call, the webhook, and verify.

Step 2 — render your payment page

Build the asset picker from payment.offers[]. Each offer is one payable asset with everything you need to display it:

FieldUse it for
assetSymbol, assetNetworkThe label — e.g. "USDT on TRON"
assetLogoUrl, networkLogoUrlIcons
amountThe invoice principal in the asset
txFee, commissionFeeFee lines (only fees the customer pays are non-zero here)
totalValueThe exact amount the customer must send — display this one prominently

All monetary values are decimal strings — never parse them into floats. timeoutSeconds (with createdAt) drives your countdown.

The columns above are the display-relevant subset; for the type and meaning of every offer field, see the full offer object table on the API reference.

Note that offers carry no deposit address yet — the address is derived on demand when an asset is selected, in the next step.

Step 3 — customer picks an asset → your server selects the offer

When the customer picks an asset, your frontend tells your backend, and your backend calls the select endpoint:

curl -X POST https://api-staging.finomesh.com/api/v1/public/payments/{paymentId}/offers/{offerId}/select

No API key, no body — the payment UUID plus your allowlisted server IP authenticate the call.

200 OK — the deposit address is assigned:

{
"success": true,
"data": {
"assignmentStatus": "ASSIGNED",
"depositAddress": "TX7k2…",
"amount": "51.69",
"paymentStatus": "PENDING",
"expiresAt": "2026-07-03T11:21:43Z",
"offer": { "id": "7c2e1f30-…", "assetSymbol": "USDT", "totalValue": "51.69", "…": "…" }
}
}

amount is the selected offer's totalValue — the exact figure the customer must send. offer is the full offer object so this single response can render the whole pay screen.

Both the 200 and 202 bodies share the SelectOfferResult shape:

FieldTypeNullableDescriptionExample
assignmentStatusstring (enum)NoASSIGNED (200, address ready) or PENDING (202, retry).ASSIGNED
depositAddressstringYesPresent when ASSIGNED; absent while PENDING.TX7k2…
retryAfterMsintegerYesOn 202 only — re-call the same endpoint after this many ms.400
amountstring (decimal)NoThe exact amount to send (the offer's totalValue). Decimal string — never a number.51.69
paymentStatusstring (enum)NoPayment status (PENDING here).PENDING
expiresAtstring (RFC 3339)NoAddress deadline — stop displaying it past this.2026-07-03T11:21:43Z
offerobjectNoThe full selected offer object.(see offer object)

Full reference: select endpoint.

202 Accepted — address derivation is still in flight on our side:

{
"success": true,
"data": {
"assignmentStatus": "PENDING",
"retryAfterMs": 400,
"amount": "51.69",
"paymentStatus": "PENDING",
"expiresAt": "2026-07-03T11:21:43Z",
"offer": { "…": "…" }
}
}

Re-call the same endpoint after retryAfterMs — the call is idempotent, and re-calling is the designed recovery. A ready-to-paste retry loop:

Node.js — select with 202 retry
async function selectOffer(paymentId, offerId, { maxWaitMs = 30_000 } = {}) {
const url = `https://api-staging.finomesh.com/api/v1/public/payments/${paymentId}/offers/${offerId}/select`;
const deadline = Date.now() + maxWaitMs;

while (true) {
const res = await fetch(url, { method: 'POST' });
const body = await res.json();
if (!res.ok) throw new Error(`select failed: ${res.status} ${body.error?.code}`);

if (res.status === 200) return body.data; // ASSIGNED — has depositAddress

// 202 — assignment pending; wait and re-call (idempotent).
if (Date.now() >= deadline) throw new Error('address assignment timed out');
await new Promise((r) => setTimeout(r, body.data.retryAfterMs ?? 500));
}
}

A ~30-second budget is generous — assignment normally completes within the first call's built-in wait. If you ever give up, the address also appears on GET /public/payments/{id} in the offer's depositAddress once assigned, so a later refresh recovers it.

Selecting again, or a different asset

  • Same asset, or another asset on the same network — you get the same deposit address back. Selection is idempotent per network.
  • An asset on a different network — a second address is derived for that network. One address per network, shared by all offers on it. The customer can switch assets freely; each network's address stays valid.

Select errors

HTTPCodeMeaning
403forbiddenYour server's source IP is not on the gateway's allowlist. The message echoes the IP we actually observed — behind a CDN, NAT, or egress proxy this can differ from the address your DNS advertises; whitelist the echoed one.
404not_foundUnknown payment or the offer is not on this payment
409conflictThe payment is not checkoutMode: "API", is no longer PENDING, or has expired

Step 4 — show the pay screen

Display, from the select response:

  • the deposit address (plus a QR code you generate),
  • the exact amount to send (amount — already includes any customer-paid fees),
  • the asset and network (offer.assetSymbol, offer.assetNetwork) — warn the customer to send only that asset on that network,
  • a countdown driven by expiresAt.

Expiry

Run your own countdown from expiresAt (equivalently createdAt + timeoutSeconds). When it reaches zero:

  • stop displaying the address — a deposit after expiry is not a valid payment;
  • optionally call the public POST /public/payments/{id}/expire endpoint to finalize immediately — it is idempotent and only expires a genuinely-overdue, still-unpaid payment;
  • either way, the platform expires the payment server-side within about a minute, and the EXPIRED webhook follows.

Step 5 — the outcome: webhook, then verify

There is no callback in API mode — the signed webhook is the only push channel. When the payment reaches a terminal status (SUCCESS, ACCEPTABLE, MISMATCH, EXPIRED) Finomesh POSTs the thin {"paymentId","status"} body with the X-Webhook-Signature HMAC to your webhookUrl, retrying on failure (6 attempts over ≈2.7 hours).

The golden rule is unchanged: the webhook is a trigger, not a source of truth. On receipt, verify the signature, dedupe by paymentId, then call POST /payments/{id}/verify and fulfil from the verify response — it is the sole carrier of the money figures.

Webhook down? Poll verify.

If your webhook endpoint is unreachable past the retry window (or you simply want belt-and-braces reconciliation), poll verify: POST /payments/{id}/verify is idempotent and returns PENDING harmlessly until the payment resolves. Run a reconciliation sweep that verifies any payment still unresolved on your side after its expiresAt — that way a missed webhook can never strand an order. See Reconciliation.

While the customer waits on your pay screen, poll your own backend for the outcome (which it learns from the webhook, a verify call, or the status poll below) — remember, the customer's browser never calls Finomesh.

Checking payment status

For a cheap internal status inquiry — driving your pay screen, deciding when to fire verify — poll the public payment read with statusOnly=true:

curl "https://api-staging.finomesh.com/api/v1/public/payments/{paymentId}?statusOnly=true"
{
"success": true,
"data": {
"paymentUid": "0d9f3a64-4f0c-4b6e-9f6e-2f4f4c1b8a21",
"status": "PENDING",
"isVerified": false,
"verifiedAt": null
}
}

A tiny, stable four-field payload — see the slim status object field table. isVerified tells you whether you have already verified the payment; reading it here never flips it — only verify does. Like every read of an API-mode payment, the call is source-IP gated (403 off-allowlist).

Need the full payment, not just its status? Call the same endpoint without statusOnly and you get the complete payment object back at any point in the payment's life — the same shape the create response embeds, with amounts, fees, and offers. Use it to re-fetch everything your pay screen needs after creation instead of persisting the create payload; once the payment resolves on-chain it additionally carries txHash, receivedAmount, and explorerTxUrl — a ready-to-use block-explorer link with the transaction hash already substituted, handy for your own receipt or order page:

curl "https://api-staging.finomesh.com/api/v1/public/payments/{paymentId}"

Two rules to build around:

  • Fulfil only from verify. The status poll and the full read are inquiries — convenient for driving your UI, never for fulfilment. Ship goods or credit accounts only after POST /payments/{id}/verify, the authoritative read.
  • No WebSocket, no hosted checkout. API-mode payments are rejected with 403 at the hosted checkout's WebSocket (/ws/payments/{id}), and opening an API-mode payment's paymentUrl in the hosted checkout does not work either — the flow is fully server-to-server. Your channels are the webhook (push), verify (authoritative pull), and this status poll (cheap pull).

Checklist

  • Server IPs (or CIDRs) added to the gateway's allowlist in the dashboard.
  • Create sends checkoutMode: "API" + webhookUrl, and no callbackUrl.
  • API key and the select call live only on your backend — the browser talks only to you.
  • Select handles 202 with a retry loop, and 403/409 as hard errors.
  • Pay screen shows the offer's exact amount, the right network, and a countdown from expiresAt; the address disappears at expiry.
  • Webhook handler verifies the signature, dedupes, responds 200 fast, then verifies.
  • Status inquiries use ?statusOnly=true; fulfilment happens only from verify, never from a poll.
  • A reconciliation sweep poll-verifies unresolved payments in case a webhook is missed.