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" }
  }'
FieldDescription
amountDecimal string, e.g. "49.99". Numbers are rejected to avoid float rounding.
currencyFiat (USD, EUR, GBP, …) or a coin ticker.
order_idYour reference; returned in every webhook.
success_url, cancel_urlOptional redirects from the checkout page.
metadataUp to 20 string key/value pairs.
expires_in_minutes15–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

pendingWaiting for the customer to choose a coin and send funds.
processingDeposit detected; the swap is confirming or exchanging.
paidFunds delivered to your wallet (within your underpayment tolerance). Fulfil the order.
underpaidDelivered, but less than expected. Check settlement.received_amount.
expiredNo payment before expires_at. A late deposit can still move it to paid.
refunded / failedThe provider refunded the customer or the swap failed.
canceledCanceled 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": [...] } }
400Invalid parameters.
401Missing or invalid API key.
404Not found (or belongs to another app/mode).
409Conflict, e.g. canceling a paid payment or reusing an idempotency key.
429Rate 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.