Developers
A payments API that tells the truth about money
Quickstart
Charge each order at its real total, send the customer to checkout, and fulfil from a signed webhook. These are the same four steps in every language, and every sample below is a real file we run against the API.
- 1
Get your keys
Create a test account, then open API keys in the dashboard for a secret key (
sk_test_…) and Webhooks for an endpoint secret. Keep both on your server, as environment variables..env PLINTH_API_URL=https://plinth-api.zerolayer.online PLINTH_SECRET_KEY=sk_test_... PLINTH_WEBHOOK_SECRET=whsec_... - 2
Create a payment when the customer checks out
Compute the total on your server and send it as a string. The
Idempotency-Keymakes a retry return the same payment instead of a second one. Then redirect the customer tocheckout_url.create-payment.sh #!/usr/bin/env sh # Create a payment for one order. PLINTH_API_URL and PLINTH_SECRET_KEY come from your environment. curl -sS -X POST "$PLINTH_API_URL/v1/payment-intents" \ -H "Authorization: Bearer $PLINTH_SECRET_KEY" \ -H "Idempotency-Key: order-1042-1" \ -H "Content-Type: application/json" \ -d '{ "amount": "25.00", "order_reference": "order-1042", "success_url": "https://shop.example/thanks", "cancel_url": "https://shop.example/cart" }'Tested: each file is run against a live API by scripts/test-doc-examples.mjs.
What comes back (abridged)
response { "id": "pi_9e78c740ce82e30fdbed754ad9a97ea6", "status": "AWAITING_PAYMENT", "amount": "25.00", "amount_units": "25000000", "currency": "USDC", "order_reference": "order-1042", "checkout_url": "https://plinth.zerolayer.online/pay/pi_9e78c740ce82e30fdbed754ad9a97ea6", "success_url": "https://YOUR_DOMAIN/thanks", "expires_at": "2026-10-14T14:48:00.720Z", "networks": [{ "network": "arc-testnet", "usdc_address": "0x3600000000000000000000000000000000000000", "deposit_address": "0x08D7…7188", "wallet_checkout_supported": true }] }Taken from a real response on a local test chain; the network entry is shown for Arc Testnet. The deposit address is unique to this payment and no contract is deployed for it yet.
- 3
Fulfil from the webhook, never from the redirect
Add an endpoint in the dashboard that points at your server. This receiver verifies the signature on the raw body, rejects stale timestamps, ignores an event it has already seen, and acts on
payment.confirmed.webhook-server.mjs // Node 18+, no dependencies. Receives Plinth webhooks, verifies them, and acts once per event. import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SECRET = process.env.PLINTH_WEBHOOK_SECRET; const seen = new Set(); // use a database table in production, so a restart cannot cause a double delivery export function verify(rawBody, header, secret = SECRET, now = Math.floor(Date.now() / 1000)) { const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1 || Math.abs(now - t) > 300) return false; const want = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex"); const got = Buffer.from(parts.v1, "hex"); return got.length === want.length && timingSafeEqual(got, want); } export function onPaid(orderReference) { console.log("paid:", orderReference); // mark the order paid and fulfil it here } createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/webhooks/plinth") return res.writeHead(404).end(); const chunks = []; for await (const c of req) chunks.push(c); const raw = Buffer.concat(chunks).toString("utf8"); // verify the raw bytes, before any JSON parsing if (!verify(raw, req.headers["plinth-signature"])) return res.writeHead(401).end(); const event = JSON.parse(raw); if (!seen.has(event.id)) { seen.add(event.id); if (event.type === "payment.confirmed") onPaid(event.data.object.order_reference); } res.writeHead(200).end(); // a non-2xx makes Plinth retry later }).listen(Number(process.env.PORT ?? 8788));Tested: valid, tampered, stale and missing signatures, and a replayed event, in both languages.
- 4
Test it end to end
Pay the test checkout from a wallet with test USDC, or send test USDC to the deposit address. Your order should turn paid from the webhook alone. Then check the two failure cases: a webhook with a bad signature must be rejected (401), and a replayed event must change nothing.
Prefer an AI assistant? It can do these four steps for you.
API
Versioned under /v1. Test keys (sk_test_) operate on testnets, live keys on mainnets. Keys are stored hashed. The full OpenAPI 3.1 document is served by the API at /v1/openapi.json and committed to the repository as docs/openapi.json.
Core endpoints
- POST
/v1/payment-intentsCreate a payment (idempotent)
- GET
/v1/payment-intents/{id}Retrieve a payment
- GET
/v1/paymentsList and search payments
- POST
/v1/payment-linksCreate a reusable link
- POST
/v1/invoicesCreate a numbered invoice
- GET
/v1/networks · /v1/assetsNetworks, capabilities, official assets
- GET
/v1/settlements · /v1/transactionsSettlement and on-chain movements
- POST
/v1/refund-requestsRequest a refund; completes when your vault transfer is verified
- POST
/v1/webhook-endpointsRegister a signed webhook endpoint
- GET
/v1/webhook-deliveriesDelivery log, with manual retry
- GET
/v1/settlement-policyAccounting policy and live on-chain vault policy
Errors
{ "error": { "type": "invalid_request_error", "code": "VALIDATION_FAILED",
"message": "Request validation failed", "request_id": "req_…",
"details": [{ "path": "amount", "message": "Amount must be a decimal string…" }] } }Webhooks
Each request is signed: Plinth-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your endpoint secret. Reject timestamps older than 5 minutes. Failed deliveries are retried 8 times over about 10 hours; every attempt is logged in the dashboard. Deduplicate on event.id.
- Signature header
Plinth-Signature- Timestamp tolerance
- Reject older than 5 minutes
- Retries
- 8 over about 10 hours
- Deduplicate on
event.id
| Event | Meaning |
|---|---|
| payment.detected | A transfer was seen and verified. Not final. |
| payment.confirmed | Final funds satisfy the amount and your policy. Safe to fulfil. |
| payment.completed | Funds are confirmed inside the merchant vault. |
| payment.underpaid | Expired with only part of the amount. |
| payment.overpaid | More than the amount arrived (excess in data). |
| payment.expired | Expired with nothing received. |
| payment.late | Funds became final after expiry. |
| payment.invalid_asset | A non-official token was sent. |
| settlement.submitted | Relayer broadcast the sweep. |
| settlement.completed | Sweep is final. |
| settlement.failed | Sweep could not execute; being retried. |
| refund.requested | A refund was requested. |
| refund.completed | The refund transfer was verified on-chain. |
| payout.requested | |
| payout.confirmed | |
| payout.switched_to_vault | |
| payout.cancelled |
Payment states
Status is derived from verified facts; it is never set by a client. Multiple transfers add up. Past expiry, partial funds are underpaid and funds that become final late are a late payment.
- Created
CREATEDCreated, not yet open for payment.
- Awaiting payment
AWAITING_PAYMENTWaiting for funds.
- Detected
DETECTEDA transfer was seen on-chain. Not final yet.
- Confirming
CONFIRMINGFunds seen; waiting for network finality or the remaining amount.
- Paid
PAIDFinal funds cover the amount. Settlement is about to start.
- Overpaid
OVERPAIDMore than the requested amount was received.
- Settling
SETTLEMENT_PENDINGPaid in full. Moving funds into the merchant vault.
- Settled
SETTLEDFunds are confirmed inside the merchant vault.
- Expired
EXPIREDExpired without payment.
- Underpaid
UNDERPAIDExpired with only part of the amount received.
- Late payment
LATE_PAYMENTFunds arrived after expiry.
- Wrong token
INVALID_ASSETA token other than the official payment token was sent.
- Settlement failed
SETTLEMENT_FAILEDFunds are final but the sweep failed. Retrying.
- Refund requested
REFUND_REQUESTEDA refund is waiting for the merchant to send it.
- Partly refunded
PARTIALLY_REFUNDEDPart of the payment was returned.
- Refunded
REFUNDEDReturned to the customer.
Test in the sandbox
Everything runs on testnets with test keys. No real funds are involved at any step.
- 1
Create an account
Sign up and enable a testnet. No card, no KYC.
- 2
Create a vault
Name a wallet you control as the owner. The relayer pays for the deployment.
- 3
Make a test key and a payment
Create an
sk_test_key and callPOST /v1/payment-intents. - 4
Pay it and watch the webhook
Fund a wallet from Circle's faucet, pay your own checkout and receive signed events.
Locally, pnpm dev:stack starts a full stack with a seeded merchant, a local chain and test USDC.