Reference
Errors & Limits
The response envelope, every status code we return, and how throttling behaves.
The response envelope
Every response has the same shape, whether it succeeded or not. Branch on the HTTP status, read message for a human, read error for a machine.
successis always present and always a boolean.dataappears only on success. Every failure carrieserrorinstead. They never both appear.erroris an object with acode, except on a validation failure, where it is an array of{ field, code, message }with exactly one entry per offending field. Therecodeisrequiredorinvalid_format.messageis written for a human reading a log. Do not match on it. Match onerror.code, which is stable.
{
"success": true,
"message": "Tracking link created",
"data": { "id": 90124 }
}Status codes
200OKThe request succeeded. Read data. Also returned when creating a tracking link that already exists.
201CreatedA new object exists. data carries it, including its new id.
202AcceptedQueued for background work. Webhook test events and delivery replays return this.
400Bad RequestMalformed JSON or a request that failed validation.
401UnauthorizedMissing, malformed, paused, expired or revoked API key. Not a scope problem.
403ForbiddenThe key is valid but lacks the scope or is outside its IP allowlist, or the channel or program is not in a state that allows the write.
404Not FoundNo such object, or one your account cannot see. We do not distinguish the two, on purpose. Also an unknown route.
409ConflictThe write clashes with what already exists: a second application for the same program and channel, a second channel for one domain, a sixth webhook endpoint.
413Payload Too LargeThe request body is over 1 MB.
422UnprocessableWell-formed and authorised, but the domain rules say no: a deep link off-domain, a webhook URL that is not public https, a verification tag we could not find.
429Too Many RequestsRate limited. Read Retry-After and back off.
500Server ErrorOur fault. Safe to retry an idempotent request; open a ticket if it persists.
Error codes
error.code is the contract. It is stable across releases, and it is what your retry and alerting logic should branch on.
malformed_json400The request body is not valid JSON.
invalid_api_key401The key does not exist, is paused, expired or was revoked.
insufficient_scope403Valid key, missing scope. The body names the scope that was required.
ip_not_allowed403The calling address is outside the key's allowlist. error.ip is the address we saw, often IPv6.
route_not_found404No endpoint matches the method and path. Check for a missing /v1 or a typo.
program_not_found404Unknown program ID, or one not available to your account.
channel_not_found404The channelId does not exist or is not one of yours.
tracking_link_not_found404The tracking link does not exist or is not one of yours.
withdrawal_not_found404The withdrawal does not exist or is not one of yours.
webhook_not_found404The webhook endpoint does not exist, was deleted, or is not one of yours.
delivery_not_found404No delivery with that ID belongs to this webhook endpoint.
program_not_approved403There is no approved application for this program on the channel you passed.
channel_not_verified403The channel is not approved, or has a verification step it has not completed, so it cannot apply or carry links.
duplicate_application409This program and channel pair already has an application, whatever its status, rejected included. error carries applicationId and status. Re-applying is not possible through the API; contact support.
duplicate_channel409You already have a channel for this domain.
verification_not_required409The channel's category has no verification step. Social, email and other channels are reviewed by hand.
webhook_limit_reached409The account already has five webhook endpoints. Delete one first.
delivery_not_replayable409The delivery is still queued or retrying. Only succeeded or failed deliveries can be replayed.
payload_too_large413The request body is over 1 MB.
destination_not_allowed422The program does not support deep links, or destinationUrl is not on the merchant's domain or a subdomain of it. error.merchantDomain names the domain.
verification_failed422We fetched the channel URL but did not find the verification meta tag, or the page could not be fetched.
url_not_allowed422The webhook URL is not https on port 443, carries credentials, or resolves to a private address.
rate_limited429Too many requests in the window. Retry-After tells you when.
quota_exceeded429The account has used its monthly quota. Retry-After counts down to the 1st (UTC).
internal_error500Something failed on our side. Safe to retry an idempotent request with back-off.
{
"success": false,
"message": "This key is missing the links:write scope",
"error": {
"code": "insufficient_scope",
"required": "links:write",
"granted": ["programs:read", "reports:read"]
}
}Rate limits
Limits are per account, shared by all of its API keys: 120 requests per minute in a fixed one-minute window, against a monthly quota of 250,000 calls. Every request that gets past authentication carries the X-RateLimit-* headers, and the X-Quota-* headers unless it was rate limited. A 401 or an ip_not_allowed 403 carries neither.
X-RateLimit-Resetis a Unix timestamp for when the per-minute window clears.X-Quota-Usedis the month's count before the current request;X-Quota-Limitis the monthly quota.Retry-Afterappears only on a429and is in seconds. Honour it rather than guessing.- Exceeding the monthly quota returns
429until the first of the next month (UTC), witherror.codeset toquota_exceeded. - Calls refused with a
401, anip_not_allowed403or any429do not count towards the quota. Every other authenticated call does, including ones that fail with a4xx. - The click redirect at
go.linkapprove.comallows up to 50 requests per second per visitor IP, a ceiling real visitors never reach.
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 103
X-RateLimit-Reset: 1755511260
X-Quota-Used: 128402
X-Quota-Limit: 250000Retries and idempotency
What is safe to retry
- Every
GETis safe. Retry freely. 429and5xxare safe to retry with back-off, because the request either did not run or did not complete.4xxother than429will fail identically on retry. Fix the request instead.
Is it us or you?
GET /v1/health needs no key, is not rate limited and does not count towards the quota. A 200 with "API is healthy" means the API is up, so a failure elsewhere is about the request or the key.
Writes are naturally idempotent
409 rather than creating a duplicate, and minting a link with an identical program, channel, destination URL, subId and subId2 returns the existing link with 200 instead of 201. A retried write will not double up.async function callWithRetry(request, attempts = 4) {
for (let attempt = 0; attempt < attempts; attempt++) {
const res = await request();
if (res.status !== 429 && res.status < 500) return res;
const retryAfter = Number(res.headers.get("Retry-After"));
const backoff = retryAfter
? retryAfter * 1000
: Math.min(2 ** attempt * 500, 8000);
await new Promise((resolve) => setTimeout(resolve, backoff));
}
throw new Error("Gave up after retries");
}