ionety
Docs

Build with ionety in 5 minutes.

Create an invoice, share the hosted checkout, get paid to your wallet, and listen for the invoice.paid webhook. That's the whole integration.

01

Quick start

Three steps from zero to your first paid invoice:

  1. 1Sign in to dashboard.ionety.com and create an API key. You'll see the ion_pk_… value once — copy it now.
  2. 2Create an invoice with one POST. ionety returns an invoice id.
  3. 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.

02

Authentication

All /v1 endpoints (other than /v1/public/*) require a Bearer token. Generate it from your dashboard.

Bash
curl https://api.ionety.com/v1/invoices \
  -H "Authorization: Bearer ion_pk_..."
Treat your API key like a password. Never expose it in client-side code or commit it to a repo. If you suspect leakage, revoke it from the dashboard and create a new one — old keys cease to authenticate immediately.
03

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.

Request
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"
  }'
Response · 201
{
  "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"
}
FieldTypeDescription
amountstringRequired. Positive integer string in token base units.
descriptionstringOptional. Up to 500 characters, shown on the checkout page.
metadataobjectOptional. Arbitrary JSON returned on retrieve and webhook payloads.
expiresAtstringOptional ISO-8601 datetime. Past this point the invoice will not accept payment.
tokenAddressstringOptional. Defaults to USDC. Accepted on Base: USDC, USDT, DAI.
chainIdnumberOptional override. Defaults to your account's active chain.
successUrlstringOptional https:// URL the customer is redirected to ~4s after the invoice is paid. See Redirects.
cancelUrlstringOptional https:// URL the customer can return to via a 'Back to merchant' link on the checkout page.
04

Hosted checkout

Every invoice has a public checkout page. There is no separate setup step — share the URL anywhere:

URL
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.

05

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.

FieldTypeDescription
successUrlstringWhere the customer lands ~4s after payment confirms. Append-safe — we set ?invoice_id=… without trampling your existing query string.
cancelUrlstringTarget 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:

Redirect
GET /order/1234/complete?invoice_id=62c29010-cbf3-4c97-bfb3-733a258ae406
Never trust the redirect alone to mark an order paid. Anyone who has the URL can hit it. The source of truth is the invoice.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.

06

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.

The webhook payload, indexer behavior, and dashboard UI are identical regardless of whether the customer paid directly on Base or bridged from another chain. The same invoice.paid event fires; the same paymentRef format applies. You don't need to special-case anything.
06b

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:

  1. 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.
  2. 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
    }
  }
}
Lightning Network is not yet supported. v1 is on-chain only — the customer must pay regular Bitcoin (not LN) and wait for confirmation. Lightning is on the roadmap pending license clarification on the relevant atomic-swap libraries.
07

List & retrieve

Retrieve a single invoice:

Request
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.

Request
curl "https://api.ionety.com/v1/invoices?status=paid&limit=20" \
  -H "Authorization: Bearer ion_pk_..."
08

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.

Register
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"]
  }'
Response · 201
{
  "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:

Delivery
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.

Webhook deliveries are idempotent at the source — the same 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.
09

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.

TypeScript · verify.ts
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:

TypeScript · express
// 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.

10

Invoice statuses

FieldTypeDescription
pendingstringCreated and awaiting payment.
paidstringSettled on chain. paidAt and paymentRef are populated; webhooks fire.
expiredstringexpiresAt elapsed without a successful on-chain settlement.
cancelledstringVoided from the dashboard. Will not accept payment.
11

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.

Shape
{
  "error": {
    "code": "validation_failed",
    "message": "validation failed",
    "details": [ /* zod issues, when applicable */ ]
  }
}
FieldTypeDescription
unauthorized401Missing or invalid API key.
forbidden403Authenticated but the action is not permitted on this resource.
not_found404Invoice / webhook id doesn't exist or doesn't belong to you.
validation_failed400Body or query failed validation. See details for field-level issues.
conflict409Resource is already in a state that conflicts with the request.
internal_error500Server-side problem. Safe to retry idempotent calls.
12

API reference

FieldTypeDescription
POST /v1/invoicesauthCreate an invoice.
GET /v1/invoicesauthList invoices for your account. status, limit, cursor.
GET /v1/invoices/{id}authRetrieve an invoice by id.
POST /v1/webhooksauthRegister a webhook URL. Returns the secret once.
GET /v1/webhooksauthList your registered webhooks (no secrets).
DELETE /v1/webhooks/{id}authDelete a webhook.
GET /v1/public/invoices/{id}publicRead an invoice for the hosted checkout page.
GET /healthzpublicLiveness probe.

Need something the docs don't cover? Email [email protected].