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.
- Get an API key from support (Telegram or support@ethpaid.com) — keys are issued manually during the pilot.
- Set your webhook URL (PUT /v1/webhook) and store the signing secret.
- Create an invoice for each order and redirect the buyer to checkout_url.
- 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"}'| Field | Type | Description |
|---|---|---|
amount | string | Amount, e.g. "25.50" |
currency | string | USD (default), USDT, USDC, EUR |
order_id | string | Your order number, up to 200 chars |
description | string | Shown to the buyer, up to 500 chars |
redirect_url | string | Where to send the buyer after paying (https) |
metadata | object | Anything — returned in webhooks |
networks | array | Limit 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
new | awaiting payment |
pending | payment seen, waiting for confirmations |
paid | paid — ship the order |
partially_paid | partly paid; the buyer may top up, or accept as is (POST …/accept) |
overpaid | paid with extra — ship; the extra can be refunded |
paid_late | paid after expiry — accept or refund |
expired | expired unpaid |
cancelled | cancelled |
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=20 | list invoices (before=<id> for the next page) |
POST /v1/invoices/{id}/cancel | cancel an unpaid invoice |
POST /v1/invoices/{id}/accept | accept an underpayment or a late payment |
POST /v1/invoices/{id}/refunds | start a refund: a link for the buyer to enter an address |
POST /v1/links | permanent payment link + HTML button |
POST /v1/invoices/{id}/simulate | simulate a payment (test key only) |
GET /v1/invoices.csv?days=90 | CSV export |
PUT /v1/webhook | set the webhook URL, get a new secret |
POST /v1/webhook/test | send test.ping |
GET /v1/networks | available networks and tokens |
Errors
401 | missing or invalid key |
404 | invoice not found (or other mode) |
409 | conflict: order_id taken, invoice cannot be cancelled, etc. |
422 | invalid data: below the network minimum, no wallet, etc. |
503 | issuing new invoices is paused — retry later (Retry-After) |
Integration questions — contact support Full schema (OpenAPI)