Start here
5 sectionsAccept non-custodial crypto payments
Finality lets your site accept crypto payments, with funds landing directly in your own wallet—we never touch them. The flow: your backend calls one API to create a payment, you show the address to your customer, and once they pay we notify you via webhook within seconds. It's the payment-gateway experience you already know—minus the custody, freezes, and settlement delays.
Pick your path
Three ways to take crypto payments. They all settle the same way — straight to your own wallet — so pick whichever fits how you sell today.
Take payments without writing code
Everything here is done by clicking, copying and pasting. If you can install a WordPress plugin, you can do this.
First: set up your account (once)
- 1Sign in and open Settings. Paste the wallet address where you want to be paid — one per blockchain you want to accept. This is the address the money goes to, so double-check it.
- 2Open the API keys page and create a key. Copy it somewhere safe; it's shown once. Start with a key beginning sk_test_ if you want to rehearse without real money.
- 3Copy your webhook secret from the same page. The plugin needs it to confirm that incoming notifications really came from us.
If you have an online store
Install the plugin for your platform, then paste the API key and webhook secret you just copied. Crypto appears as a payment method at checkout, and paid orders update themselves.
| WooCommerce | Upload plugin → Settings → Payments | Full checkout integration. Orders move to Processing automatically once the money settles on-chain. |
| WordPress (no store) | Upload plugin → paste a shortcode | For tips, donations, memberships or a single product. Put [finality_pay amount="49"] on any page. |
| Shopify | Deploy the app → install to your store | Shopify only allows certified partners inside its checkout, so this app sends the customer a payment link after they order, then tags the order once it settles. |
If you don't have a store
You don't need a website at all. A payment link works over email, WhatsApp, Telegram, or printed as a QR code.
- 1In the dashboard, create a payment: enter the amount and what it's for.
- 2Copy the link, or download the QR code.
- 3Send it to your customer. They open it, pick which coin to pay with, and send the payment from any wallet — or straight from an exchange withdrawal.
- 4You get an email the moment it settles, and the payment shows up in your dashboard.
How you'll know you got paid
The money arrives in your wallet directly — there's no 'finality balance' in between. You'll see the payment in your dashboard within seconds, with a link to the blockchain record so you can verify it yourself.
How it works
Four steps, fully automated. You only implement steps 1 and 4:
When a customer checks out, your server calls POST /v1/intents with the amount, chain, token, and your order ID. You get back a dedicated pay address and a hosted checkout link.
Display the pay address (or a QR code, or our hosted checkout page). The customer sends a normal transfer from any wallet—no wallet connection or contract calls needed.
Our self-hosted full nodes detect the payment the moment it lands in a block—no waiting for multiple confirmations.
Once the payment is confirmed we POST a signed event to your webhook. You verify the signature and fulfill the order or redirect to a success page. Funds have already been split to your wallet atomically per your rules.
Integrate in 5 minutes
- 1Create an API key in the dashboard and configure your payout wallet address and webhook URL.
- 2When a customer checks out, call the create-payment endpoint from your backend to get a pay address.
- 3Show the address or QR code to the customer, or redirect them straight to our checkout page.
- 4Implement your webhook handler: verify the signature and treat payment.settled as a successful payment.
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"merchant_order_id": "your-order-9527",
"webhook_url": "https://your-site.com/webhooks/finality"
}'{
"id": "9aRtUe2c-4f1b-4c3a-9e77-2b8d5f0a1c33",
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"pay_address": "7xKq...dedicated pay address...3fBw",
"pay_url": "https://finality.money/pay/9aRtUe2c-...",
"status": "pending",
"expires_at": "2026-07-21T12:40:00Z"
}Subscriptions? Just as fast
To charge on a recurring cycle (memberships / SaaS) instead of one-time, it's three steps:
- 1Create a plan on the dashboard Subscriptions page (name / amount / cycle / currency) and grab the checkout_url. To dry-run at zero cost, create a sandbox test plan first.
- 2Drop the checkout_url onto your Subscribe button, appending ?ref=your-user-id for reconciliation.
- 3Reuse the same webhook handler: treat subscription.active as provision-service, subscription.charged as a successful renewal.
sandbox test plan: hit “Simulate payment success” on the hosted checkout and you'll instantly receive the subscription.active webhook—no real transfer needed. Swap in a live plan to go live. Full details in the Subscriptions section below.API keys
All server-side requests are authenticated with a Bearer token. The publishable key (pk_live_) is for frontend checkout components; the secret key (sk_live_) is server-side only—never expose it to the frontend or commit it to a repository.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxSecret keys are verified by hash, and the plaintext is also stored encrypted — so you can retrieve it any time from the dashboard or the key_secret field of GET /v1/merchant/keys. You do not have to copy it down at creation time. To rehearse the whole flow without moving real money, use a sk_test_ key: everything behaves identically, and the hosted checkout gains a "simulate payment" button. Sandbox mode is decided by the key prefix alone — never by a request field — so nobody holding a live key can forge a paid order.
Create a payment
Create a payment request. Returns a dedicated pay address; the payment is complete once the customer sends funds to it.
| chain | string | Chain to receive on: solana / base / bsc / robin (eth coming soon) |
| token | string | Token to receive: USDC / USDT |
| amount | string | Amount (USD, 1:1 for stablecoins). Use a string to avoid precision loss |
| merchant_order_id | string | Your order ID, unique. Used for reconciliation and idempotency |
| webhook_url | string? | Per-payment webhook URL, overrides the account default |
| success_url | string? | Success redirect URL: the customer is sent back here after paying (with intent_id + merchant_order_id). Passed by your server, so it's safe |
| splits | array? | Split across multiple recipients (see the Splits section) |
| expiry_secs | number? | Expiry in seconds. Defaults to 1800 (30 minutes). This is also the price-lock window for volatile assets. |
| metadata | object? | Custom key-value pairs, returned as-is in the webhook |
merchant_order_id returns the same payment intent—it won't charge twice. Safe to retry on network hiccups.Retrieve a payment
Fetch the latest status of a payment at any time. We recommend webhooks for real-time updates; use this endpoint for reconciliation or as a polling fallback when a webhook fails.
| pending | Waiting for the customer to pay |
| detected | Detected in a block (0-conf) |
| confirmed | Confirmation threshold reached |
| settled | Split atomically, funds received—safe to fulfill |
| expired | Expired without payment |
When a customer underpays
Under an exact-amount model, underpayment is a real and common case. The checkout endpoint returns three extra fields so you can show "how much is still owed" instead of leaving the customer waiting:
| received_atomic | string | Atomic units received so far (string). |
| remaining_atomic | string | Atomic units still owed. "0" once fully paid. |
| is_partial | boolean | true when something was paid but not enough. Both nothing-paid and fully-paid are false. |
/v1/checkout/:id, not on /v1/intents/:id. Not finding them on the latter isn't a bug. Also, a failed lookup degrades silently to received_atomic: "0", which is indistinguishable from "nothing paid".Checkout endpoints
If you use our hosted checkout (the checkout_url returned when you create a payment), you can skip this section — the customer opens it and pays, with nothing to integrate. What follows is for merchants building their own checkout UI.
checkout_url from the create response: the hosted page already handles QR codes, multi-currency selection, underpayment prompts, live settlement, and the success redirect. Build your own only if payment must live entirely inside your own UI.Read checkout state (including underpayment)
No auth — safe to call from the customer's browser. This is the only endpoint exposing underpayment state: received_atomic, remaining_atomic, and is_partial. Those three fields do not exist on /v1/intents/:id.
Build a payment transaction (connected wallet)
Returns an unsigned transaction for the customer's wallet to sign. This is optional — a customer can equally send a plain transfer to pay_address from any wallet with identical results. Use this only if you want a one-click connected-wallet flow.
| owner | string | Customer wallet address. Required — though only the Solana path actually reads it; the EVM path ignores it but the field must still be present or the request fails to parse. |
// EVM (ERC20)
{ "chain": "base", "token": "USDC", "is_native": false,
"to": "0x8335...", // for ERC20, to = token contract
"data": "0xa9059cbb...",
"value_wei": null,
"pay_address": "0xPay...", "amount_atomic": "49000000" }
// Solana
{ "chain": "solana", "token": "USDC", "is_native": false,
"solana_unsigned_tx": "<base64>",
"pay_address": "7xKq...", "amount_atomic": "49000000" }data is null rather than an error if calldata construction fails — check for it; (2) this endpoint does not validate order status, so settled or expired orders still return parameters — check status yourself; (3) a multi-currency order that hasn't selected a token returns 400, so call select first.Record a checkout impression
Call this from a custom checkout, or the conversion funnel's denominator stays zero and analytics shows no views. Always returns 204, even for an unknown order id, so it's safe to fire and forget. There is no server-side deduplication — handle that yourself (e.g. sessionStorage).
Sandbox testing (rehearse at zero cost)
Orders created with an sk_test_ key are sandbox orders and involve no real money. This endpoint forces one to settled and fires a webhook that is byte-for-byte the same shape as production, so your handler gets a full rehearsal before launch.
pending, and pending → settled is an illegal state transition — calling now returns 500. Wait until the order reaches detected. The hosted checkout's "simulate payment" button already handles this timing; watch for it when building your own.# (1) Create an order with a sandbox key
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "chain":"base", "token":"USDC", "amount":"49.00",
"merchant_order_id":"test-1",
"webhook_url":"https://your-site.com/webhooks/finality" }'
# (2) Once it reaches detected, simulate payment (a fresh pending order returns 500)
curl -X POST https://finality.money/v1/checkout/<intent_id>/simulate-pay
# (3) Your webhook endpoint receives a production-shaped event:
# { "event":"payment.settled", "settle_tx":"SANDBOX",
# "livemode":false, ... }| settle_tx | "SANDBOX" | Always the literal string "SANDBOX", not a real on-chain hash. Your reconciliation must tolerate it. |
| livemode | false | Always false. Production webhooks carry true — use it to tell test from real revenue. |
| WebSocket | — | Simulated payments do **not** push over WebSocket, only the webhook fires. Poll the checkout endpoint to reflect status in a UI. |
| Repeat calls | 200 | Once settled, further calls return 200 idempotently without re-firing the webhook. |
is_test field exists but is deliberately ignored — otherwise anyone holding a live key could forge a paid order). To go live, swap the key for sk_live_ and change nothing else.Multi-currency (let the customer choose)
Omit token when creating a payment and it enters multi-currency mode: the customer picks the chain and token at checkout. Good for a global audience where you'd rather not decide for them.
curl -X POST https://finality.money/v1/intents \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "amount": "25.00",
"merchant_order_id": "order-8802",
"currencies": ["USDC","SOL","ETH"] }'
# Response: no pay_address yet — waiting for the customer to choose
# { "id":"...", "mode":"multi_currency", "status":"awaiting_currency",
# "options":["solana:USDC","solana:SOL","base:USDC","base:ETH"],
# "pay_url":"https://finality.money/pay/..." }pay_address is null at this point and the status is awaiting_currency, because the address can't be derived until the token is known. The options array is what the customer will actually see — chains where you haven't configured a payout address are filtered out automatically.Customer selects a currency
Called once the customer chooses. No auth (it's called from the customer's browser). It does four things at once: locks the exchange rate, derives the address for that token, advances the status to pending, and registers the address with on-chain detection.
| currency | string | Either "chain:symbol" (recommended, e.g. "solana:USDC") or a bare symbol, which matches the first option with that symbol. |
curl -X POST https://finality.money/v1/checkout/<intent_id>/select \
-H "Content-Type: application/json" \
-d '{ "currency": "solana:USDC" }'
# Response: address derived, rate locked
# { "chain":"solana", "token":"USDC", "status":"pending",
# "pay_address":"7xKq...", "token_amount":"25.00",
# "amount_atomic":"25000000", "expires_at":"..." }409. That's a deliberate address-swap protection (an address already shown to the customer must not change underneath them). Re-submitting the same currency is idempotent and just refreshes the expiry. To genuinely change, let the order expire and create a new one.| Currency validation | 400 | A malformed "chain:symbol" rejects the whole request; a bad bare symbol is silently ignored, which can quietly shrink the option list. |
| Payout address filter | 400 | Chains without a configured payout address are dropped. If all are dropped, creation fails and tells you to configure an address first. |
| No splits | — | Splits are ignored in multi-currency mode; the full amount goes to your default payout address. Use fixed-currency mode when you need splits. |
| Expiry reset | 1800s | Selecting locks the rate for a fixed 1800 seconds, overriding the expiry_secs you set at creation. |
Orders & search
Pull your payment intents in bulk, filtered by time, status, or keyword, with cursor paging. This is the endpoint you'll use for daily reconciliation instead of fetching order IDs one at a time.
| period | string? | today / 7d / 30d. Any other value (or omitted) means all time. Note that today is literally the last 24 hours, not the calendar day. |
| status | string? | Comma-separated, e.g. detected,confirmed. Case-insensitive. |
| search | string? | Substring match against any of 4 fields: merchant_order_id, pay address, detected tx, settle tx. |
| limit | number? | Defaults to 100; the server clamps it to 1–5000. |
| cursor_created_at | string? | Paging cursor. Echo back the field of the same name from the previous page's next_cursor. |
| cursor_id | string? | Paging cursor. Must be sent together with cursor_created_at. |
created, awaiting_currency, pending, detected, confirmed, settled, expired, failed. The backend never emits detecting — to find "paid, awaiting confirmation", use detected,confirmed.Cursor paging
Keyset paging ordered by (created_at, id) descending — no OFFSET. Page 10,000 is as fast as page 1, and new orders arriving mid-scan can't cause duplicates or gaps.
First page
curl "https://finality.money/v1/merchant/intents?limit=50&status=settled" \
-H "Authorization: Bearer sk_live_..."
# The cursor comes back at the end (next_cursor is null when has_more=false)
# { "intents": [...], "has_more": true,
# "next_cursor": { "cursor_created_at": "2026-08-07T09:12:33Z",
# "cursor_id": "6f1c..." },
# "stats": { "total": 1284, "settled": 1190, "settled_usd": "48210.50" } }
# Next page: echo both next_cursor fields back as-is
curl "https://finality.money/v1/merchant/intents?limit=50&status=settled\
&cursor_created_at=2026-08-07T09:12:33Z&cursor_id=6f1c..." \
-H "Authorization: Bearer sk_live_..."cursor_id without cursor_created_at is silently ignored — you'll get page one again. This is the easiest mistake to make here.stats block deliberately does not match the list: stats responds only to period, ignoring status, search, and the cursor, and it excludes sandbox orders — while the intents list includes them. To total a filtered set, sum the list yourself.Full history for one order
Everything about a single order in one call: the order, every on-chain payment, the settlement, and every webhook delivery attempt. This is what you open when a customer says they paid but nothing shipped.
| intent | object | The full order record. |
| payments | array | Every on-chain payment, with the payer address, tx hash, amount, and detection time, oldest first. |
| settlement | object|null | Settlement record (split detail, protocol fee, invoice number). null until settled. |
| webhooks | array | Delivery records with attempt count, last error, and dead-letter flag. Capped at 50, newest first. |
Coupons
Discount one-off payments. Create a coupon code (e.g. SAVE20 = 20% off); the customer enters it at checkout and pays the discounted price. You can also reference a coupon when creating the payment, so the customer lands on an already-discounted price with nothing to type.
Create coupons with sk_live. Codes are unique within your account (case-insensitive).
| code | string | Coupon code (e.g. SAVE20). Unique within your account |
| discount_type | string | "percent" (percentage off) or "fixed" (flat amount off) |
| discount_value | string | percent: discount points (20 = 20% off); fixed: USD amount to subtract |
| max_redemptions | number? | Max redemptions (0 or omitted = unlimited) |
| expires_at | string? | Expiry (ISO 8601; omitted = never expires) |
curl -X POST https://finality.money/v1/coupons \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "SAVE20", "discount_type": "percent", "discount_value": "20" }'Pass coupon_code when creating a payment; the backend validates it and charges the discounted amount—the customer sees the discounted price at checkout:
{
"chain": "base",
"token": "USDC",
"amount": "100.00",
"merchant_order_id": "order-8801",
"coupon_code": "SAVE20"
}zero contract changes and 100% non-custodial.Webhooks
When a payment's status changes, we POST a signed event to the URL you configured. This is your primary way to confirm a successful payment.
The one that means "ship it"
Funds have been split on-chain and are in your wallet. This is the only event you should fulfil on.
| payment.settled | Ship ✓ | The contract split the payment and your share is in your wallet. Irreversible. This is your green light. |
The two that mean "stop"
Both mean money moved, but you must not deliver. Each needs a human to look at it.
| payment.blocked | Never ship | The payer's address matched a sanctions list, so we stopped the settlement before it happened. The money was never moved to you. Do not deliver — this needs a compliance decision, not an automated one. |
| payment.late | Never ship | Payment arrived after the order expired. The funds are sitting in that order's payment address; you can recover them from the dashboard. Contact the customer before sending anything — the order was already closed. |
Everything else (informational)
Useful for showing status and reconciling, but not fulfilment signals on their own.
| payment.detected | Don't ship | Seen on-chain but not final yet (zero confirmations). Good for showing "payment received, confirming…" — risky to ship on. |
| payment.partial | Don't ship | The customer paid less than the total. The order stays open — if they top it up, settlement proceeds normally. Show them how much is still owed. |
| payment.overpaid | Ship ✓ | They paid more than the total. The full amount reached you, so the order is complete — the excess stays at the payment address and can be withdrawn from Unsettled funds in your dashboard. |
| payment.expired | Don't ship | Nobody paid before the window closed. |
| payment.failed | Don't ship | Settlement failed on-chain. It retries automatically; check your dashboard if it keeps happening. |
| payment.refunded | — | Legacy, no longer emitted: the platform no longer provides a refund-recording endpoint. Once settled, funds are in your payout address and refunds are yours to handle. Listed here only for reference if your existing webhook handler still matches on it. |
| payment.confirmed | — | Reserved. Under the current settle-on-detection model this is not emitted. |
Subscription events
| subscription.active | — | Subscription started and the first cycle was charged. |
| subscription.charged | Ship ✓ | A cycle was charged successfully — extend the customer's access. |
| subscription.insufficient_balance | — | The customer's vault is running low and the next charge may fail. Nudge them to top up. |
| subscription.expired | — | The grace period passed without a successful charge. The subscription is permanently over; the customer must subscribe again. |
| subscription.canceled | — | The customer cancelled on-chain. Stop providing the service. |
| subscription.completed | — | A fixed-term subscription finished all of its agreed cycles. Normal completion — not a cancellation and not a missed payment. |
| subscription.migrated | — | The customer switched plans (upgrade or downgrade). Not a cancellation — keep the service running; note the subscription_id changes. |
| subscription.trial_ending | — | The free trial ends soon — the first real charge is coming. |
| subscription.renewal_reminder | — | A renewal is coming up. Good moment to remind them. |
subscription.migrated is not a cancellation. When a customer upgrades or downgrades, the old subscription is closed on-chain and a brand-new one is created — so the subscription_id changes. The payload carries old_subscription_id, new_subscription_id, old_plan_id and new_plan_id. If you key your records on subscription_id, you must re-point that mapping to new_subscription_id, or the next subscription.charged will arrive under an id you don't recognise and look like a stranger's payment. Never treat this event as a stop signal: the customer just paid to change plans, and cutting off their service is exactly the wrong move. Your client_reference_id and metadata carry over to the new subscription, so keying on client_reference_id instead avoids the whole problem.POST https://your-site.com/webhooks/finality
X-Finality-Signature: t=1760000000,v1=3a7bd3e2360a... // HMAC-SHA256(secret, "{t}.{raw body}"), hex
X-Finality-Event-Id: 8f2c1a9e-... // stable id — use it to deduplicate
X-Finality-Event-Type: payment.settled
{
"event": "payment.settled",
"intent_id": "9aRtUe2c-4f1b-4c3a-9e77-2b8d5f0a1c33",
"merchant_order_id": "your-order-9527",
"chain": "solana",
"token": "USDC",
"amount": "1240.00",
"livemode": true, // false for sandbox (sk_test_) orders
"settle_tx": "5Kd...9Qm", // on-chain proof
"payer": "3fBw...7xKq", // who actually paid, read from the chain
"metadata": { "...": "whatever you sent at creation" }
}What happens when delivery fails
| Success | 2xx | Any 2xx counts as delivered. Everything else is treated as a failure and retried. |
| Max attempts | 16 | After 16 failures the event goes to the dead-letter queue and automatic retries stop. |
| Backoff | 1s → 1h | Exponential with full jitter, from 1 second up to 1 hour, spanning roughly 24–72 hours in total. |
| Per-attempt timeout | 10s | No response within 10 seconds counts as a failure. Return 200 first, then do slow work. |
| Circuit breaker | 5 / 60s | 5 consecutive failures pause delivery for 60 seconds before probing again. Attempts aren't consumed while open. |
dead=true and replay them.Manual replay
Use this to backfill events missed during an outage or a bad deploy. Find the failed event IDs with GET /v1/merchant/webhooks first.
# First find which events failed (dead=true means dead-lettered)
curl "https://finality.money/v1/merchant/webhooks?intent_id=6f1c...&limit=100" \
-H "Authorization: Bearer sk_live_..."
# Redeliver one of them
curl -X POST https://finality.money/v1/merchant/webhooks/8f2c1a9e-.../replay \
-H "Authorization: Bearer sk_live_..."
# → { "ok": true, "replayed": "8f2c1a9e-..." }intent_id filtering and limit (max 500) — there's no paging cursor. Pull periodically if you need long-term history.Rotating the signing secret
Rotate immediately if you suspect the secret leaked. Returns a new whsec_ secret. This endpoint accepts a logged-in session only; API keys can't call it.
Subscriptions (auto-renewal)
Subscriptions charge on a recurring cycle like Stripe — but fully non-custodial: the money stays in the customer's own on-chain vault, the contract can only pull the agreed amount each cycle, and they can withdraw any time. You do not need to write code: you can create a plan and generate customer links entirely from the dashboard.
On the Subscriptions page, fill in name / price / cycle / currency. That is the whole setup.
Click Generate link on the plan row to create a personal link for one customer, and send it to them. No code needed.
They open the link, connect a wallet and pay the first cycle. Every renewal after that happens automatically.
You see the subscription appear in the dashboard right away. If you have a website that needs to switch a customer on automatically, add webhooks (below) — otherwise you can stop here.
If you do not write software, this is the whole job. It takes about five minutes in the dashboard.
- 1Open Settings and paste the wallet address you want to be paid at, on the blockchain you want to accept. Without this, customers cannot pay you.
- 2Go to Subscriptions → Create plan. Enter a name, a price, and how often to charge (monthly, yearly, or your own interval). Save.
- 3Your new plan appears in the list. On its row, click Generate link, type the customer's name or your own reference for them, and click Create.
- 4Copy the link it shows you and send it to that customer however you normally talk to them — email, WhatsApp, anywhere.
You are done. When they pay, the subscription shows up on your Subscriptions page and renews on its own. Each customer gets their own link, so you always know who paid.
Doing it with code instead
Create a plan from the dashboard, or via the API (sk_live auth). The response includes a shareable checkout_url.
| name | string | Plan name shown to customers (e.g. Pro Monthly) |
| amount | string | Amount per cycle. usd mode = US-dollar figure (stablecoin 1:1); native mode = token quantity (e.g. 1 SOL) |
| pricing_mode | string | "usd" (stablecoin-priced) or "native" (token-denominated); defaults to usd |
| chain | string | Settlement chain, e.g. solana / bsc / base |
| token | string | Token symbol, e.g. USDC / SOL |
| cycle | string | Cycle: month / year / week / day / or seconds (any custom interval, min 3600) |
| grace_seconds | number? | Grace period in seconds: tolerance window after due date before the subscription expires. Defaults to the platform's 7 days |
| trial_seconds | number? | Trial period in seconds: delays the first charge. Defaults to 0 (no trial) |
| max_cycles | number? | Total number of charges. Omit or 0 = renews until cancelled (the default, and how every plan behaved before this option existed). N > 0 = stops on its own after N charges; the limit is written into the vault address and enforced on-chain, so charge N+1 reverts — nobody can take more, not even us. |
| public_checkout | boolean? | Set true to get one shareable link that anyone can subscribe through (good for public pricing pages and donations). Omit it and the plan is invite-only: checkout_url comes back null and you create a link per customer instead. This cannot be changed after the plan is created. |
{
"id": "0f4c8e2a-...",
"name": "Pro Monthly",
"amount": "9.99",
"chain": "solana",
"token": "USDC",
"cycle_seconds": 2592000,
"max_cycles": 0, // 0 = renews until cancelled
"public_checkout": false, // you did not pass public_checkout, so this plan is invite-only
"checkout_url": null, // null because the plan is invite-only — this is expected
"payout_ready": true // false means you have not set a payout address yet: customers cannot pay
}checkout_url being null is normal, not an error. Plans are invite-only unless you pass public_checkout: true, because an open link cannot tell you who paid. To get a link for an invite-only plan, create a personal link for a customer (below).Full backend example · Node.js
Wire up plan creation, an identity-carrying subscription link, and webhook handling into one runnable Express service. Create the plan with your sk_live, verify with the webhook_secret, locate your own user via client_reference_id, and provision / renew / deactivate by event type.
import express from "express";
import crypto from "crypto";
const SK = process.env.FINALITY_SECRET_KEY; // sk_live_... (server-side only)
const WEBHOOK_SECRET = process.env.FINALITY_WEBHOOK_SECRET;
const API = "https://finality.money";
// 1. Create a plan (once at startup or from the dashboard; keep the plan_id)
async function createPlan() {
const r = await fetch(`${API}/v1/subscriptions/plans`, {
method: "POST",
headers: { Authorization: `Bearer ${SK}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Pro Monthly", amount: "9.99", pricing_mode: "usd",
chain: "solana", token: "USDC", cycle: "month",
}),
});
return await r.json(); // { id, checkout_url, ... }
}
// 2. Build a subscribe link for a signed-in user (?ref= your own user ID)
function subscribeLink(plan, userId) {
return `${plan.checkout_url}?ref=${encodeURIComponent(userId)}`;
}
// 3. Receive webhooks: verify signature (raw bytes!) + dedupe + handle by event type
const app = express();
// Header looks like t=1735689600,v1=abc123… — split on the comma first, do not compare the whole string.
// The signed message is "timestamp + a dot + the raw body" — the timestamp is inside the HMAC too.
function verifySignature(rawBody, header) {
if (typeof header !== "string") return false;
let t = null, v1 = null;
for (const part of header.split(",")) {
const [k, val] = part.trim().split("=");
if (k === "t") t = Number(val);
else if (k === "v1") v1 = val;
}
if (!Number.isFinite(t) || !v1) return false;
// Reject anything older/newer than 5 minutes. This is what stops replays.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]);
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(signed).digest("hex");
// * Compare lengths first: timingSafeEqual THROWS RangeError on mismatch (not false),
// a short signature header would otherwise 500 this endpoint.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/finality", express.raw({ type: "*/*" }), (req, res) => {
if (!verifySignature(req.body, req.headers["x-finality-signature"]))
return res.sendStatus(400);
const evt = JSON.parse(req.body); // parse only after the signature checks out
const userId = evt.client_reference_id; // the user ID you passed via ?ref=
const eventId = req.headers["x-finality-event-id"];
if (alreadyHandled(eventId)) return res.sendStatus(200); // idempotent — prevents double provisioning
switch (evt.event) {
case "subscription.active": activateUser(userId, evt.subscription_id); break;
case "subscription.charged": extendUser(userId, evt.next_charge_at); break;
case "subscription.expired": deactivateUser(userId); break;
case "subscription.completed": deactivateUser(userId); break; // paid in full — not a failure
}
markHandled(eventId);
res.sendStatus(200);
});
app.listen(3000);Put the link on your site
Use checkout_url directly as a button link. Configure the success redirect in the dashboard—set a global default under Settings, or specify one per plan at creation (blank falls back to the global). It's stored server-side, so customers can't tamper with the redirect by editing the link—the safest and simplest approach. The user identity ref (unique per user) is passed dynamically via a query parameter:
| ref | string? | A hint only — NOT the trusted client_reference_id. Anything in ?ref= is typed into the address bar by the customer, so they can change it. It arrives in the webhook as untrusted_ref, and client_reference_id stays null. Never grant access based on it; to get a trusted ID, create a personal link (below). |
| success_url | string? | Success redirect URL. Recommended in the dashboard (per-plan or global default); can also be overridden via this query param. subscription_id and client_reference_id are appended automatically. |
| metadata | json? | Any custom key–value pairs (URL-encoded JSON object), echoed back in webhooks. |
https://finality.money/subscribe/0f4c8e2a-...?ref=user_8837success_url query param only when you need to set it dynamically per case (URL-encode it). After success the customer sees a “Return to merchant” button and is redirected automatically after 5 seconds; your landing page receives subscription_id to provision the service.Subscription webhook events
Every point in the subscription lifecycle POSTs to your configured webhook URL (shared with one-time payments, same URL and signing secret, set on the Settings page). All events carry owner_address, client_reference_id, and metadata for precise reconciliation.
| subscription.active | event | First cycle received, subscription active—provision service now |
| subscription.charged | event | A renewal cycle was charged successfully |
| subscription.insufficient_balance | event | Vault balance is below the next cycle; renewal will fail (prompt the user to top up) |
| subscription.expired | event | Still uncharged past the grace period, subscription voided (user must resubscribe) |
| subscription.canceled | event | The customer cancelled on-chain. Stop providing the service. |
| subscription.completed | event | A fixed-term subscription finished all of its agreed cycles. Normal completion — not a cancellation and not a missed payment. |
| subscription.migrated | event | The customer switched plans (upgrade or downgrade). Not a cancellation — keep the service running; note the subscription_id changes. |
| subscription.trial_ending | event | The free trial ends soon — the first real charge is coming. |
| subscription.renewal_reminder | event | A renewal is coming up. Good moment to remind them. |
subscription.migrated is not a cancellation. When a customer upgrades or downgrades, the old subscription is closed on-chain and a brand-new one is created — so the subscription_id changes. The payload carries old_subscription_id, new_subscription_id, old_plan_id and new_plan_id. If you key your records on subscription_id, you must re-point that mapping to new_subscription_id, or the next subscription.charged will arrive under an id you don't recognise and look like a stranger's payment. Never treat this event as a stop signal: the customer just paid to change plans, and cutting off their service is exactly the wrong move. Your client_reference_id and metadata carry over to the new subscription, so keying on client_reference_id instead avoids the whole problem.subscription.completed means the customer paid in full. It only fires for plans created with max_cycles, and it means every agreed charge went through. Stop providing the service the same way you would at the end of any paid term — but do not treat it like subscription.expired (a missed payment) or subscription.canceled (the customer opted out). This customer did nothing wrong, so dunning emails or win-back campaigns aimed at lapsed users are the wrong response. The payload carries cycles_charged and max_cycles so you can reconcile. There is no way to extend a finished subscription — the cycle limit is welded into the vault address — so if you want the customer to continue, invite them to subscribe again.POST https://your-site.com/webhooks/finality
X-Finality-Signature: t=1760000000,v1=3a7bd3e2360a...
X-Finality-Event-Type: subscription.active
X-Finality-Event-Id: evt_5f8c1a2b-...
{
"event": "subscription.active",
"subscription_id": "7b1e9f04-...",
"plan_id": "0f4c8e2a-...",
"livemode": true,
"chain": "solana",
"token": "USDC",
"vault": "9xQ...v1k",
"owner_address": "3fBw...7xKq",
"client_reference_id": "user_8837",
"metadata": { "plan_tier": "pro" },
"amount": "9.99",
"subscribe_tx": "5Kd...9Qm",
"next_charge_at": "2026-08-30T12:00:00Z"
}client_reference_id — the ID you set server-side when creating a personal link — to map this subscription to a user in your system. It is the only identity field a customer cannot alter. owner_address is the subscriber's wallet, and subscription_id is used for later lookups and reconciliation.X-Finality-Event-Id header. Use it as your idempotency key—process each Event-Id exactly once so delivery retries can't double-provision or double-charge. The livemode field in the payload (true = production, false = sandbox, same as Stripe) tells live from test events; events from a sandbox test plan have livemode set to false.Private subscription offers (per-customer discounts)
Want to give a specific customer their own deal (first month free, a private discount)? Create a one-time "subscription offer"—a time-limited, single-use link that can override the plan's promo terms, with the discount welded into the vault too (100% non-custodial, the customer can't alter it). It mirrors Stripe's Checkout Session (subscription mode). Just send the returned checkout_url to that customer.
| plan_id | string | The subscription plan ID to bind |
| client_reference_id | string? | Your own user/order ID for this customer. It is stored with the subscription and returned in every webhook (first payment and every renewal) — this is how you match an on-chain payment back to an account in your system. Strongly recommended. |
| promo_amount | string? | Override promo price (same unit as the plan amount; 0 = free during promo). Leave blank to keep the plan's promo |
| promo_cycles | number? | Override promo cycles (first N cycles at promo price). Blank = don't override, use the plan's |
| customer_email | string? | Customer email (optional, so you can tell who each offer went to) |
| expires_in_secs | number? | Offer lifetime in seconds (expired links auto-invalidate). Defaults to the platform default |
{
"id": "3d9f...",
"checkout_url": "https://finality.money/subscribe/0f4c8e2a-...?session=3d9f...",
"status": "open",
"expires_at": "2026-08-05T00:00:00Z"
}Full integration (two endpoints on your side)
Here is the whole loop. You create a link tagged with your own user ID, and we hand that same ID back when the payment lands — so your server knows whose account to activate. Replace the API key, the plan_id, and your own activation logic.
// Only two things below need changing.
const FINALITY_KEY = "sk_live_..."; // ← 1. CHANGE THIS: Dashboard → Developers → API keys
const PLAN_ID = "0f4c8e2a-..."; // ← 2. CHANGE THIS: Dashboard → Subscriptions → your plan
// Your site: user clicks "Subscribe" → you create their personal link.
app.post("/subscribe", async (req, res) => {
const r = await fetch("https://finality.money/v1/subscriptions/sessions", {
method: "POST",
headers: {
"Authorization": "Bearer " + FINALITY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
plan_id: PLAN_ID,
client_reference_id: req.user.id, // ← YOUR user id. This comes back to you.
}),
});
const session = await r.json();
res.redirect(session.checkout_url); // send them to checkout
});// Your site: we call this when they pay, and on every renewal.
app.post("/webhooks/finality", async (req, res) => {
// Always verify the signature first (see the Webhooks section).
if (!verifyFinalitySignature(req)) return res.sendStatus(400);
const { event, client_reference_id, subscription_id } = req.body;
if (event === "subscription.active" || event === "subscription.charged") {
// client_reference_id is exactly the req.user.id you sent above.
await db.users.update(client_reference_id, {
subscribed: true,
finality_subscription_id: subscription_id,
});
}
// "completed" = fixed-term plan paid in full. Same action (access ends),
// but it is NOT a failure — never dun or win-back these customers.
if (event === "subscription.canceled" || event === "subscription.expired"
|| event === "subscription.completed") {
await db.users.update(client_reference_id, { subscribed: false });
}
res.sendStatus(200); // respond 200 quickly; we retry on failure
});Changing plans involves migrating to a new address; it has its own section: Upgrades & downgrades.
Subscription read APIs (bots / your own backoffice)
| GET /v1/subscriptions | sk | List all your subscriptions (each with live balance and cycles remaining) — the data source for a renewal-health board. |
| GET /v1/subscriptions/overview | sk | Per-plan aggregate: subscriber count, estimated take-home, collected, needs-attention. |
| GET /v1/subscriptions/plans | sk | List all plans. |
| GET /v1/subscriptions/plans/:id/insights | sk | Deep insight for one plan: conversion funnel, drop-off reasons, wallet/chain breakdown. |
Which link to send
There are two kinds of subscription link, and a plan is locked to one of them. Sending the wrong one is the most common reason a customer tells you "your page doesn't work", so it is worth two minutes to know which is which.
/subscribe/{plan_id}One link, reusable, share it anywhere: your pricing page, a tweet, a QR code. Because anyone can open it, you cannot tell who paid from the link alone. Best for donations, tips, and public pricing pages.
/subscribe/s/{token}You create it for one specific customer and tag it with their ID from your own system. It works once, then expires. When they pay, that ID comes back to you, so your server knows exactly whose account to activate.
Links created before this format still work — the older /subscribe/{plan_id}?session=… form opens fine, so anything you already sent is unaffected. Always use the checkout_url exactly as returned; never build it yourself.
Which identifier you can trust
Every webhook carries two identity fields, and they are not equally trustworthy. client_reference_id is the value you set server-side when you created a personal link — the customer never sees or touches it. untrusted_ref is whatever was in the ?ref= parameter of the URL they opened, which they can edit to any value they like, including another customer's ID.
{
"event": "subscription.active",
"subscription_id": "9c1e...",
// ✅ TRUSTED — set by you, server-side, when you created the session.
// The customer cannot influence this. Safe to activate an account on.
"client_reference_id": "user_8837",
// ⚠️ NOT TRUSTED — taken from ?ref= in the URL the customer opened.
// They can edit it to anything, including someone else's ID.
// Use it as a hint for reconciliation. NEVER to decide who gets access.
"untrusted_ref": null
}client_reference_id. A customer paying with real money on-chain is not proof of who they are — the payment is genuine, but the name attached to it is only genuine if it came from you. If client_reference_id is null, you do not know who paid; match the payment by hand rather than trusting untrusted_ref.What the customer sees when a link is wrong
Every one of these shows a plain-language explanation and a next step — never a blank page or a raw error code.
| Situation | Status | What the customer sees |
|---|---|---|
| Open link used for an invite-only plan | 403 | "This link needs an invitation" — asks them to request a personal link from you. |
| Link is mistyped or was only partly copied | 404 | "This link doesn't work" — tells them the usual cause is an incomplete copy, and to ask you for the full link. |
| Personal link already used, or expired | 410 | "This invitation is no longer valid" — asks them to request a new one from you. |
Upgrades & downgrades
When a customer switches plans, unused time on the current plan is credited against the first charge of the new one. It's a single atomic transaction — close the old subscription, open the new one — and the customer signs once.
nobody can alter the terms of an existing subscription, including us. The cost is that plan changes require a new address.Step 1 — compute proration and build the transaction
| new_plan_id | string | Target plan ID. |
| owner | string | Customer's wallet address. |
The backend computes credit_atomic as "amount actually paid this period × seconds remaining ÷ cycle seconds", rounded down. On an upgrade the customer pays the difference; on a downgrade, if the credit exceeds a full new-plan cycle it converts into free cycles (promo_cycles) with the remainder becoming one transitional cycle.
{
"chain": "base",
"direction": "upgrade",
"new_vault": "0xNewVault...",
"new_plan_id": "0f4c8e2a-...",
"new_nonce": 2,
"credit_atomic": "18300000",
"first_amount_atomic": "80700000",
"promo_amount_atomic": "80700000",
"promo_cycles": 1,
"promo_transition_atomic": "0",
"to": "0xGateway...",
"data": "0x...",
"value_wei": null
}reverts if the numbers don't match. So even if we miscalculated — or cheated — the chain won't execute it. You don't have to trust our arithmetic.Step 2 — confirm after broadcasting
Send back the tx hash after the customer broadcasts. The new subscription starts as confirming and flips to active once the new vault is confirmed on-chain.
| new_plan_id | string | Target plan ID. |
| owner | string | Customer wallet address. Required — it also feeds the ownership assertion, so it cannot be omitted. |
| migrate_tx | string | Migration transaction hash. |
| new_vault / new_nonce | string / number | The new vault address and nonce from step one, echoed back. |
| promo_* (3) | string / number | The three promo fields from step one, echoed back. |
| message / signature | string | The customer's wallet signature proving identity. The message must contain the owner address. |
| Same chain and token | 400 | Switching chain or token isn't a migration — the customer must start a new subscription (a vault-derivation requirement). |
| Old subscription must be live | 409 | active / past_due / confirming can migrate. Cancelled or expired cannot. |
| Target plan must be active | 410 | An archived plan can't be a migration target. |
| Affiliate credit carries over | — | The referring affiliate keeps credit after an upgrade. Payout recipients, however, follow the new plan and do not carry over. |
Customer self-service
These three power your customer-facing UI. All of them return unsigned transactions only — the customer's own wallet signs, and the contract enforces that only the owner can execute.
| POST …/recharge-prepare | from, amount_atomic | Top up the vault so charges can continue. Anyone may fund it. |
| POST …/withdraw-prepare | owner, amount_atomic | Withdraw the vault balance. Pass "0" to withdraw everything. |
| POST …/cancel-prepare | owner | Cancel the subscription; the keeper stops charging. The balance remains withdrawable. |
"abc", scientific notation, or a string with stray whitespace return 400 rather than silently draining the vault — deliberately, so a frontend bug can't empty a customer's balance.Plan-change allowlist (required — without it customers can't switch)
By default no plan can be switched to. Use the upgrade_to_plan_ids array in the body to declare which plans subscribers of this plan may move to; only same-chain, same-token plans are eligible. Skip this and the customer portal's plan switcher stays empty forever — which reads as "the feature is broken".
Put a manage-subscription link on your site
Your customers manage their own subscriptions at finality.money/account — top up, withdraw, cancel, upgrade or downgrade, all signed with their wallet. Nothing to build and nothing to host: you only need to link to it. Without that link, every plan change and every cancellation reaches you as a support email instead.
The simplest version
Just a plain link. When customers connect the wallet they paid with, they see every subscription on that wallet — including ones from other merchants. No keys, tokens, or redirect URLs to configure.
<!-- Customers manage everything themselves — no integration needed -->
<a href="https://finality.money/account">Manage subscription</a>Link straight to one subscription
Add ?sub=<subscription_id> and the page scrolls to that subscription and highlights it after sign-in. Use this from an account page or a renewal email, where you already know which subscription the customer means.
<!-- Opens straight on this subscription after sign-in -->
<a href="https://finality.money/account?sub=7b1e9f04-...">Manage subscription</a>?sub= even when your stored id may be out of date.Where subscription_id comes from
Two sources, both of which you are likely already reading:
| Every subscription event | webhook | Each subscription webhook carries subscription_id at the top level, alongside client_reference_id. |
| GET /v1/subscriptions | API | Lists your subscriptions with the same id. Useful for backfilling customers who subscribed before you stored it. |
subscription_id, so a stored id goes stale exactly for your upgrading customers. Two rules keep this from biting you: key your own records on client_reference_id (your user id, which is carried over automatically), and refresh the stored id whenever subscription.migrated or subscription.active arrives.Complete example
Store the id when the webhook arrives, then render the link. Falling back to the plain /account URL when you have no id stored means the link always works.
// 1) Store the subscription id when the webhook arrives
app.post("/webhooks/finality", express.json(), (req, res) => {
const evt = req.body;
const userId = evt.client_reference_id; // the user id you passed in when they subscribed
switch (evt.event) {
case "subscription.active":
case "subscription.migrated": // plan switched: the id changed, overwrite it
db.users.update(userId, { finalitySubId: evt.subscription_id });
break;
}
res.sendStatus(200);
});
// 2) Render the manage link on your account page
function manageUrl(user) {
const id = user.finalitySubId;
return id
? `https://finality.money/account?sub=${id}`
: "https://finality.money/account"; // no id stored: the plain link still works
}Programmatic access (wallet-signature API, no API key)
Customers and affiliates have no API key and don't need one — the wallet signature is the identity: build the message from the exact template below, sign it with the wallet's private key, and any bot or script can call these endpoints. Nobody can query someone else's data: without the private key there is no valid signature. A signature stays valid for 7 days (read-only).
Sign in to Finality
Wallet: <WALLET_ADDRESS>
Nonce: <32_HEX_RANDOM>
Issued At: <ISO8601_UTC_NOW>
This request will not trigger a transaction or cost any gas.| POST /v1/account/subscriptions | customer | All subscriptions under this wallet (across merchants), each with live balance and health. |
| POST /v1/account/subscriptions/:id/close-prepare | customer | One-click close: builds the unsigned "stop charging + withdraw all + reclaim deposit" transaction (Solana only). |
| POST /v1/affiliate-portal/me | affiliate | All affiliate profiles under this wallet (across merchants): referral links + performance. |
curl -X POST https://finality.money/v1/account/subscriptions \
-H "Content-Type: application/json" \
-d '{
"owner": "<WALLET_ADDRESS>",
"message": "<SIGNED_MESSAGE_ABOVE>",
"signature": "<BASE58_OR_HEX_SIGNATURE>"
}'
# -> returns owner plus a subscriptions arrayWebSocket push
Webhooks are the server-to-server channel you fulfill orders on; WebSockets are for your UI, so a customer sees "received" the instant it lands instead of watching a spinner. Connect to wss://finality.money.
Per-payment settlement push
Requires intent_id. Without it the connection still upgrades, then receives a single {"type":"error"} frame and closes.
const ws = new WebSocket(
"wss://finality.money/v1/ws?intent_id=" + intentId
);
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === "error") { console.error(m.error); return; }
// this arrival (not a running total)
console.log(m.chain, m.token, m.amount_atomic, m.sender, m.tx_hash);
// * to know whether it's fully paid, re-read the checkout endpoint
fetch(`/v1/checkout/${intentId}`)
.then(r => r.json())
.then(v => { if (!v.is_partial && v.remaining_atomic === "0") fulfill(); });
};| type | "payment" | Always "payment". Error frames use type "error". |
| chain / token | string | Chain and token of this arrival. |
| address | string | The receiving address. |
| sender | string|null | Payer address, read from the chain. May be null. |
| amount_atomic | number | ★ The amount of THIS arrival in atomic units — not a running total. An underpayment reports just that instalment. |
| tx_hash | string | On-chain transaction hash. |
type:"payment" message. To know whether it's enough, re-read GET /v1/checkout/:id and check is_partial and remaining_atomic. Note also that sandbox simulated payments never reach this channel.Subscription confirmation push
Requires sub (the subscription id). This is a one-shot connection: it closes itself once a terminal state is delivered. On connect it first checks the database and, if the subscription already reached a terminal state, pushes once and closes — so you can't miss a confirmation that happened before you connected.
{ "type": "subscription",
"subscription_id": "3d9f...",
"status": "active", // active or failed
"subscribe_tx": "0xabc..." }Merchant-wide on-chain event stream
Pushes every on-chain event belonging to your account — the live activity feed for a dashboard. Authentication is unusual here: it uses a ?token=<JWT> query parameter rather than an Authorization header, because browser WebSocket handshakes cannot carry custom headers. Only JWTs work; API keys are rejected. A missing token returns HTTP 401 without upgrading the connection.
{ "kind": "payment_settled", // payment_settled / subscription_created / charged / canceled / withdrawn / vault_topup
"chain": "base",
"subject": "0xPayAddr...", // subscription events = vault address; payment events = pay address
"actor": "0xPayer...",
"amount_atomic": 49000000, // * a number here (the REST version returns a string)
"token": "USDC",
"tx_hash": "0xabc...",
"slot": 28374611 }The same data has a REST form for initial page load. limit ranges 1–200, default 50. Mind the scope difference: the REST version returns subscription events only (it inner-joins the subscriptions table), so payment events never appear there — while the WebSocket pushes both. Also amount_atomic is a string in REST and a number over WebSocket.
Verify webhook signatures
Every webhook carries a signature in the X-Finality-Signature header, shaped like t=1760000000,v1=3a7bd3e2…. Here t is the Unix timestamp (seconds) of this delivery, and v1 is the hex of HMAC-SHA256(secret, timestamp + a dot + the raw body). Verify it with your webhook secret to confirm the request really came from Finality.
import crypto from "crypto";
// Header format: "t=<unix seconds>,v1=<hex hmac>"
// The signed message is `${t}.${rawBody}` — the timestamp is INSIDE the HMAC,
// so it can't be moved forward without the secret.
const TOLERANCE_SECONDS = 300; // 5 minutes, same default as Stripe
// Constant-time compare that never throws.
// timingSafeEqual requires equal-length buffers — it throws RangeError otherwise,
// and Buffer.from(undefined) throws TypeError when the header is missing.
// Both are trivially triggerable by an attacker, so check length first.
function verify(rawBody, signature, secret) {
if (typeof signature !== "string") return false;
// 1) Parse "t=...,v1=..." (ignore unknown vN= parts so future versions don't break this)
let t = null, v1 = null;
for (const part of signature.split(",")) {
const [k, val] = part.trim().split("=");
if (k === "t") t = Number(val);
else if (k === "v1") v1 = val;
}
if (!Number.isFinite(t) || !v1) return false;
// 2) Reject anything outside the tolerance window — THIS is what stops replays.
// Without it a captured request stays valid forever.
// Math.abs also rejects timestamps from the future.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
// 3) Verify the HMAC over "${t}.${rawBody}"
const signedPayload = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]);
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/finality", express.raw({ type: "*/*" }), (req, res) => {
// req.body is a raw Buffer here — never JSON.parse before verifying.
if (!verify(req.body, req.headers["x-finality-signature"], process.env.FINALITY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
// Deduplicate: the same event can be delivered more than once.
const eventId = req.headers["x-finality-event-id"];
if (await alreadyProcessed(eventId)) return res.sendStatus(200);
await markProcessed(eventId);
const evt = JSON.parse(req.body);
if (evt.event === "payment.settled") {
await fulfillOrder(evt.merchant_order_id); // funds are in your wallet — ship it
}
res.sendStatus(200);
});import hmac, hashlib, time
TOLERANCE_SECONDS = 300 # 5 minutes, same default as Stripe
@app.post("/webhooks/finality")
def finality_webhook():
# Header format: "t=<unix seconds>,v1=<hex hmac>"; signed message is "{t}.{raw_body}"
header = request.headers.get("X-Finality-Signature", "")
parts = dict(
p.strip().split("=", 1) for p in header.split(",") if "=" in p
)
try:
ts = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
abort(401)
# Reject anything outside the tolerance window — this is what stops replays.
# abs() also rejects timestamps from the future.
if abs(time.time() - ts) > TOLERANCE_SECONDS:
abort(401)
signed_payload = f"{ts}.".encode() + request.data # raw bytes, never re-serialized
expected = hmac.new(
WEBHOOK_SECRET.encode(), signed_payload, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
evt = request.get_json()
if evt["event"] == "payment.settled":
fulfill_order(evt["merchant_order_id"])
return "", 200<?php
// Header format: "t=<unix seconds>,v1=<hex hmac>"; signed message is "{t}.{raw_body}"
const TOLERANCE_SECONDS = 300; // 5 minutes, same default as Stripe
function finality_verify(string $rawBody, ?string $header, string $secret): bool {
if ($header === null) return false;
$t = null; $v1 = null;
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = $kv[1];
elseif ($kv[0] === 'v1') $v1 = $kv[1];
}
if ($t === null || $v1 === null || !ctype_digit($t)) return false;
// Reject anything outside the tolerance window — this is what stops replays.
if (abs(time() - (int)$t) > TOLERANCE_SECONDS) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1); // constant-time
}
// php://input is the raw body — never json_encode(json_decode(...)) before hashing.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_FINALITY_SIGNATURE'] ?? null;
if (!finality_verify($rawBody, $header, getenv('FINALITY_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$evt = json_decode($rawBody, true);
if ($evt['event'] === 'payment.settled') {
fulfill_order($evt['merchant_order_id']); // funds are in your wallet — ship it
}
http_response_code(200);payment.settled delivery can replay those exact bytes forever and the signature will still verify, making you ship the order again every time. Reject anything outside a tolerance window (we suggest 5 minutes, matching Stripe) so a captured request stops working almost immediately. Keep deduplicating on X-Finality-Event-Id as well: the timestamp bounds the replay window, the event id stops duplicates inside it.JSON.parse then JSON.stringify before hashing. Re-serializing changes whitespace / key order / escaping, and any byte change breaks the signature, so verification will always fail. The examples above use express.raw / request.data to get exactly the unparsed raw bytes, and only parse into an object after the signature checks out.Payment QR codes & deep links
finality uses an exact-amount model: settlement requires received ≥ required. So the QR code has to carry the amount. Encode a bare address and the customer types it by hand — one cent off and the order sits unpaid until it expires.
49.00); EIP-681 uses atomic units (49000000). Get it wrong and the customer pays a million times too much, or too little.Solana Pay
Format: solana:<recipient>?amount=<decimal>. Add spl-token to send a token (the wallet derives the ATA itself); omit it to send native SOL.
# Native SOL (no spl-token parameter)
solana:9xQe...Recipient?amount=1.25&label=Acme&message=Order%201234
# SPL token (USDC): amount is still a decimal, not atomic units
solana:9xQe...Recipient?amount=49.00\
&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\
&label=Acme&message=Order%201234amount=49.00, never 49000000. We use the non-interactive Transfer Request form, so the QR carries everything and the wallet never calls back to a server.EIP-681 (all EVM chains)
Native and ERC20 are two different shapes: for ERC20 the path holds the token contract address, and the actual recipient moves into the address query parameter.
# Native coin (ETH/BNB): value is in atomic wei
ethereum:0xRecipient...@8453?value=1000000000000000
# ERC20 (USDC on Base, 6 decimals): 49.00 USDC = 49000000
# * The path holds the TOKEN CONTRACT; the recipient moves to the address query param
ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer\
?address=0xRecipient...&uint256=49000000| Chain ID | @<chainId> | Immediately after the address, in decimal. Base = 8453, BSC = 56. |
| Native amount | value | Atomic wei. 0.001 ETH = 1000000000000000. |
| ERC20 amount | uint256 | Smallest token unit. USDC has 6 decimals, so 49.00 = 49000000. |
Splits & affiliates
A single payment can be split across multiple recipients—seller, platform, promoter—atomically in the same on-chain transaction. Ideal for marketplaces, affiliate payouts, and multi-party settlement. If any leg fails, the whole payment rolls back.
{
"chain": "base",
"token": "USDC",
"amount": "1000.00",
"merchant_order_id": "mkt-order-2001",
"splits": [
{ "address": "0xSeller...", "bps": 8900, "label": "seller" },
{ "address": "0xPlatform...", "bps": 700, "label": "platform" },
{ "address": "0xAffiliate...", "bps": 300, "label": "affiliate" }
]
}bps are basis points; the recipients plus the platform fee (100 bps) must sum to 10000. Split rules are encoded into the pay address and enforced on-chain—no one can alter where the funds go.
Affiliates
Let others sell for you and split revenue automatically. Generate a unique link for each affiliate; when a customer buys or subscribes through their link, their cut is split off every payment and sent on-chain straight to their address—zero manual work for you. Great for affiliate marketing, influencer sales, and channel distribution.
Why this isn't "just another affiliate feature"
Every pain of traditional affiliate platforms lives in one place: paying the affiliates. Monthly payout runs, 30-60 day delays, manual bookkeeping, PayPal or bank transfers (which affiliates in many countries simply can't receive) — and the standing worry of whether the merchant will actually pay.
finality removes that step entirely: commission never touches your hands. Inside the very transaction where the customer pays, the affiliate's share splits off automatically, straight to their own wallet — arrives in seconds, no waiting period, no bookkeeping, every split visible on the blockchain. Not paying is simply impossible.
Card payments can be charged back, which is why traditional platforms hold commissions for 1-2 months; crypto payments have no chargebacks, so commissions have no clawbacks: one rule — settled with the sale, never reversed. Equally certain for you and your affiliates. Want flexible incentives? Set a lower on-chain share and pay discretionary bonuses on top.
How it differs from Splits
| Splits | splits | Every order is split among the same fixed parties (static co-split). Good for fixed channel chains like platform + regional agent + store. Up to 5 recipients. |
| Affiliate | affiliate | Only the person who drove the sale gets paid (dynamic attribution). Good for affiliate teams / networks—one unique link per person, and money goes to whoever's link the customer came from. Unlimited affiliates. |
Enter the affiliate's label + payout addresses. You can create multiple links for them later (on different plans).
Pick a plan, set their revenue share, and generate a unique ?aff= link in one click.
Give the link to the affiliate. The dashboard aggregates per affiliate: how many subscriptions and how much volume each drives—real on-chain sales, unfakeable.
30-day attribution: if a customer clicks an affiliate's link, doesn't buy, then comes back to the same plan within 30 days (even without the link), the sale still counts for that affiliate. Last click wins.
| GET /v1/affiliate-portal/invite/:token | public | Invite preview: merchant name and current addresses shown when an affiliate opens their invite (the token is the credential). |
| POST /v1/affiliate-portal/me | public | Affiliate wallet sign-in: all profiles, links and stats under the wallet (signature format: see the customer-portal section). |
| POST /v1/pay-link/:code | public | Landing endpoint for exact referral links: auto-creates an order from the link's template with the split injected. |
① Register an affiliate
Register an affiliate first to get an affiliate id. Payout addresses are stored once and reused by all their links.
| name | string | Affiliate label (human-readable within your account, e.g. "KOL Jane") |
| addresses | object | Multi-chain payout addresses, e.g. { "solana": "...", "base": "0x..." }. They earn on whichever chains you fill in |
| external_ref | string? | The affiliate's ID in your system (optional, for two-way reconciliation) |
curl -X POST https://finality.money/v1/affiliates \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Influencer Jane",
"addresses": { "solana": "7xKq...3fBw", "base": "0xJane..." },
"external_ref": "your_affiliate_id_42"
}'
# → { "id": "b7c1e2d4-...", "ref_code": "...", ... }② Create a unique link
Create a unique link for the affiliate on a given plan and set their revenue share. Returns a full ?aff= link—hand it straight to the affiliate to promote.
| kind | string | "subscription" (on a subscription plan) or "exact" (on a one-off payment) |
| plan_id | string? | Subscription plan ID (required when kind=subscription) |
| bps | number | The affiliate's share (bps; 2000 = 20%). Taken from your 99%, 1..=9900 |
curl -X POST https://finality.money/v1/affiliates/<affiliate_id>/links \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "kind": "subscription", "plan_id": "0f4c8e2a-...", "bps": 2000 }'
# -> returns a dedicated link; hand it straight to the affiliate:
{
"ref_code": "ABC23456",
"bps": 2000,
"url": "https://finality.money/subscribe/0f4c8e2a-...?aff=ABC23456"
}?aff= is the affiliate code (who gets paid); ?ref= is your system's user ID (client_reference_id, for reconciliation). One link can carry both: ?aff=ABC23456&ref=user_8837.③ Watch the reports
List all affiliates + attribution reports: subscriptions, orders, and cumulative volume driven by each. Data comes from real on-chain sales (not click tracking)—unfakeable.
When a customer overpays
Under the exact-amount model, anything paid above the required amount stays at the payment address. This endpoint builds a transaction to withdraw it — again, signed by you.
| intent_id | string | The related payment intent ID. |
curl -X POST https://finality.money/v1/merchant/overpaid/prepare \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "intent_id": "6f1c9e2a-..." }'
# The response is FLAT (transaction fields at the top level, no tx wrapper)
# EVM: { "chain","to","data","from","is_native": false }
# Solana: { "chain","solana_unsigned_tx","from" }
# You sign and broadcast this yourself — the platform never touches the moneyrecipients[0] — your configured merchant payout address — is allowed to withdraw. That's enforced by the contract and can't be changed by anyone, including us.Invoices (payment receipts)
An invoice number is generated automatically at settlement, carrying the on-chain transaction hash as tamper-proof evidence of payment. This is a receipt for money already received — not a bill awaiting payment. The payment intent itself is the bill.
| invoice_no | string | Format INV-{yyyymm}-{6 digits}, assigned at settlement. |
| issued_at | string | Issue time = settlement time. |
| merchant | object | Your details: name, email, logo_url, website. |
| order | object | Order details: intent_id, your order ID, creation time. |
| payment | object | Payment details: chain, token, amount, settle tx, payer address, split breakdown. |
{
"invoice_no": "INV-202608-000731",
"issued_at": "2026-08-07T09:14:02Z",
"merchant": { "name": "Acme", "email": "...", "logo_url": null, "website": null },
"order": { "intent_id": "6f1c...", "merchant_order_id": "order-8801",
"created_at": "2026-08-07T09:11:40Z" },
"payment": { "chain": "base", "token": "USDC",
"amount_usd": "49.00", "token_amount": "49.00",
"settle_tx": "0xabc...",
"payer": "0xCustomer...",
"merchant_amount": "48.51", "protocol_fee": "0.49",
"splits_detail": [ ... ] }
}409 instead of an invoice: the order hasn't settled yet (no payment has occurred), or it's a sandbox order (orders created with sk_test_ get no real invoice number). payer and token_amount may be null.Public receipt (customer proof, no auth)
A public subset of the same invoice data: anyone with the order UUID can view it (UUIDs aren't enumerable — same model as Stripe receipt links), and it excludes your cost structure (net received, fees, and split details stay merchant-only). The customer's success page links to /invoice/:intent_id automatically; for support, one link settles what one order was.
Payment analytics
Revenue split by chain and token, plus two complementary customer rankings. This answers "which chain do my customers actually use" and "who's coming back".
| period | string? | today / 7d / 30d. Anything else (or omitted) means all time. There's no limit parameter — customer lists are fixed at the top 50. |
| by_chain | array | Aggregated per chain: {key, count, usd}. |
| by_token | array | Aggregated per token: {key, count, usd}. |
| top_customers | array | Grouped by the client_reference_id you pass through. |
| top_payers | array | Grouped by on-chain payer address, with a returning-customer flag. |
| daily_trend | array | Last 30 days as {day, views, paid}. |
top_customers depends on you passing metadata.client_reference_id at creation — omit it and you see nothing. top_payers is collected from the chain automatically and is always populated, even if you pass nothing at all. If you want customer analytics without wiring up an account system, use top_payers.curl "https://finality.money/v1/merchant/analytics?period=30d" \
-H "Authorization: Bearer sk_live_..."
# top_payers groups by (payer address, chain); returning=true means a repeat customer
# { "top_payers": [
# { "address": "0xCustomer...", "chain": "base", "orders": 4,
# "usd": "196.00", "returning": true,
# "first_at": "...", "last_at": "..." } ] }by_chain, by_token and top_payers all exclude sandbox orders, but top_customers does not. daily_trend tracks the subscription-link funnel, not one-off payments. Database errors degrade to empty arrays rather than an error response.API keys & scopes
Server-side calls use API keys, which come in three scopes. Read the boundaries below literally — they describe what the code actually enforces, not what sounds reasonable.
| label | string? | A label to tell your keys apart. Defaults to "key". |
| scope | string? | full / payments_only / read_only. Defaults to full. |
| is_test | boolean? | true issues a sandbox key (sk_test_ prefix). Defaults to false. |
| distribution | array? | Optional distribution policy array. |
The three scopes
| full | Everything | Create orders and withdraw unsettled balances from payment addresses. Use for your own backend. |
| payments_only | Payments only | Create orders and read, but cannot withdraw balances. Use for downstream or third-party systems. |
| read_only | Read only | Read access with no order creation. Use for dashboards and reconciliation scripts. |
payments_only and full are identical for creating orders. The only difference between them is money-moving writes such as balance withdrawal and settlement retry. If your goal is "this system may take payments but must not move money back out", payments_only is correct. If you assumed it also restricted order creation, it does not.Managing keys
| GET /v1/merchant/keys | List keys | Returns keys that haven't been revoked. The response carries no scope or is_test field — infer the environment from the pk_test_ / pk_live_ prefix. |
| PATCH /v1/merchant/keys/:id | Edit label / distribution | Note the :id in the path. Scope cannot be changed — revoke and reissue instead. |
| DELETE /v1/merchant/keys/:id | Revoke a key | Soft delete, effective immediately. The :id path segment is required. |
distribution does not mean "leave unchanged" — it clears it, because the field isn't optional and absent decodes as an empty array. To edit only the label while keeping a distribution policy, send the policy back too. label behaves the opposite way: omit it and it's preserved.sk_test_ are sandbox (livemode: false in webhooks), sk_live_ is real money. Declaring it in the request body has no effect.Team & roles
Invite colleagues to share an account, separated by role. Here's what each role can actually do.
| owner | Owner | The account holder. Only the owner can change payout addresses, and that requires step-up verification. |
| admin | Admin | Read and write: create orders, withdraw balances, manage keys, manage the team. |
| viewer | Viewer | Read-only. Every write returns 403. |
owner and admin carry identical permissions — the only boundary actually enforced is "is this a viewer". Also, the owner role cannot be granted through the API: passing "owner" silently becomes viewer. Plan your team structure around this reality rather than assuming admin is weaker than owner.Invitation flow
- 1You (owner or admin) call the invite endpoint and receive an invite link containing a token.
- 2Send the link to your colleague — we don't email it for you, so you need to pass it along.
- 3Your colleague registers or signs in to finality to get their own account.
- 4They call the accept endpoint with the token; their membership becomes active.
- 5They sign out and sign back in — this step is not optional, see below.
# (1) Invite (owner/admin, JWT auth)
curl -X POST https://finality.money/v1/merchant/team \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "role": "viewer" }'
# → { "member_id": "...", "role": "viewer",
# "invite_url": "https://finality.money/team/accept?token=inv_..." }
# * The token is returned only here — no endpoint can retrieve it later
# (2) The invitee registers/signs in themselves, then accepts with the token
curl -X POST https://finality.money/v1/merchant/team/accept \
-H "Authorization: Bearer <invitee's JWT>" \
-d '{ "token": "inv_..." }'Audit log
A record of significant writes on your account: who, when, from which IP, and what. Use limit to control volume (1–500, default 100), newest first.
| actor | string|null | The acting merchant ID. For organisation members this is the individual, not the organisation. |
| action | string | Action type — see the list below. |
| target | string|null | ID of the object acted on (key ID, member ID, and so on). |
| detail | object | Structured details, with different fields per action. |
| ip | string|null | Source IP. |
| created_at | string | When it happened. |
Recorded actions
auth.login 2fa.enable 2fa.disable
2fa.recovery_used api_key.create api_key.patch
api_key.delete webhook.rotate_secret
config.update notify_channels.update
payout_address.update refund.prepare refund.confirm
team.invite team.set_role team.removelimit is the only parameter — there is no filtering by action, actor, or time range, and no paging cursor. For long-term retention or richer search, pull periodically and store it in your own logging system.Two-factor authentication
TOTP-based two-factor auth, compatible with Google Authenticator, 1Password, Authy and other standard apps. Once enabled, every sign-in method — email, Google, and wallet signature — requires a code.
Enabling it (two steps)
- 1Call enable to generate a secret and receive an otpauth link — nothing is active yet.
- 2Render the otpauth link as a QR code for the user to scan with their authenticator app.
- 3The user enters the 6-digit code from the app; call confirm with it.
- 4On success you receive 10 recovery codes — make the user save them right there.
# (1) Generate the secret (not active yet)
POST /v1/merchant/2fa/enable (no body)
# → { "secret": "JBSWY3DP...", "otpauth_uri": "otpauth://totp/Finality:..." }
# Render otpauth_uri as a QR code for the user to scan; no ready-made QR image
# (2) Confirm with the 6-digit code from the app — only now is it active
POST /v1/merchant/2fa/confirm { "code": "123456" }
# → { "enabled": true, "recovery_codes": [ 10 eight-character codes ] }
# * Recovery codes are returned only once; there's no reissue endpointtotp_enabled stays false. If the user scans the QR and closes the page, the next sign-in still won't ask for a code — so make the confirmation step impossible to skip in your UI.The sign-in flow
With 2FA enabled, the login endpoints stop returning a session token and return a challenge instead. Your frontend prompts for the code and exchanges it for the real session.
// With 2FA on, login returns a challenge instead of a session token:
{ "merchant_id": "...", "need_2fa": true, "challenge": "<valid 5 minutes>" }
// prompt for the code, then exchange it for a real JWT:
POST /v1/auth/2fa { "challenge": "...", "code": "123456" }
// → { "merchant_id": "...", "token": "<30-day JWT>", "role": "owner" }
// Without 2FA, login returns directly:
{ "merchant_id": "...", "token": "...", "role": "owner" }it cannot be used as a session token. Presenting it to any business endpoint is rejected. Real sessions last 30 days.Endpoint reference
Endpoints that are available but didn't warrant their own section above. The complete machine-readable version lives at /openapi.json.
Utility
| GET /v1/prices | No auth | Live native-coin quotes (SOL/ETH/BNB/BTC) plus Solana rent constants. Use to convert a fiat price into a token amount. |
| GET /v1/wallet-balances | No auth | Balance of any address for a chain and token. All three parameters are required. Pre-flight a customer's balance and gas. |
| GET /health | No auth | Liveness probe for monitoring and load balancers. |
# Live prices + Solana rent constants
curl https://finality.money/v1/prices
# { "sol":73.95, "eth":1918.86, "bnb":591.70, "btc":64792.59,
# "solana_ata_rent_sol":0.00148844, "solana_account_rent_sol":0.00065024,
# "solana_ata_rent_lamports":1488440, "solana_account_rent_lamports":650240 }
# rent figures are read live from the network — do not hardcode them
# Check a balance (all three parameters required)
curl "https://finality.money/v1/wallet-balances\
?chain=base&address=0xCustomer...&token=USDC"Subscriptions
| GET /v1/subscriptions/overview | API key / JWT | Subscription overview: MRR, active and at-risk counts, and every subscriber's vault balance with cycles remaining. One request powers a whole dashboard, avoiding N+1. |
| GET /v1/subscriptions | API key / JWT | Lists subscriptions with vault balance and remaining cycles. Filter by ?client_reference_id= for one user; paginate with ?limit= plus the returned next_cursor (cursor_created_at + cursor_id). With either param, returns {data, has_more, next_cursor} instead of a bare array. |
| GET /v1/subscriptions/:id | API key / JWT | Retrieve one subscription. |
| GET /v1/subscriptions/plans | API key / JWT | List subscription plans. |
| PATCH /v1/subscriptions/plans/:id | API key / JWT | Archive or restore a plan. Archiving disables the link and blocks new subscribers; existing subscriptions keep charging normally. |
| GET /v1/subscriptions/plans/:id/insights | API key / JWT | Per-plan insight: step-by-step drop-off funnel, abandonment reasons, wallet/chain/token distribution, daily trend. |
| GET /v1/subscriptions/sessions | API key / JWT | List one-time invitations with their open/completed/expired conversion. |
| POST /v1/account/subscriptions | No auth | Customer-side portal: a wallet signature returns every subscription for that address across all merchants, with no account required. |
POST /v1/account/subscriptions is for your customers, not for you. A customer signs with their wallet and can see and manage every subscription they hold — across all merchants — topping up, cancelling, or withdrawing, without registering an account anywhere. That experience is only possible under a non-custodial model.Coupons and affiliates (including archiving)
| GET /v1/coupons | API key / JWT | List coupons. |
| PATCH /v1/coupons/:id | API key / JWT | Archive or restore a coupon. |
| PATCH /v1/affiliates/:id | API key / JWT | Archive or restore an affiliate. |
| PATCH /v1/affiliate-links/:id | API key / JWT | Archive or restore a single affiliate link. |
Merchant configuration
Note this is PATCH, not POST or PUT — the wrong method returns 405. Fields you send are updated; fields you omit keep their current value.
| payout_addresses | object | Per-chain payout addresses, e.g. {"solana":"...","base":"0x..."}. Merge semantics — chains you omit are untouched. |
| webhook_url / logo_url | string | Webhook URL and merchant logo. |
| default_success_url | string | Global default success-redirect URL. |
| email_code / wallet_sig | string | Step-up credentials required when changing payout addresses — see below. |
wallet_sig + wallet_nonce), email accounts need an emailed code (call verify/send first, then pass email_code). This is stronger than TOTP: an attacker holding your authenticator still cannot pass it. Also note this endpoint is not atomic — other fields are written first, so a failed step-up rolls back only the payout-address portion.| GET /v1/merchant/config | API key / JWT | Read full merchant configuration (including the webhook secret in plaintext, payout addresses, and notification channels). |
| GET /v1/merchant/2fa/status | JWT | Whether two-factor auth is currently enabled on this account. |
| POST /v1/merchant/2fa/disable | JWT | Disable two-factor auth. Accepts a TOTP code only — not a recovery code. |
| POST /v1/merchant/verify/send | JWT | Send the email code required to change payout addresses. Email accounts only. |
| GET /v1/merchant/referral | JWT | Platform referrals: your code, link, referred merchants, and cumulative rewards. |
| GET /v1/merchant/referral/earnings | JWT | Referral earnings detail. |
| POST /v1/merchant/notify/test | JWT | Send one test notification to each configured channel (Telegram / Discord / Slack / Feishu / webhook) and report per-channel results. |
/v1/auth/*) — used internally by our dashboard and SDKs; merchants never implement login themselves. (2) Subscription checkout internals (/v1/sub-checkout/*) — consumed by the hosted checkout; you only need the checkout_url. (3) The offline demo kiosk (/v1/kiosk/*) — for trade-show demos. All are real and stable; they simply are not part of a merchant integration.Supported chains
| Network | Tokens | Status |
|---|---|---|
| Solana | SOL · USDC · USDT · cbBTC | Live |
| Base | ETH · USDC · USDT · cbBTC | Live |
| BNB Chain | BNB · USDT · USDC | Live |
| Ethereum | ETH · USDT · USDC · DAI · PYUSD · cbBTC | Live |
| Robinhood Chain | ETH · USDG | Live |
You choose which chain the customer pays on when you create the payment. Create payments on multiple chains for the same order to let the customer pick.
Getting ready to receive on Solana
On Solana, a payout address needs to hold a small balance to exist on the network. All you need to do is make sure your payout address isn’t a brand-new empty one — topping it up with 0.001 SOL once is enough. Everything else is handled for you, at no cost and with no action required. Base, BNB Chain and other EVM chains have no such requirement.
The exact threshold (set by Solana, not us)
This threshold is fixed by the Solana network. Finality adds nothing on top:
- A brand-new address receiving native SOL for the first time: Solana requires a small minimum balance for an address to exist on-chain (the exact figure is set by the network and changes over time). Topping up with 0.001 SOL covers it comfortably.
- Receiving USDC / USDT: nothing to prepare — handled automatically.
You can read Solana’s own explanation of accounts and rent here: Solana docs · Accounts & rent ↗
Who needs to prepare, and how
Send 0.001 SOL to your Solana payout address, once. Everything else is handled for you.
If you use revenue splits, ask each reseller to send any amount of USDC (or USDT) to their own address — even $0.01 activates it. If they aren’t ready, your orders are unaffected — their share goes to you.
Do it once and you never think about it again.
Common questions
Just one thing: send 0.001 SOL to your Solana payout address. Skipping it is usually fine too — on most orders the payout itself is large enough to activate the address, and only very small SOL orders get blocked, with a clear message at order creation.
To you. At settlement the chain checks each recipient one by one and skips any that can't receive, leaving that share with you, the merchant. We never front this account cost for a reseller and never deduct it from anyone's share — a recipient is either paid or skipped, and nothing is taken out.
Yes — resellers prepare their own addresses. Ask each to send any amount of USDC/USDT to their address once (even $0.01); for native SOL, their address just needs a balance. If they aren’t ready, your orders are unaffected — their share goes to you.
Not for your own payout address — whichever token arrives is handled automatically. Only reseller addresses need one transfer per token.
This page is about Solana only. Payments on Base, BNB Chain, Robinhood Chain and other EVM networks have no activation step and no such cost.
Embedded checkout (no redirect)
Today there is one way to take payment: send the customer to the hosted checkout page. You already have its address — it is the checkout_url that comes back when you create a payment or a plan. Point your "Pay" or "Subscribe" button at it and you are done; there is nothing to install.
cdn.finality.money and @finality/react do not exist today, so copying them now will not work. Use the hosted checkout link above; it takes the same payments.<script src="https://cdn.finality.money/widget.js"></script>
<div id="pay"></div>
<script>
finality.mount('#pay', {
intentId: '9aRtUe2c-...', // the id returned when your backend created the payment (UUID)
theme: { accent:'#YourBrandColor', radius:'14px' }, // fully customizable
onSettled: (e) => { location.href = '/success'; } // fires when payment settles
});
</script>import { FinalityCheckout } from '@finality/react';
<FinalityCheckout
intentId="9aRtUe2c-..."
theme={{ accent: '#YourBrandColor' }}
onSettled={(e) => router.push('/success')}
/>x402 — let AI agents pay directly
Finality speaks the x402 protocol (the HTTP 402 machine-native payment standard), so an AI agent can pay for an order with no browser, no session, and nobody clicking anything. It pays **the very same order** a human would — same receiving address, same webhooks, same invoice.
The flow (two HTTP round trips)
Without a payment header you get 402 and a quote; with a signature you get 200. The quote is carried both in the PAYMENT-REQUIRED header (base64, as v2 requires) and in the response body, so you can read it with plain curl.
# (1) No credential -> get the quote
curl -i https://finality.money/v1/x402/<intent_id>
HTTP/1.1 402 Payment Required
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6Mi... # the same quote, base64-encoded
{ "x402Version": 2,
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"amount": "1000000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x58b3822ac31ed6c8c8bc2199575f4857920c5ea8",
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" } # * read from chain, never hardcoded
}] }
# (2) Retry with the signature -> accepted
curl -i https://finality.money/v1/x402/<intent_id> \
-H "PAYMENT-SIGNATURE: <base64 PaymentPayload>"
HTTP/1.1 200 OK
PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLC...Capability discovery
Returns the chains and tokens that are actually usable right now. The list is not hardcoded — the server reads each token’s name() / version() on-chain to confirm real EIP-3009 support, and anything without it never appears.
| Base | eip155:8453 | USDC, cbBTC (EIP-3009 verified on-chain) |
| Ethereum | eip155:1 | USDC, DAI, cbBTC |
| BSC / Robinhood / Solana | — | Not yet. BSC’s USDC is a Binance-Peg wrapper with no EIP-3009; Robin’s USDG lacks version(); Solana uses a different mechanism (partially-signed transactions) and comes later. |
Compatibility
This is a fully standard implementation with no custom parameters. payTo is the order’s own dedicated address, derived when the order was created, so any off-the-shelf x402 client (@x402/fetch, x402 for Python, the Go SDK) works unmodified. Gateways that route payments through a shared intermediary wallet must demand a non-standard ?payerAddress= parameter, forcing every SDK user to hand-edit URLs. We don’t have that problem.
| Protocol version | v2 + v1 | v2 is canonical (PAYMENT-SIGNATURE / PAYMENT-RESPONSE); v1 X-PAYMENT headers are also accepted. |
| Scheme | exact | exact only — it matches our exact-amount model precisely. For metered or recurring billing, use our subscription vaults instead. |
| Amount check | = | Must match exactly (v2 rule). Both underpayment and overpayment are rejected; overpayments go through the overpaid-withdrawal flow. |
| Who pays gas | the agent | The agent broadcasts and pays its own gas, so its wallet needs a little native coin alongside the USDC. That is inherent to payer-broadcast settlement. |
name must come from the token contract on-chain, never hardcoded — Base mainnet USDC returns "USD Coin" while testnet returns "USDC". Hardcode either one and verification silently fails everywhere else. We hand you the real values in the quote’s extra field; just use those.MCP server (let an AI agent integrate for you)
We publish an official MCP server so Claude, Cursor, Windsurf, or any MCP-compatible agent can read the real API reference, generate a correct integration, create a sandbox payment, fire a simulated webhook, and verify delivery — without moving a cent. Once configured, you just say "add crypto payments to my site."
{
"mcpServers": {
"finality": {
"command": "npx",
"args": ["-y", "@finality/mcp"],
"env": { "FINALITY_API_KEY": "sk_test_..." }
}
}
}sk_test_ sandbox key.The 12 tools
| finality_docs | No key needed | Fetch official docs: overview, full API reference, or the OpenAPI spec. Agents should call this before writing code so they don't guess field names. |
| finality_integration_guide | No key needed | A step-by-step plan per scenario (plugin / custom backend / payment link / subscription / marketplace splits), including the traps. |
| finality_create_payment | Key required | Create a payment. No fund risk — it only generates a receiving address; money goes to the merchant's own wallet. |
| finality_get_payment | Key required | Check one payment's status. |
| finality_list_payments | Key required | Search and paginate payments. |
| finality_payment_lifecycle | Key required | Full trace of one order, including the real payer address and webhook delivery results. |
| finality_simulate_payment | Key required | Sandbox simulated payment that fires a genuine webhook. |
| finality_analytics | Key required | Payment analytics. |
| finality_subscriptions_overview | Key required | Subscription overview (MRR, at-risk subscriptions, vault balances). |
| finality_check_webhook_health | Key required | Webhook delivery health — diagnose "it was paid but my system didn't react". |
| finality_prices | No key needed | Live prices and Solana rent constants. |
| finality_wallet_balance | No key needed | Balance of any wallet. |
finality_integration_guide for the plan → finality_docs to ground field names → writes the code → finality_create_payment for a sandbox order → finality_simulate_payment to fire a webhook → finality_check_webhook_health to confirm delivery. The integration is verified end to end before launch, at zero cost.Machine-readable sources
If you're building or training your own AI system, these three files are fetchable directly and share the same source of truth as this documentation:
| /llms.txt | Site summary | Positioning, pricing, supported chains, and capability boundaries. Follows the llms.txt convention. |
| /llms-full.txt | Full API reference | Parameters, responses, constraints, and common traps per endpoint — enough for an agent to generate correct code. |
| /openapi.json | OpenAPI 3.1 | Standard OpenAPI 3.1, ready for code generators and API tooling. |
SDKs & plugins
Pick the path that matches your stack. The plugins need no code at all; the SDKs handle signing, retries and webhook verification for you.
npm i @finality/nodepip install finalityUpload the plugin, then Settings → PaymentsShortcode: [finality_pay amount="49"]Self-hosted app — deploy, then install to your storeAny language, any frameworkError codes
Every error returns HTTP status plus a JSON body with an `error` field. The status code is what you should branch on — it is stable. The message text is human-readable and may change, so don't match on it.
| 400 | Invalid request | Something in the request is wrong: unknown chain or token, malformed amount, invalid coupon, or no payout address configured for that chain. The message says which. |
| 401 / 403 | Not authorised | API key missing, invalid, revoked — or the key's scope is too narrow (a read_only key cannot create charges). |
| 404 | Not found | No such resource, or it belongs to another merchant. We deliberately return 404 rather than 403 so the endpoint never reveals whether an id exists. |
| 409 | Conflict | The resource is not in a state that allows this action — e.g. requesting an invoice for an order that hasn't settled yet. |
| 429 | Rate limited | Back off and retry. This is the one response that carries a machine-readable code and a `retry_after` hint in seconds; a `Retry-After` header is sent too. |
| 5xx | Server error | Something failed on our side. Safe to retry with exponential backoff — our SDKs do this for you. |
Error body shape:
{ "error": "coupon invalid: expired" }
// 429 additionally carries a machine-readable code and a hint:
{ "error": "rate_limited", "message": "Too many requests", "retry_after": 30 }