Developer documentation
Accept crypto with one API call and a webhook. The full machine-readable spec is at /docs (OpenAPI).
Quickstart
- Create an account and an app in the dashboard. Set the coin, network and wallet address you want to be paid in.
- Create a test API key on the app's API keys tab.
- Create a payment from your server and redirect the customer to
checkout_url. - Set a webhook URL and mark the order paid when you receive
payment.paid. - Switch to a live key when you are ready.
Authentication
Send your secret key as a bearer token: Authorization: Bearer sk_live_…. Keys starting with sk_test_ create test-mode payments that use simulated providers. Keys are only shown once; keep them on your server and never in browser or mobile code.
Create a payment
curl https://zodypay.com/api/v1/payments \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1042" \
-H "Content-Type: application/json" \
-d '{
"amount": "49.99",
"currency": "USD",
"order_id": "1042",
"description": "2x T-shirt",
"success_url": "https://shop.example/thanks",
"metadata": { "customer_id": "c_881" }
}'| Field | Description |
|---|---|
amount | Decimal string, e.g. "49.99". Numbers are rejected to avoid float rounding. |
currency | Fiat (USD, EUR, GBP, …) or a coin ticker. |
order_id | Your reference; returned in every webhook. |
success_url, cancel_url | Optional redirects from the checkout page. |
metadata | Up to 20 string key/value pairs. |
expires_in_minutes | 15–1440, default 60. |
Response (HTTP 201):
{
"id": "pay_Xk2v9sW1...",
"object": "payment",
"livemode": false,
"status": "pending",
"order_id": "1042",
"amount": "49.99",
"currency": "USD",
"settlement": {
"coin": "USDC", "network": "base",
"address": "0x…", "memo": null,
"expected_amount": "49.99", "received_amount": null,
"tx_hash": null, "tx_url": null
},
"swap": null,
"checkout_url": "https://pay.example.com/p/pay_Xk2v9sW1...",
"metadata": { "customer_id": "c_881" },
"expires_at": "2026-10-26T13:00:00.000Z",
"paid_at": null,
"created_at": "2026-10-26T12:00:00.000Z"
}settlement.expected_amount is what you will receive in your settlement coin. Stablecoins pegged to the invoice currency convert 1:1; other coins are priced from a market feed when the payment is created.
Payment statuses
pending | Waiting for the customer to choose a coin and send funds. |
processing | Deposit detected; the swap is confirming or exchanging. |
paid | Funds delivered to your wallet (within your underpayment tolerance). Fulfil the order. |
underpaid | Delivered, but less than expected. Check settlement.received_amount. |
expired | No payment before expires_at. A late deposit can still move it to paid. |
refunded / failed | The provider refunded the customer or the swap failed. |
canceled | Canceled through the API before the customer paid. |
Webhooks
Events: payment.processing, payment.paid, payment.underpaid, payment.expired, payment.refunded, payment.failed, payment.canceled. Respond with any 2xx within 10 seconds. Failed deliveries are retried 10 times over about two days (30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h, 12h, 24h) and can be resent from the dashboard.
POST /your/webhook
Content-Type: application/json
ZodyPay-Signature: t=1761480000,v1=5f2b…c9
{
"id": "evt_9sd8f7…",
"type": "payment.paid",
"created_at": "2026-10-26T12:04:11.000Z",
"data": { "object": { /* payment, same shape as the API */ } }
}Verifying signatures
The ZodyPay-Signature header contains a timestamp and an HMAC-SHA256 of {timestamp}.{raw body} keyed with your app's webhook secret (dashboard → Webhooks). Reject old timestamps to prevent replays.
Node.js
import crypto from 'node:crypto';
import express from 'express';
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = String(parts.v1 ?? '');
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
const app = express();
// Use the raw body: re-serialising parsed JSON changes the bytes and breaks the signature.
app.post('/webhooks/zodypay', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verify(raw, req.get('ZodyPay-Signature') ?? '', process.env.ZODYPAY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
// Deduplicate on event.id — deliveries can repeat.
if (event.type === 'payment.paid') markOrderPaid(event.data.object.order_id);
res.sendStatus(200);
});Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", 0))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Idempotency
Send Idempotency-Key on POST /v1/payments (your order id works well). Retrying with the same key and body returns the original payment with header Idempotent-Replayed: true. Reusing a key with a different body returns 409. Keys are kept for 7 days.
Errors
{ "error": { "code": "invalid_request", "message": "Request validation failed", "details": [...] } }400 | Invalid parameters. |
401 | Missing or invalid API key. |
404 | Not found (or belongs to another app/mode). |
409 | Conflict, e.g. canceling a paid payment or reusing an idempotency key. |
429 | Rate limited; back off and retry. |
Test mode
Test keys route through two simulated providers (MockSwap and MockDEX) with fixed prices. After a swap is created it advances automatically: awaiting deposit → confirming → exchanging → settled. Enter MOCK-UNDERPAY as the refund address at checkout to simulate a 10% underpayment.