API documentation

REST API and webhooks for online stores: create invoices and receive signed payment notifications.

Quick start

Create an invoice via the API, send the buyer to checkout_url, wait for the invoice.paid webhook — and ship. By then the funds are already in your wallet.

  1. Get an API key from support (Telegram or support@ethpaid.com) — keys are issued manually during the pilot.
  2. Set your webhook URL (PUT /v1/webhook) and store the signing secret.
  3. Create an invoice for each order and redirect the buyer to checkout_url.
  4. Handle webhooks, verifying the signature.

Base URL: https://api.ethpaid.com

Keys and modes

Send the key in the Authorization: Bearer header. sk_test_… keys work with test networks (Sepolia, Base Sepolia, BSC Testnet, Tron Nile) and can simulate payments; sk_live_… keys use mainnets. Test and live invoices and links are separate. The key is shown once — keep it on your server, never in the browser.

Create an invoice

POST /v1/invoices

curl https://api.ethpaid.com/v1/invoices \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"amount": "25.00", "currency": "USD", "order_id": "1042",
       "description": "Order #1042", "redirect_url": "https://shop.example.com/thanks"}'
FieldTypeDescription
amountstringAmount, e.g. "25.50"
currencystringUSD (default), USDT, USDC, EUR
order_idstringYour order number, up to 200 chars
descriptionstringShown to the buyer, up to 500 chars
redirect_urlstringWhere to send the buyer after paying (https)
metadataobjectAnything — returned in webhooks
networksarrayLimit networks: ethereum, bsc, base, tron (test: sepolia, bsc-testnet, base-sepolia, tron-nile)

currency — USD, USDT, USDC or EUR (EUR is converted at the rate when created, rounded up). order_id is unique per store: a repeat returns 409. The Idempotency-Key header prevents duplicates when a request is retried.

Invoice object

{
  "id": "3401423f-c7f5-43b0-a115-3c4b14ef6ba6",
  "object": "invoice",
  "number": 128,
  "mode": "test",
  "status": "new",
  "amount": "25",
  "currency": "USD",
  "order_id": "1042",
  "checkout_url": "https://pay.ethpaid.com/i/3401423f-c7f5-43b0-a115-3c4b14ef6ba6",
  "expires_at": "2026-10-26T12:00:00+00:00",
  "paid_at": null,
  "payment_options": [
    { "network": "base-sepolia", "token": "USDT", "address": "0x7F80…31BD", "amount": "25", "network_fee": "0" },
    { "network": "tron-nile", "token": "USDT", "address": "TYj1…k8Ux", "amount": "26.47", "network_fee": "1.47" }
  ]
}

payment_options — one per network, each with its own address and exact amount. network_fee — the Tron network fee when the buyer pays it (already included in amount).

Statuses

newawaiting payment
pendingpayment seen, waiting for confirmations
paidpaid — ship the order
partially_paidpartly paid; the buyer may top up, or accept as is (POST …/accept)
overpaidpaid with extra — ship; the extra can be refunded
paid_latepaid after expiry — accept or refund
expiredexpired unpaid
cancelledcancelled

Webhooks

A POST to your URL on every status change: invoice.pending, invoice.paid, invoice.partially_paid, invoice.overpaid, invoice.paid_late, invoice.expired, invoice.cancelled (and test.ping on request). Reply 2xx — otherwise we retry after 1 min, 5 min, 15 min, 1 h, 6 h and 24 h. https only, no internal IPs.

{
  "id": "6c1f…",
  "type": "invoice.paid",
  "created": 1792952000,
  "data": { "id": "3401423f-…", "status": "paid", "order_id": "1042", "metadata": { … }, … }
}

Verifying signatures

Header ETHPAID-Signature: t=<time>,v1=<signature>. The signature is HMAC-SHA256 with your webhook secret over “t.raw_body” (the body exactly as received). Reject requests older than 5 minutes. An event may arrive more than once — use the event id to deduplicate.

PHP

<?php
$secret = getenv('ETHPAID_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');            // raw body, do not re-encode
parse_str(str_replace(',', '&', $_SERVER['HTTP_ETHPAID_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, $secret);
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int)$sig['t']) > 300) {
    http_response_code(400); exit;
}
$event = json_decode($body, true);
if ($event['type'] === 'invoice.paid') {
    // mark order $event['data']['order_id'] as paid (once per $event['id'])
}
http_response_code(200);

Node.js

import crypto from "node:crypto";
// Express: app.post("/ethpaid", express.raw({ type: "application/json" }), handler)
function verify(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const mac = crypto.createHmac("sha256", secret).update(`${p.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(p.t)) <= 300;
  const a = Buffer.from(mac), b = Buffer.from(p.v1 ?? "");
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Other methods

GET /v1/invoices/{id}get an invoice
GET /v1/invoices?status=paid&limit=20list invoices (before=<id> for the next page)
POST /v1/invoices/{id}/cancelcancel an unpaid invoice
POST /v1/invoices/{id}/acceptaccept an underpayment or a late payment
POST /v1/invoices/{id}/refundsstart a refund: a link for the buyer to enter an address
POST /v1/linkspermanent payment link + HTML button
POST /v1/invoices/{id}/simulatesimulate a payment (test key only)
GET /v1/invoices.csv?days=90CSV export
PUT /v1/webhookset the webhook URL, get a new secret
POST /v1/webhook/testsend test.ping
GET /v1/networksavailable networks and tokens

Errors

401missing or invalid key
404invoice not found (or other mode)
409conflict: order_id taken, invoice cannot be cancelled, etc.
422invalid data: below the network minimum, no wallet, etc.
503issuing new invoices is paused — retry later (Retry-After)