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
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 project
  • POST /v1/wallets — per API key
  • POST /v1/payment and POST /v1/payment/{payment_id}/refund

Replay behaviour

FieldTypeDescription
First requestprocessesRuns normally. A 2xx status and body are cached against the key; a non-2xx releases the claim so you can retry.
Replaycached 2xxA repeat with the same key returns the original status and body verbatim.
In-flight duplicate409If the first request is still processing, the duplicate gets 409. Back off and retry the same key.
Window (TTL)24 hoursKeys 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.

POST/v1/payment/{payment_id}/refundx-api-key

Request body

Both fields are optional:

FieldTypeDescription
addressstringWhere to send the refund. Defaults to the original payer's address.
amountnumberHow 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 payer

Refunds 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 an Idempotency-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

Error
{
  "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

HTTPCodeMeaning
400VALIDATION_ERRORBad params, amount, or insufficient balance. Read details.
401UNAUTHORIZEDMissing or invalid API key.
403FORBIDDENAuthenticated but not allowed (e.g. invalid key on wallet creation).
404NOT_FOUNDThe resource does not exist.
409IDEMPOTENCY_CONFLICTSame Idempotency-Key still processing. Retry with backoff.
429RATE_LIMITEDToo many requests. Honor Retry-After.
500INTERNAL_ERRORUnexpected server error. Idempotent requests are safe to retry.
Some not-found conditions surface as 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.

JavaScript — honor Retry-After
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.

Search docs

Search the documentation

Ask the AI assistant