Node4 API Errors, Rate Limits and Retries

What each Node4 API status and error code means, how to read the rate-limit headers, and which requests are safe to retry after a failure.

Treat the HTTP status as the decision and the body as the detail: a 429 means wait for Retry-After and resend, a 5xx or a dropped connection on a GET means retry with backoff, and any other 4xx means fix the request, because sending it again unchanged returns the same answer. Writes are the exception to "retry with backoff". Purchases are retried only with the same Idempotency-Key; replacing a proxy, creating a sub-user, creating a webhook and changing credentials are never retried blindly. The sections below give the codes, the headers and a retry helper in Node.js and Python.

The error envelope

Every failure from https://node4.io/api/v1 is JSON with an error string:

{ "error": "Insufficient permissions. This endpoint requires the 'read:usage' permission." }

The purchase endpoints (POST /orders, POST /topups) add a stable, machine-readable code, and some answers carry extra fields your code can use:

{
  "error": "This repeats order #9301, a card purchase of the same plan in the last 2 minutes, so the card was not charged again. ...",
  "code": "duplicate_recent_purchase",
  "orderId": 9301
}

Program against the status and code. The error sentence is written for people and may be reworded at any time. A successful response is always { "success": true, "data": ... }, so success is never present on a failure. Getting started shows a wrapper that turns this envelope into exceptions.

Status codes

StatusMeaningRetry?
400The request is malformed: a missing or invalid field, an unknown parameter value, a bad id, or a missing Idempotency-Key on a purchaseNo. Fix the request
401No API key, a value that does not start with n4k_, or a key that does not existNo. Check the header
402A card purchase could not be paid; nothing was chargedNo. See the purchase codes below
403The key is revoked or expired, the account is not active, the key lacks the scope this endpoint needs, or the plan does not include the featureNo
404No such resource on your account, or no endpoint at that pathNo
409A conflict: another change to the same thing is in progress, the proxy is not active, or a purchase conflictDepends on the case; see below
422The request is well formed but refused, such as a plan that is not for sale or an Idempotency-Key reused with a different bodyNo
429Too many requests, or a monthly allowance is used upYes, after Retry-After
500Unexpected server errorReads: yes, with backoff. Purchases: check orders first
502 / 503A dependency is unavailable for the momentReads: yes, with backoff. Purchases: only with the same key

401 or 403: which one you got matters

A 401 means the server could not identify a key at all. The usual causes are a missing header, Bearer without the space, a truncated key, or quotes pasted into the value. The message is either Missing or invalid API key... or Invalid API key.

A 403 means the key was recognized and the request is still not allowed. Read the error:

404 for other accounts' resources

Asking for an order, proxy, credential, sub-user, webhook or invoice that belongs to a different account returns 404 with the same message as one that never existed, for example {"error": "Order not found."}. The API does not confirm that someone else's id exists. If your own id answers 404, check that the key belongs to the account you think it does (GET /profile shows the account behind a key).

A path that matches no endpoint is also 404, with No API endpoint matches GET /api/v1/.... That one is a typo in your code, not a missing record.

Rate limits and their headers

Each key may make 60 requests per minute, the same on every plan, counted separately for each key. Every authenticated response tells you where you stand:

HTTP/1.1 200 OK
RateLimit-Limit: 60
RateLimit-Remaining: 17
RateLimit-Reset: 23
RateLimit-Policy: 60;w=60
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1792281623

RateLimit-Reset is the number of seconds until the window resets; X-RateLimit-Reset is the Unix time at which it resets. Pick one family and read it consistently. Once the window is used up, the answer is:

HTTP/1.1 429 Too Many Requests
Retry-After: 23
{
  "error": "Rate limit exceeded. Please wait before making more requests.",
  "retryAfterSeconds": 23,
  "limit": 60,
  "windowSeconds": 60
}

A rate-limited request is turned away before any handler runs, so nothing happened and resending it after Retry-After is safe for every method, writes included.

Not every 429 is the per-minute limit. POST /proxies/{id}/replace answers 429 when the month's replacement allowance is spent; its body carries replacementLimit and resetsAt, and its Retry-After can be days. Check for resetsAt before scheduling an automatic retry, or your job will sleep until next month.

There is also a per-address limit: all requests from one IP share 300 requests per minute across /api, whatever key they use. Its 429 says Too many requests from this IP. Please try again later. and carries retryAfterSeconds and Retry-After, but no limit or windowSeconds. Several busy integrations behind one address can hit it before any single key reaches its own limit; the same wait-and-resend rule applies.

To stay under the limit:

Which requests are safe to retry

RequestAfter a timeout, 5xx or dropped connection
Any GETRetry with backoff
POST /orders, POST /topupsRetry with the same Idempotency-Key and the same body
POST /proxies/{id}/replace, POST /proxies/refreshDo not retry. Read GET /proxies to see whether the IP changed
POST /sub-usersDo not retry. Read GET /sub-users; a retry can create a second login
POST /webhooksDo not retry. Read GET /webhooks; a retry can register the URL twice
POST, PATCH or DELETE /credentials, PATCH or DELETE /sub-users/{id}Read the current state first, then decide
POST or DELETE /ip-whitelistRead GET /ip-whitelist first. A repeated add of an IP you already whitelisted is refused with 400 and the message This IP address is already whitelisted by another account., even though the entry is yours; if GET /ip-whitelist lists it, the first add worked and you can ignore the refusal
Any request answered 429 by the per-minute limiterRetry after Retry-After

The rule behind the table: only reads and the two purchase endpoints give the same result when sent twice. Every other write takes effect each time it runs, and a timeout does not tell you whether the first attempt ran. A replacement in particular retires the old address and issues new credentials, so a second one moves the proxy again and spends another unit of the monthly allowance.

A 409 that says another change is in progress (Another credential change for this account is in progress. Try again in a moment.) means your request did not run. Wait a second or two and resend it.

Retrying purchases

Purchase answers carry a code. Grouped by what to do:

CodeStatusWhat to do
idempotency_in_flight409The first request with this key is still running. Wait a few seconds and resend with the same key
checkout_in_progress409Another checkout for the same thing is running. Wait, then resend with the same key
rate_limited429Wait for Retry-After, resend with the same key
payment_provider_unavailable502Nothing was created. Resend later with the same key
unavailable503Purchases are paused for the moment; nothing was charged. Resend later with the same key
outcome_unknown409Stop. Check GET /orders; contact support if needed. Never switch to a new key
payment_unconfirmed409Stop. An earlier card payment is still being confirmed or reviewed. Check GET /orders/{orderId}
duplicate_recent_purchase409Stop. The purchase already exists at orderId; pay its checkoutUrl if Bitcoin
payment_held402Stop. The payment is under review; the order resolves on its own
checkout_error, internal_error500Check GET /orders (and orderId, when present) before anything else. Resending with the same key may replay this answer; a checkout_error that charged nothing frees the key, so the resend runs again
idempotency_key_reused422This key was used for a different body. A new purchase needs a new key
idempotency_key_required, invalid_request400Fix the header or the body
no_saved_card, card_expired, card_declined402Nothing was charged. Fix the card in the dashboard, then buy with a new key
plan_unavailable, addon_unavailable, topup_not_eligible, payment_method_unsupported, checkout_refused, profile_incomplete422Nothing was charged. Change the request, then use a new key
checkout_refused400The purchase could not be completed for an unexpected reason. Check GET /orders before trying again
not_found404A Bitcoin purchase named something that does not exist. Nothing was charged. Change the request, then use a new key
account_blocked, payment_method_unavailable403Contact support

A key is remembered for 24 hours from its first request, and resending the same key with the same body inside that window replays the stored answer with Idempotent-Replayed: true. That is why a refusal such as card_declined keeps coming back under the same key, and why the fix is a new key once the cause is fixed. The walkthrough of a purchase from plan to delivery is in buying plans and top-ups with the API, and the key rules are summarized in the reference.

A retry helper

Back off exponentially with jitter for 5xx and network errors, honor Retry-After on 429, and never loop on other 4xx. Only send reads and purchases through it.

const BASE = "https://node4.io/api/v1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// For GETs, and for POST /orders and /topups with a fixed Idempotency-Key.
export async function node4WithRetry(path, init = {}, attempts = 5) {
  for (let i = 0; ; i++) {
    let res;
    try {
      res = await fetch(BASE + path, {
        ...init,
        headers: { Authorization: `Bearer ${process.env.NODE4_API_KEY}`, "Content-Type": "application/json", ...init.headers },
      });
    } catch (networkError) {
      if (i >= attempts - 1) throw networkError;
      await sleep(Math.min(30_000, 1000 * 2 ** i) * (0.5 + Math.random()));
      continue;
    }
    let body;
    try {
      body = await res.json();
    } catch {
      // Not JSON: an edge or gateway error page. Retry it on 5xx, return it otherwise.
      body = { error: `HTTP ${res.status}`, unparsed: true };
    }
    const retryable =
      res.status === 429 || res.status === 502 || res.status === 503 ||
      (res.status === 500 && !init.method) || body.code === "idempotency_in_flight" ||
      (body.unparsed && res.status >= 500);
    if (res.ok || !retryable || i >= attempts - 1 || body.resetsAt) return { status: res.status, body };
    const wait = Number(res.headers.get("Retry-After")) * 1000 || Math.min(30_000, 1000 * 2 ** i) * (0.5 + Math.random());
    await sleep(wait);
  }
}
import os
import random
import time
import requests

BASE = "https://node4.io/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['NODE4_API_KEY']}"


def node4_with_retry(method, path, attempts=5, **kwargs):
    """For GETs, and for POST /orders and /topups with a fixed Idempotency-Key."""
    for i in range(attempts):
        backoff = min(30, 2 ** i) * (0.5 + random.random())
        try:
            res = session.request(method, BASE + path, timeout=60, **kwargs)
        except requests.ConnectionError:
            if i == attempts - 1:
                raise
            time.sleep(backoff)
            continue
        try:
            body = res.json()
            unparsed = False
        except ValueError:
            # Not JSON: an edge or gateway error page. Retry it on 5xx, return it otherwise.
            body = {"error": f"HTTP {res.status_code}"}
            unparsed = True
        retryable = (
            res.status_code in (429, 502, 503)
            or (res.status_code == 500 and method == "GET")
            or body.get("code") == "idempotency_in_flight"
            or (unparsed and res.status_code >= 500)
        )
        if res.ok or not retryable or i == attempts - 1 or "resetsAt" in body:
            return res.status_code, body
        time.sleep(float(res.headers.get("Retry-After") or backoff))

A purchase 500 is left out of the retry on purpose: it may mean the card was charged and setup failed, and the answer tells you what happened. Read it, then check the order. With a timeout on a purchase, by contrast, you have no answer at all, so resending with the same key is exactly right: it either replays the stored result or runs the purchase for the first time.

When the errors do not add up

More on keeping keys narrow and rotating them is in keeping API keys secure. What each plan includes is on the features page.