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
| Status | Meaning | Retry? |
|---|---|---|
400 | The request is malformed: a missing or invalid field, an unknown parameter value, a bad id, or a missing Idempotency-Key on a purchase | No. Fix the request |
401 | No API key, a value that does not start with n4k_, or a key that does not exist | No. Check the header |
402 | A card purchase could not be paid; nothing was charged | No. See the purchase codes below |
403 | The 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 feature | No |
404 | No such resource on your account, or no endpoint at that path | No |
409 | A conflict: another change to the same thing is in progress, the proxy is not active, or a purchase conflict | Depends on the case; see below |
422 | The request is well formed but refused, such as a plan that is not for sale or an Idempotency-Key reused with a different body | No |
429 | Too many requests, or a monthly allowance is used up | Yes, after Retry-After |
500 | Unexpected server error | Reads: yes, with backoff. Purchases: check orders first |
502 / 503 | A dependency is unavailable for the moment | Reads: 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:
API key has been revoked.orAPI key has expired.: create a new key under Account Settings → API & Webhooks.Account is not active.: the account itself is suspended or closed; contact support.Insufficient permissions. This endpoint requires the '<scope>' permission.: the key was created without that scope. Scopes are fixed at creation, so make a new key with it. The scope table says which scope each endpoint needs.- A plan restriction, such as
IP replacement is not available on the free plan.
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: 1792281623RateLimit-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:
- Give each integration its own key, so one busy job cannot use up another's budget.
- Slow down when
RateLimit-Remaininggets low instead of waiting for the429. - Replace polling with webhooks where an event exists (the webhooks section of the reference lists them).
- Cache what changes rarely: the plan list, the proxy list between deployments, location lists.
Which requests are safe to retry
| Request | After a timeout, 5xx or dropped connection |
|---|---|
Any GET | Retry with backoff |
POST /orders, POST /topups | Retry with the same Idempotency-Key and the same body |
POST /proxies/{id}/replace, POST /proxies/refresh | Do not retry. Read GET /proxies to see whether the IP changed |
POST /sub-users | Do not retry. Read GET /sub-users; a retry can create a second login |
POST /webhooks | Do 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-whitelist | Read 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 limiter | Retry 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:
| Code | Status | What to do |
|---|---|---|
idempotency_in_flight | 409 | The first request with this key is still running. Wait a few seconds and resend with the same key |
checkout_in_progress | 409 | Another checkout for the same thing is running. Wait, then resend with the same key |
rate_limited | 429 | Wait for Retry-After, resend with the same key |
payment_provider_unavailable | 502 | Nothing was created. Resend later with the same key |
unavailable | 503 | Purchases are paused for the moment; nothing was charged. Resend later with the same key |
outcome_unknown | 409 | Stop. Check GET /orders; contact support if needed. Never switch to a new key |
payment_unconfirmed | 409 | Stop. An earlier card payment is still being confirmed or reviewed. Check GET /orders/{orderId} |
duplicate_recent_purchase | 409 | Stop. The purchase already exists at orderId; pay its checkoutUrl if Bitcoin |
payment_held | 402 | Stop. The payment is under review; the order resolves on its own |
checkout_error, internal_error | 500 | Check 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_reused | 422 | This key was used for a different body. A new purchase needs a new key |
idempotency_key_required, invalid_request | 400 | Fix the header or the body |
no_saved_card, card_expired, card_declined | 402 | Nothing 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_incomplete | 422 | Nothing was charged. Change the request, then use a new key |
checkout_refused | 400 | The purchase could not be completed for an unexpected reason. Check GET /orders before trying again |
not_found | 404 | A Bitcoin purchase named something that does not exist. Nothing was charged. Change the request, then use a new key |
account_blocked, payment_method_unavailable | 403 | Contact 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
- The same request works in curl and fails in code: compare the exact
Authorizationheader both send. - A
403naming a scope you are sure you granted: you may be using an older key. Keys are listed with their permissions in Account Settings → API & Webhooks. - Sustained
5xxon reads: open a support ticket with the time, the path and theerrortext. Support is available 24/7, and we reply within 12 hours.
More on keeping keys narrow and rotating them is in keeping API keys secure. What each plan includes is on the features page.