Advanced
Three things you need once you are past your first payment: retrying requests safely, refunding a customer, and handling errors and rate limits.
Idempotency (safe retries)
If a request fails on the way and your code sends it again, an Idempotency-Key header makes that safe: ZyraPay runs the action once and replays the original response on any repeat. Use a fresh value (a UUID) per logical operation.
curl -X POST https://api.zyrapay.net/v1/checkout \
-H "x-api-key: $ZYRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f1c9b2a-7e54-4d0a-9b1f-8c2d6e0a1f33" \
-d '{ "items": [{ "name": "Pro plan", "amount": 5, "quantity": 1 }] }'Honored on the create and money-moving routes, each de-duplicating within its own scope:
POST /v1/checkout— per projectPOST /v1/wallets— per API keyPOST /v1/paymentandPOST /v1/payment/{payment_id}/refund
Replay behaviour
| Field | Type | Description |
|---|---|---|
First request | processes | Runs normally. A 2xx status and body are cached against the key; a non-2xx releases the claim so you can retry. |
Replay | cached 2xx | A repeat with the same key returns the original status and body verbatim. |
In-flight duplicate | 409 | If the first request is still processing, the duplicate gets 409. Back off and retry the same key. |
Window (TTL) | 24 hours | Keys are remembered for 24 hours. Retry within that window to get the replay. |
A second safety net
Independently of the idempotency key, payments are de-duplicated server-side — the same on-chain payment is never recorded or credited twice, even without a key.Safe-retry pattern
Generate the key before the first attempt and reuse it across every retry of the same operation. On a 409 or 5xx, back off and retry with the same key.
import { randomUUID } from "crypto";
async function createCheckoutSafely(body) {
const key = randomUUID(); // one key per logical operation
const headers = {
"x-api-key": process.env.ZYRA_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": key,
};
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch("https://api.zyrapay.net/v1/checkout", {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status === 409 || res.status >= 500) { // retry SAME key
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
continue;
}
throw new Error("ZyraPay " + res.status);
}
throw new Error("exhausted retries");
}Refunds
Return funds to a payer with a single authenticated request. The refund is sent on-chain, so it is a real transfer, not a database reversal.
/v1/payment/{payment_id}/refundx-api-keyRequest body
Both fields are optional:
| Field | Type | Description |
|---|---|---|
address | string | Where to send the refund. Defaults to the original payer's address. |
amount | number | How much to refund. Omit for a full refund (the whole actually_paid); pass a smaller value for a partial one. |
curl -X POST https://api.zyrapay.net/v1/payment/4830112957/refund \
-H "x-api-key: $ZYRA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c1e4f80-2a99-4b3d-8e21-5f0b9c7a1d42" \
-d '{ "amount": 5 }' # omit the body for a full refund to the payerRefunds are final and guarded
The same payment cannot be refunded twice, and partial refunds cannot exceed the original amount — a repeat is rejected rather than re-sent. Still pass anIdempotency-Key so a network retry replays the result instead of risking a second attempt. Once broadcast, an on-chain refund cannot be undone, so double-check the address. A successful refund fires a refunded callback to your IPN URL.Errors & rate limits
ZyraPay uses standard HTTP status codes and a consistent error envelope. Treat the HTTP status as the source of truth and branch on the machine error code rather than matching the message string.
Error envelope
{
"success": false,
"error": "VALIDATION_ERROR",
"message": "Item 1: amount is required and must be a positive number",
"statusCode": 400,
"details": ["Item 1: amount is required and must be a positive number"]
}Status & codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad params, amount, or insufficient balance. Read details. |
| 401 | UNAUTHORIZED | Missing or invalid API key. |
| 403 | FORBIDDEN | Authenticated but not allowed (e.g. invalid key on wallet creation). |
| 404 | NOT_FOUND | The resource does not exist. |
| 409 | IDEMPOTENCY_CONFLICT | Same Idempotency-Key still processing. Retry with backoff. |
| 429 | RATE_LIMITED | Too many requests. Honor Retry-After. |
| 500 | INTERNAL_ERROR | Unexpected server error. Idempotent requests are safe to retry. |
500 rather than 404. Don't assume 404 for every missing resource — branch on the status you receive.Rate limits
Wallet creation is limited to 200 requests per 15 minutes per API key (or IP). Exceeding it returns 429 with Retry-After and blocks the caller for one hour. Generate wallets as customers need them rather than pre-minting in loops.
async function withBackoff(fn, max = 4) {
for (let attempt = 0; attempt < max; attempt++) {
const res = await fn();
if (res.status !== 429 && res.status < 500) return res;
const retryAfter = Number(res.headers.get("Retry-After"));
const wait = retryAfter
? retryAfter * 1000
: 500 * 2 ** attempt + Math.random() * 250; // jittered backoff
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("exhausted retries");
}Related
For the production checklist see Going Live, and for webhook signature troubleshooting see Webhooks.