Quick start
Three steps from zero to your first paid invoice:
- 1Sign in to dashboard.ionety.com and create an API key. You'll see the
ion_pk_…value once — copy it now. - 2Create an invoice with one POST. ionety returns an invoice id.
- 3Send the customer to
https://pay.ionety.com/i/{id}. They pay in USDC, USDT, or DAI from their wallet on any major chain (Ethereum, Arbitrum, Optimism, Polygon, Base) and funds land directly in yours on Base.
ionety is live on Base mainnet and Bitcoin L1. Access is currently invite-only and the default per-transaction cap is $500 (in any of USDC, USDT, or DAI); reach out if your invoices need a higher cap.
Authentication
All /v1 endpoints (other than /v1/public/*) require a Bearer token. Generate it from your dashboard.
curl https://api.ionety.com/v1/invoices \
-H "Authorization: Bearer ion_pk_..."Create an invoice
POST /v1/invoices. amount is a string in the token's smallest base unit — for USDC and USDT (6 decimals), "1000000" means 1.00. For DAI (18 decimals), "1000000000000000000" means 1.00. This avoids float rounding bugs.
curl -X POST https://api.ionety.com/v1/invoices \
-H "Authorization: Bearer ion_pk_..." \
-H "Content-Type: application/json" \
-d '{
"amount": "1000000",
"description": "Order #1234",
"metadata": { "orderId": "1234" },
"successUrl": "https://your-site.com/order/1234/complete",
"cancelUrl": "https://your-site.com/order/1234"
}'{
"id": "62c29010-cbf3-4c97-bfb3-733a258ae406",
"amount": "1000000",
"tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"chainId": 8453,
"status": "pending",
"description": "Order #1234",
"metadata": { "orderId": "1234" },
"expiresAt": null,
"paidAt": null,
"paymentRef": null,
"successUrl": "https://your-site.com/order/1234/complete",
"cancelUrl": "https://your-site.com/order/1234",
"createdAt": "2026-05-03T22:54:09.123Z",
"updatedAt": "2026-05-03T22:54:09.123Z"
}| Field | Type | Description |
|---|---|---|
amount | string | Required. Positive integer string in token base units. |
description | string | Optional. Up to 500 characters, shown on the checkout page. |
metadata | object | Optional. Arbitrary JSON returned on retrieve and webhook payloads. |
expiresAt | string | Optional ISO-8601 datetime. Past this point the invoice will not accept payment. |
tokenAddress | string | Optional. Defaults to USDC. Accepted on Base: USDC, USDT, DAI. |
chainId | number | Optional override. Defaults to your account's active chain. |
successUrl | string | Optional https:// URL the customer is redirected to ~4s after the invoice is paid. See Redirects. |
cancelUrl | string | Optional https:// URL the customer can return to via a 'Back to merchant' link on the checkout page. |
Hosted checkout
Every invoice has a public checkout page. There is no separate setup step — share the URL anywhere:
https://pay.ionety.com/i/{invoiceId}The page handles wallet connection (MetaMask, Coinbase Wallet, WalletConnect), source-chain selection, the ERC-20 approval, and the on-chain payment. When the transaction is confirmed, the invoice flips to paid and any registered webhooks fire. See Cross-chain payments for the multichain flow.
The checkout reads the invoice from the unauthenticated GET /v1/public/invoices/{id} endpoint, so you can also build your own checkout if you prefer — merchant payout address, token, and chain are returned with the invoice.
Redirects
Send the customer back to your site after they finish — or abandon — checkout. Both URLs are optional and pinned at invoice creation. If you omit them, the checkout page just shows a terminal “Payment received” state and waits.
| Field | Type | Description |
|---|---|---|
successUrl | string | Where the customer lands ~4s after payment confirms. Append-safe — we set ?invoice_id=… without trampling your existing query string. |
cancelUrl | string | Target of the 'Back to merchant' link, shown only while the invoice is still pending. |
After the redirect lands on your server, you'll receive the invoice id as a query parameter:
GET /order/1234/complete?invoice_id=62c29010-cbf3-4c97-bfb3-733a258ae406invoice.paid webhook (recommended), or a server-side GET /v1/invoices/{id} check that confirms status === "paid" before fulfilling the order.Both URLs must use https:// (http://localhost is allowed for local development). They cannot be overridden by query parameters on the checkout page — the invoice creator decides where the customer ends up.
Cross-chain payments
The hosted checkout accepts USDC, USDT, and DAI on Base — and lets customers pay from any of these source chains. Settlement always lands as the same asset on Base in your payout address.
- Ethereum
- Arbitrum
- Optimism
- Polygon (USDC, USDT)
- Base
DAI bridging is available from Ethereum, Arbitrum, and Optimism. Polygon's bridged DAI route is not currently enabled.
When a customer pays from a non-Base chain, the checkout uses Across Protocol to bridge the funds. The relayer fronts gas on Base — the customer only needs source-chain ETH, an ERC-20 approve, and a single deposit tx. Bridge settlement is typically ~30 seconds.
The bridge fee (~0.05–0.25%) is paid by the customer, on top of the invoice amount. Merchants always receive the full invoice amount minus only the 1% protocol fee — bridge mechanics are invisible on your side.
invoice.paid event fires; the same paymentRef format applies. You don't need to special-case anything.Accepting Bitcoin
ionety supports native on-chain Bitcoin (Bitcoin L1, not wrapped). Customers send BTC directly to a unique address derived from your wallet's xpub — we never see, hold, or sign for the funds.
Setup, in two steps. Open Settings → Bitcoin in the dashboard:
- Paste your BIP84 account-level xpub (zpub or xpub-format) from any modern wallet (Sparrow, BlueWallet, Coldcard, etc.). We use it only to derive successive receive addresses (
m/0/n); it cannot spend. - Sign one USDC allowance transaction from your payout wallet to the ionety fee wallet. We pull our 1% fee weekly via on-chain
transferFrom— no Stripe, no card, no fiat. Revoke any time; revoking auto-disables BTC.
Once enabled, BTC appears as a currency option when you create invoices. The invoice amount is denominated in USD; we lock the BTC/USD rate at creation and the customer pays the locked sats amount via QR. Confirmation policy is configurable — 1 conf (~10 min) by default for retail, up to 6 confs for high-value merchants.
Webhook payload differences for BTC invoices:
{
"type": "invoice.paid",
"data": {
"invoice": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"currency": "BTC",
"amount": "47120", // sats
"tokenAddress": null, // null for BTC
"chainId": null, // null for BTC
"metadata": {}
},
"payment": {
"network": "bitcoin",
"txid": "<bitcoin txid>",
"confirmations": 1,
"valueSats": "47120",
"valueUsdCents": "5000", // USD value at lock time
"address": "bc1q…" // address the customer paid
}
}
}List & retrieve
Retrieve a single invoice:
curl https://api.ionety.com/v1/invoices/{id} \
-H "Authorization: Bearer ion_pk_..."List your invoices, optionally filtered by status. Cursor pagination — pass nextCursor from the previous response as cursor to fetch the next page.
curl "https://api.ionety.com/v1/invoices?status=paid&limit=20" \
-H "Authorization: Bearer ion_pk_..."Webhooks
Register an HTTPS endpoint to receive a POST when an invoice is paid. The plaintext secret is returned once in the creation response — store it; you cannot retrieve it again.
curl -X POST https://api.ionety.com/v1/webhooks \
-H "Authorization: Bearer ion_pk_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-site.com/hooks/ionety",
"events": ["invoice.paid"]
}'{
"id": "5b9a...",
"url": "https://your-site.com/hooks/ionety",
"events": ["invoice.paid"],
"active": true,
"createdAt": "2026-05-03T22:54:09.123Z",
"updatedAt": "2026-05-03T22:54:09.123Z",
"secret": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}When an invoice is paid, ionety POSTs the following to your URL:
POST /hooks/ionety
Content-Type: application/json
X-Ionety-Event: invoice.paid
X-Ionety-Delivery-Id: 7c1e...
X-Ionety-Signature: t=1762200849,v1=8f3a...
{
"type": "invoice.paid",
"data": {
"invoice": {
"id": "62c29010-cbf3-4c97-bfb3-733a258ae406",
"amount": "1000000",
"tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"chainId": 8453,
"metadata": { "orderId": "1234" }
},
"payment": {
"txHash": "0x40d3ad906ce0f2281d0237c84ac6ab392398c31e1367c69b4261b3d2cdcbe663",
"blockNumber": "45517845",
"merchantAddress": "0x4eAF...",
"payerAddress": "0xa1b2...",
"tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"amount": "1000000",
"fee": "10000"
}
}
}Respond with any 2xx status to acknowledge. Non-2xx responses (or timeouts past 10 seconds) are retried on an exponential schedule (30s, 1m, 5m, 30m, 2h, 6h, 12h) — about 21 hours total — then moved to a dead-letter state.
invoice.paid event is enqueued at most once per invoice. But network retries can still cause your handler to be called more than once. De-dupe on X-Ionety-Delivery-Id (or on data.invoice.id) to be safe.Verify signatures
Every webhook delivery includes an X-Ionety-Signature header in the form t=<unix>,v1=<hex>. The v1 value is HMAC-SHA256(secret, "{t}.{rawBody}"). You must verify it against the raw, unparsed request body.
import {createHmac, timingSafeEqual} from "node:crypto";
export function verifyIonetySignature(opts: {
header: string | undefined; // value of X-Ionety-Signature
rawBody: string; // unmodified request body
secret: string; // saved at webhook creation
toleranceSec?: number; // reject older deliveries (default 5 min)
}): boolean {
if (!opts.header) return false;
// Header format: "t=<unix>,v1=<hex>"
const parts: Record<string, string> = {};
for (const kv of opts.header.split(",")) {
const [k, v] = kv.split("=");
if (k && v) parts[k] = v;
}
const t = Number(parts.t);
const sig = parts.v1;
if (!t || !sig) return false;
const tolerance = opts.toleranceSec ?? 300;
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", opts.secret)
.update(`${t}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b);
}Express handler with the raw body wired up correctly:
// Express handler. Note: req.body must be the RAW string, not parsed JSON.
// Use express.raw({type: "application/json"}) on this route only.
app.post(
"/hooks/ionety",
express.raw({type: "application/json"}),
(req, res) => {
const ok = verifyIonetySignature({
header: req.header("x-ionety-signature"),
rawBody: req.body.toString("utf8"),
secret: process.env.IONETY_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "invoice.paid") {
// mark order paid in your DB...
}
res.status(200).end();
},
);The timestamp check rejects replays — if an attacker captures a valid signed request, they can't replay it after 5 minutes. Reject signatures whose t is too old before doing the HMAC compare.
Invoice statuses
| Field | Type | Description |
|---|---|---|
pending | string | Created and awaiting payment. |
paid | string | Settled on chain. paidAt and paymentRef are populated; webhooks fire. |
expired | string | expiresAt elapsed without a successful on-chain settlement. |
cancelled | string | Voided from the dashboard. Will not accept payment. |
Errors
Errors are returned as JSON with the shape below. Always check the HTTP status code first; the code field is a stable enum you can branch on.
{
"error": {
"code": "validation_failed",
"message": "validation failed",
"details": [ /* zod issues, when applicable */ ]
}
}| Field | Type | Description |
|---|---|---|
unauthorized | 401 | Missing or invalid API key. |
forbidden | 403 | Authenticated but the action is not permitted on this resource. |
not_found | 404 | Invoice / webhook id doesn't exist or doesn't belong to you. |
validation_failed | 400 | Body or query failed validation. See details for field-level issues. |
conflict | 409 | Resource is already in a state that conflicts with the request. |
internal_error | 500 | Server-side problem. Safe to retry idempotent calls. |
API reference
| Field | Type | Description |
|---|---|---|
POST /v1/invoices | auth | Create an invoice. |
GET /v1/invoices | auth | List invoices for your account. status, limit, cursor. |
GET /v1/invoices/{id} | auth | Retrieve an invoice by id. |
POST /v1/webhooks | auth | Register a webhook URL. Returns the secret once. |
GET /v1/webhooks | auth | List your registered webhooks (no secrets). |
DELETE /v1/webhooks/{id} | auth | Delete a webhook. |
GET /v1/public/invoices/{id} | public | Read an invoice for the hosted checkout page. |
GET /healthz | public | Liveness probe. |
Need something the docs don't cover? Email [email protected].