Overview
InstantPay lets your website take crypto payments without running a wallet yourself. Your customer pays in SOL, USDC, USDT, ETH, BNB or BTC on a checkout page hosted by us; you receive a signed webhook and a credit in EUR or USD on your InstantPay balance. Every confirmed payment costs a flat 1.66 % of the invoice amount. You pay yourself out to your own wallet whenever you like, in SOL, a stablecoin, ETH, BNB, POL, TRX, BTC or LTC.
A payment goes like this:
- Your server creates an invoice with a price in EUR or USD and gets back a checkout URL.
- The customer pays on the checkout page (or in an overlay on your site): picks a coin, sees the address and the exact amount, sends it. The rate is locked at that moment.
- InstantPay watches the chain. As soon as the payment is final, your webhook URL receives
invoice.confirmed. - You deliver. The invoice amount minus the fee is on your balance; request a payout in the coin of your choice whenever you like.
What you need: an InstantNode account, a server that can make HTTPS calls (Node, PHP, anything else works) and a public https:// URL that receives webhooks. You never handle coins, addresses or exchange rates yourself.
Try the whole flow first: our demo shop at http://5.230.154.241 is an ordinary store wired to InstantPay in test mode. Buy something, pay with the "Simulate payment" button and watch the order flip to paid.
Getting started
Six steps from nothing to a live integration. Steps 1 to 5 need no approval; you can build and test while we review your account.
- Sign in. Open pay.instantnode.eu/dashboard and sign in with your InstantNode account. A merchant account is created for you in the state pending.
- Complete your account. Under Account, fill in company, website and a short description of what you sell, accept the terms and submit. InstantNode reviews it and activates you, usually within a day.
- Copy your test key. Under API keys you already have a live and a test pair: a secret key (
sk_test_…, for your server) and a public key (pk_test_…, for the browser). Reveal the test secret key and put it in your server configuration, never in browser code. - Add a webhook endpoint. Under Webhooks, add the
https://address on your server that should receive events, then press Send test event on the endpoint page to check that your signature verification works. - Make a test purchase. Create an invoice with the test key (see below), open the checkout URL, press Simulate payment. A few seconds later your webhook receives
invoice.confirmedwithlivemode: false. - Go live. Once your account is active, switch the dashboard to live mode, reveal the live secret key (shown once) and swap it for the test key; add a live webhook endpoint. Invoices from a live key are paid with real coins and credited to your balance.
Ways to integrate
All ways use the same API; the only difference is where the customer pays. One rule holds for all of them: the invoice is always created on your server, never in the browser, otherwise your API key is public.
Hosted checkout
The simplest way. Your server creates the invoice and redirects the customer to checkoutUrl. After the payment the customer comes back to your redirectUrl. Works in every shop, needs no JavaScript.
// Node (Express) - the SDK is a single file: /sdk/node/instapay.mjs
import { InstantPay } from './instapay.mjs';
const pay = new InstantPay({ baseUrl: 'https://pay.instantnode.eu', apiKey: process.env.INSTAPAY_KEY });
app.post('/checkout', async (req, res) => {
const invoice = await pay.createInvoice({
amount: '19.99',
currency: 'EUR',
orderId: order.id,
description: 'T-shirt, blue, M',
redirectUrl: 'https://shop.example/thanks?order=' + order.id,
cancelUrl: 'https://shop.example/cart',
}, order.id); // idempotency key: a retry returns the same invoice
res.redirect(invoice.checkoutUrl);
});// PHP - /sdk/php/InstaPay.php
require 'InstaPay.php';
$pay = new InstantPay('https://pay.instantnode.eu', getenv('INSTAPAY_KEY'));
$invoice = $pay->createInvoice([
'amount' => '19.99', 'currency' => 'EUR', 'orderId' => (string) $orderId,
'redirectUrl' => 'https://shop.example/thanks?order=' . $orderId,
], (string) $orderId);
header('Location: ' . $invoice['checkoutUrl']);Payment links
No code at all. Under Payment links, create a link with a fixed amount or an open amount (the customer types it, within the bounds you set), reusable or single use, with an optional expiry and success URL. The link is a page at https://pay.instantnode.eu/l/<slug> that creates an invoice for whoever opens it; you get the URL, a QR code and an embed snippet. Every invoice from a link carries paymentLinkId and the order id link:<slug>:<n>, so the webhook and the credit work exactly the same. The link page counts uses and revenue and lists its payments.
Overlay widget
The customer stays on your page; the checkout opens in an overlay. Load instapay.js, then open it with the token, which is the last part of checkoutUrl and comes from your server. If you would rather not pass the token through your page, the browser can fetch it with your public key: POST /v1/public/checkout-sessions with { "invoiceId" } answers { token, checkoutUrl } for an invoice your server created; a public key can do nothing else.
<script src="https://pay.instantnode.eu/instapay.js"></script>
<script>
InstantPay.open({
token: token, // last segment of checkoutUrl
lang: 'en', // or 'de'
onStatus: (s) => console.log(s.status),
onPaid: (s) => location.href = '/thanks',
onExpired: (s) => alert('The payment window has expired'),
onClose: () => console.log('closed'),
closeOnPaid: true,
});
</script>The widget polls the status every four seconds and calls onPaid as soon as the payment is confirmed. Still treat that as a courtesy for the customer: only the webhook decides whether an order is paid.
Your own payment page
Create the invoice, call POST /v1/invoices/:id/select with the coin the customer chose, and show depositAddress and amountExpected yourself. Poll GET /v1/invoices/:id or wait for the webhook.
Creating an invoice
POST /v1/invoices with your key in the X-API-Key header. Amounts travel as strings; never do money maths with floating point numbers.
curl -X POST https://pay.instantnode.eu/v1/invoices \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001" \
-d '{
"amount": "19.99",
"currency": "EUR",
"orderId": "ORD-1001",
"description": "T-shirt, blue, M",
"redirectUrl": "https://shop.example/thanks?order=1001",
"cancelUrl": "https://shop.example/cart",
"metadata": {"sku": "shirt-blue-m"},
"ttlSeconds": 1200
}'| Field | Required | Meaning |
|---|---|---|
amount | yes | Price as a string with at most two decimals, e.g. "19.99" |
currency | yes | EUR or USD |
orderId | no | Your order number. Unique per account; the same orderId returns the same invoice |
description | no | Shown to the customer on the checkout |
redirectUrl / cancelUrl | no | Where the customer goes after paying or cancelling |
webhookUrl | no | Overrides the default webhook URL from your settings for this invoice |
metadata | no | Any JSON object up to 4 KB; comes back in every webhook |
asset | no | Preselect the coin (e.g. USDC_SOL) and skip the picker on the checkout |
ttlSeconds | no | How long the invoice stays payable: default 1200 (20 minutes), 60 to 86400 |
customerEmail | no | The customer gets a receipt mail once the payment is confirmed; also shown to you on the payment |
The response is the invoice (201):
{
"id": "f987fead-cbc0-47e1-b9c1-02423c18ec97",
"orderId": "ORD-1001",
"status": "created",
"paid": false,
"amount": "19.99",
"currency": "EUR",
"checkoutUrl": "https://pay.instantnode.eu/pay/Tx7CVPbpJk7IcTkdKMCKCCKt",
"expiresAt": "2026-09-19T21:20:13.527Z",
"asset": null, "amountExpected": null, "amountReceived": null, "depositAddress": null
}Limits: 60 calls per minute per key (429), at most 1000 open invoices at a time (429 too_many_open_invoices). Sending the same Idempotency-Key with a different body answers 409. Fields you leave out come from your Checkout settings: time to pay (ttlSeconds), tolerance, success and cancel URL, and the coins offered on the checkout.
Webhooks
The webhook is the part that matters. The redirect after the payment is only cosmetic: the customer can close the browser and still send the coins, so only deliver goods after the event with paid: true has arrived.
Under Webhooks you add up to ten endpoints per mode (live and test are separate lists). Each endpoint has its own signing secret (whsec_…), a list of event types it subscribes to (* for all) and a health line. InstantPay sends a POST with a JSON body to every subscribed endpoint. The URL must be https:// with a public hostname; redirects are not followed and your server has 10 seconds to answer.
X-InstantPay-Signature: t=1758315600,v1=<hex hmac-sha256>
X-InstantPay-Event: invoice.confirmed
X-InstantPay-Livemode: true
X-InstantPay-Delivery: 42
Idempotency-Key: evt_01K5N3Y7Z2Q8XW6M3R9V4T1B5CThe same headers are also sent with the X-InstaPay- spelling from before, so an existing receiver keeps working.
Verifying the signature
The signature is an HMAC-SHA256 of "<t>.<raw body>" with the endpoint's secret (revealed on the endpoint page after a fresh sign-in). Verify the raw bytes you received, not a re-serialised object, and reject timestamps older than five minutes.
// Node (Express) - verifyWebhook comes from the SDK
import { verifyWebhook } from './instapay.mjs';
app.post('/webhooks/instantpay', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyWebhook(process.env.INSTANTPAY_WEBHOOK_SECRET, req.get('x-instantpay-signature'), raw)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(raw);
if (event.livemode === false) return res.json({ ok: true }); // a test event: never deliver
const invoice = event.data.object;
if (event.type === 'invoice.confirmed' && invoice.paid) markOrderPaid(invoice.orderId);
res.json({ ok: true }); // answer fast, work afterwards
});// PHP
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_INSTANTPAY_SIGNATURE'] ?? '';
$event = InstantPay::parseWebhook(getenv('INSTANTPAY_WEBHOOK_SECRET'), $signature, $raw);
if ($event === null) { http_response_code(400); exit('bad signature'); }
if (($event['livemode'] ?? true) === false) { echo 'ok'; exit; }
$invoice = $event['data']['object'];
if ($event['type'] === 'invoice.confirmed' && $invoice['paid']) markOrderPaid($invoice['orderId']);
echo 'ok';The event body
data.object is the object exactly as GET /v1/invoices/:id (or /v1/payouts/:id) returns it, so a webhook and a read never disagree.
{
"id": "evt_01K5N3Y7Z2Q8XW6M3R9V4T1B5C",
"object": "event",
"type": "invoice.confirmed",
"livemode": true,
"created": "2026-09-19T21:05:00.000Z",
"data": {
"object": {
"object": "invoice",
"id": "f987fead-cbc0-47e1-b9c1-02423c18ec97",
"orderId": "ORD-1001",
"status": "confirmed",
"paid": true,
"livemode": true,
"amount": "19.99",
"currency": "EUR",
"asset": "SOL",
"amountExpected": "0.206789123",
"amountReceived": "0.206789123",
"metadata": {"sku": "shirt-blue-m"},
"createdAt": "2026-09-19T21:00:13.527Z",
"expiresAt": "2026-09-19T21:20:13.527Z",
"confirmedAt": "2026-09-19T21:05:00.000Z"
}
}
}| Event | When |
|---|---|
invoice.created | The invoice exists |
invoice.detected | A transaction was seen but is not final yet |
invoice.confirmed | Paid. paid is true |
invoice.overpaid | Paid, more than needed; everything that arrived is credited. paid is true |
invoice.underpaid | Too little arrived; you can accept the partial payment in the dashboard |
invoice.expired | Nothing arrived before the deadline |
invoice.paid_late | Paid after the deadline; see Fees, balance and payouts |
invoice.cancelled / invoice.failed | Cancelled, or something went wrong |
deposit.confirmed | Money landed on a customer's static address (customers feature) |
payout.paid / payout.failed | A payout to your wallet finished; data.object is the payout |
refund.paid / refund.failed | A refund to a customer finished; data.object is the refund |
account.approved / account.suspended | Your account changed state |
An endpoint can also be set to the legacy body (event, livemode and invoice / deposit / payout at the top level, priceAmount instead of amount), which is what integrations from before endpoints existed receive; invoice.overpaid arrives there as invoice.confirmed with overpaid: true. New integrations should use the default.
Rules for a robust receiver
- Answer
2xxwithin 10 seconds and do the real work afterwards. Anything else is retried with growing gaps (5 s, 20 s, 1 min, 3 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h) - ten attempts in total, then the delivery is marked dead. An endpoint that fails 20 deliveries in a row over three days or more is switched off and you get an e-mail; enable it again on its page once it is fixed. - Be idempotent. There is exactly one event per thing that happened, but a delivery can be repeated (retries, the Resend button). Use the
Idempotency-Keyheader orevent.idto skip what you have already processed. - Check
livemode. Events from test invoices and from the Send test event button carrylivemode: false. They are for checking your code, never for shipping goods. - Reconcile when in doubt.
GET /v1/eventslists every event of your account, newest first, andGET /v1/invoices/:idanswers withpaid. The Events tab in the dashboard shows the same list with every delivery and a Resend to endpoint button.
Test mode
A test key behaves exactly like a real one, except that its invoices are never paid with coins. On the checkout the customer (that is, you) sees a "Test mode" banner with a Simulate payment button; your server can do the same through the API. Test invoices are credited to a separate test balance that you see only with the Live/Test switch in the dashboard set to test; it is never payable and never real money. Their webhooks carry livemode: false.
# pay a test invoice from your server - "case" may be exact, under or over
curl -X POST https://pay.instantnode.eu/v1/invoices/<id>/simulate \
-H "X-API-Key: sk_test_..." -H "Content-Type: application/json" \
-d '{"case": "exact"}'Checklist before you go live:
- Your webhook rejects a wrong signature (
400) and accepts the test event from the dashboard. - An order is only marked paid after
invoice.confirmedwithpaid: trueandlivemode: true. - A repeated delivery does not create a second shipment.
invoice.underpaidandinvoice.expiredshow something sensible to the customer.- The test key is replaced by a live key in your configuration, and the test key is revoked.
Invoice status
paid is the field to look at: it is true for confirmed and overpaid, and false for everything else.
| Status | Meaning | paid |
|---|---|---|
created | Created, coin not chosen yet | no |
awaiting_payment | Address shown, waiting for the transfer | no |
detected | Transaction seen, not final yet | no |
confirmed | Paid and final | yes |
overpaid | Paid, more than needed | yes |
underpaid | Too little arrived; you can accept what came in (dashboard, up to 7 days after expiry) | no |
expired | Deadline passed, nothing arrived | no |
paid_late | Paid after the deadline | no |
cancelled / failed | Cancelled, or an error occurred | no |
A customer who sends slightly too little (up to 0.5 %) still counts as paid. Below that the invoice becomes underpaid.
Fees, balance and payouts
- Fee. Every confirmed payment is credited to your balance in the invoice currency minus the platform fee, 1.66 % by default. Invoice 100.00 € → credit 98.34 €. The fee is frozen when the invoice is created, so a later change only affects new invoices.
- Overpaid. You are credited everything that arrived, at the rate locked for the invoice, minus the fee. The customer's money is yours.
- Paid late. Up to 60 minutes after the deadline the payment is credited automatically at the locked rate. Later ones wait for you: Accept late payment on the invoice credits what arrived at the current rate; the price move since the invoice is yours.
- Underpaid. Nothing is credited by itself. Accept partial payment on the invoice credits what arrived at the locked rate, minus the fee; possible until 7 days after the invoice expired, afterwards through support.
- Balance. Balance in the dashboard shows three numbers per currency: available (what you can pay out), pending (payments detected on chain but not confirmed yet) and reserved (open payouts and refunds, already deducted). Below them every entry:
sale,fee,payout,payout_reversal,refund,refund_reversalandadjustment, with a running balance and a CSV export for your bookkeeping. - Refunds. From a paid invoice, Refund sends part of the credited amount back to an address the customer names, in the coin they paid with or in your payout coin. The fiat amount leaves your balance at once; the network fee comes out of it, and you see the exact numbers before you confirm. Refunds up to 200 € of accounts approved 30 days ago or more go out by themselves, larger ones are checked by InstantNode.
refund.paid/refund.failedarrive as webhooks;GET /v1/refundslists them. Test invoices cannot be refunded. - Payouts. Under Payouts, pick the coin you want to be paid in (SOL by default; also USDC and USDT on Solana, ETH, USDT and USDC on Ethereum, BNB, USDT on BSC, POL, TRX, USDT on TRON, BTC and LTC), save your wallet address for that coin and request a payout. Your balance stays in EUR/USD; the coin is only what the payout is sent as. Minimum 10 € (or $), at most 2,000 per day, one open payout at a time. Each coin keeps its own address, and a new address has to be 24 hours old before it can receive a payout. Requests up to 500 € are approved automatically within a minute; larger ones are checked by InstantNode. Before you confirm, the dashboard shows a quote (rate, coin amount, estimated network fee, what arrives), valid for 60 seconds; the network fee comes out of the payout. The crypto amount is fixed again at approval and sent to your wallet. You receive
payout.paidorpayout.failedas a webhook; thepayout.assetfield tells you which coin was sent,networkFeeCryptowhat the transfer cost. - Automatic payouts. Under Payouts set a threshold, a schedule (as soon as it is reached, at most once a day, at most once a week) and an amount to keep in the balance; the rule requests payouts by itself, marked Automatic. To automate from your own system,
POST /v1/payoutswith{ "currency": "EUR", "amount": "250.00" }(or"amount": "all") does exactly what the dashboard form does, with the same checks; it answers201with the payout object,409 payout_openwhile one is open, and400 test_modefor a test key.
Your account
- Application. Under Account you fill in the business profile: name shown to customers, legal name, website, business category, country, VAT id (optional), expected monthly volume and what you sell, then accept the merchant terms. InstantNode reviews it, usually within a day; you get a mail either way. Test keys work while you wait.
- Terms. When the merchant terms change you get a mail, and the next dashboard visit shows the new version with a checkbox before anything else opens; API keys and webhooks keep working meanwhile. The current version is always at /terms.
- Notifications. Under Notifications you choose which mails you want (payment received, payout or refund sent or failed, webhook endpoint disabled, account status, new sign-in from a new device), add up to 5 extra recipients, and optionally a Discord or Slack webhook that gets the same events as a short message.
- Sessions. Account lists every signed-in browser with device, IP and last activity; sign one out or all others. A sign-in from a device we had not seen on your account is mailed to you.
- Data export. Create export builds a zip with your profile, invoices, ledger, payouts, refunds, endpoints, events and payment links as JSON. The link works for 60 minutes and is shown once; a fresh sign-in is asked for.
- Closing the account. Possible once the live balance is zero and no payout or refund is open. Keys and endpoints are revoked at once, all sessions end, the records stay for the retention period.
API reference
Base URL https://pay.instantnode.eu. Every /v1 call needs X-API-Key: sk_live_… (or Authorization: Bearer …); a key from before the pairs (ik_<id>.<secret>) keeps working. Keys live under API keys: a secret key per mode for your server (full access, shown once, rotate with a 24 h overlap, optional IP allowlist) and a public key per mode for the browser, which is only accepted on GET /v1/public/config, POST /v1/public/checkout-sessions and the checkout status. Everything else answers 403 public_key_not_allowed to a public key.
| Scope | Allows |
|---|---|
invoices:read | Reading invoices, currencies, your account, balance, ledger and payouts |
invoices:write | Creating invoices, preselecting a coin, simulating test payments |
Writing calls need an active account (403 merchant_not_approved while pending, 403 merchant_suspended when suspended). A test key may write while the account is still pending. Reading always works.
Every endpoint with parameters, bodies and responses is in the interactive reference below. You can try requests there with your own key.
Errors come as JSON with a stable error code:
{"error": "invalid_request", "message": "amount: Required", "requestId": "…"}| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Key missing or wrong |
forbidden | 403 | The key lacks the scope |
public_key_not_allowed | 403 | A public key was used outside /v1/public/* |
ip_not_allowed | 403 | The key has an IP allowlist and this address is not on it |
key_expired | 401 | The key was rotated and its 24 h grace period is over |
merchant_not_approved | 403 | Account not active yet; submit the application in the dashboard |
merchant_suspended | 403 | Account suspended; open invoices are still settled |
not_found | 404 | No such invoice for this key |
invalid_request | 400 | A field is missing or malformed |
invalid_amount | 400 | Amount not positive or more than two decimals |
price_currency_not_allowed | 400 | Only EUR and USD |
unsafe_webhook_url | 400 | Not https, not a public hostname, or a private target |
selection_failed | 400 | Coin unavailable, amount too small or invoice expired |
not_test_invoice | 403 | simulate was called on a live invoice |
idempotency_conflict | 409 | Same Idempotency-Key, different body |
too_many_open_invoices | 429 | More than 1000 open invoices |
amount_too_large | 400 | Above the maximum invoice amount set for your account |
maintenance | 503 | InstantPay is in maintenance: new live invoices and payout requests are paused for a moment; retry after the Retry-After seconds |
Too Many Requests | 429 | Rate limit; see the retry-after header |
internal_error | 500 | Tell us the requestId |
SDKs and examples
Everything is a single file you copy into your project; there is nothing to install.
- Node: /sdk/node/instapay.mjs -
createInvoice,getInvoice,listInvoices,selectAsset,getMerchant,listLedger,listPayouts,getPayout,createPayout,listRefunds,getRefund,simulatePayment,listEvents,getEvent,verifyWebhook,parseWebhook. The constructor takes the secret key;parseWebhookunderstands both the default and the legacy body. - PHP: /sdk/php/InstaPay.php - the same methods for PHP 8 (class
InstantPay). - Complete shop: /examples/demo-shop/server.mjs is the source of the demo shop: products, hosted checkout, overlay, webhook receiver and order list in one file.
- Plain HTML overlay: /examples/plain-html.html.
- WooCommerce: /examples/woocommerce-instapay.php together with the PHP SDK in
wp-content/plugins/instapay/; enter base URL, key and webhook secret under WooCommerce → Settings → Payments.
Questions or a stuck integration: [email protected].