Guide
Errors
Every error has the same shape: a message for people and a stable code for your program. The HTTP status says what kind of problem it is; the code says exactly which.
The error shape
An error response is JSON with two fields that are always present — message, written for a person, and error, a stable machine-readable code — plus fields specific to some codes.
{
"message": "Resource not found.",
"error": "not_found"
}Switch on error, never on message. Messages are reworded and may be translated; codes are part of the API contract and do not change within a version.
Status codes
| Status | Meaning |
|---|---|
| 200 | OK. The body has the result. |
| 201 | Created. The body has the new record. |
| 204 | Done, nothing to return — typically a delete. |
| 401 | The key is missing, invalid, revoked, expired, or its creator lost access. |
| 402 | The organization has not bought what this needs, or it has lapsed to read-only. |
| 403 | The key or the member it acts as may not do this, or the organization or network is refused. |
| 404 | No such record in this organization, or no such endpoint. |
| 405 | Wrong HTTP method for this path. |
| 422 | Validation failed. errors says which fields and why. |
| 429 | Rate limited. Wait Retry-After seconds. |
| 5xx | Something failed on our side. Safe to retry with backoff. |
Error codes
Every code the API returns, with what it means and what to do about it.
No
Authorization: Bearer …header was sent.Send the key as
Authorization: Bearer cp_live_…on every request.The key does not exist, has been revoked, or has expired.
Check the key under Settings → API keys. A revoked or expired key cannot be revived; create a new one.
The member who created the key has left the organization or been blocked, so the key stopped working with them.
Have an active member create a new key and deploy it.
The key lacks the scope this request needs —
writeto change data,adminto manage webhook endpoints.Create a key with the scope named in
required.Also returns
required: the missing scopeThe member the key acts as does not have the permission this endpoint needs.
Give that member a role with one of the permissions in
required, or use a key created by a member who has one.Also returns
required: permissions, any one of which would do- forbidden403
The member has the permission in general, but not for this record — a record-level policy refused it.
Check who owns the record and what the member is allowed to see in the panel.
The organization has an IP allowlist and the request came from an address that is not on it.
Add the integration’s outbound address under Settings → Security & GDPR, or call from an allowed network.
Also returns
ip: the address the request came fromThe organization is suspended. No API call succeeds until it is reinstated.
Contact Closepact support.
The endpoint belongs to a module the organization has not bought, or a feature its tier does not include.
Check
GET /mefor the modules the organization holds. The module can be added under Settings → Subscription.Also returns
module,feature,current_tier,required_tierThe module has lapsed. Its data can still be read, but not changed.
Reads keep working. Writes resume when the subscription is renewed.
Also returns
module,feature,current_tier,required_tierThe change would need more seats than the module’s subscription has.
Add seats to the module, or free one up.
Also returns
module,seats,neededA metered allowance for the period is spent.
Wait until
resets_at, or raise the allowance.Also returns
metric,limit,resets_at- not_found404
No such record in this organization — or no such endpoint.
Check the id. Records of other organizations are never visible, so a 404 is also what an id from the wrong organization gets.
The path exists, but not with this HTTP method.
Check the method in the reference.
The request body or query failed validation.
Fix the fields listed in
errorsand send the request again. Do not retry unchanged.Also returns
errors: field name → list of problems- rate_limited429
The key has made more requests this minute than its limit allows.
Wait
retry_afterseconds (also sent as theRetry-Afterheader), then retry.Also returns
retry_after: seconds to wait
Two more can come from the platform rather than the API: a 5xx means something failed on our side and is safe to retry, and a request to a path that does not exist is 404 not_found.
Validation errors
A 422 validation_failed lists every field that failed in errors: field name to a list of messages. Fields inside arrays and objects use dot notation — lines.0.quantity is the quantity of the first line. Fix the request; sending it again unchanged fails the same way.
{
"message": "The title field is required. (and 1 more error)",
"error": "validation_failed",
"errors": {
"title": [
"The title field is required."
],
"lines.0.quantity": [
"The lines.0.quantity field must be at least 1."
]
}
}Module errors
402 is not a bug in your integration: the request was valid and the member may make it, but the organization has not bought what it needs. module says which module; required_tier is set when a higher tier would allow it. Check GET /me at start-up and leave out what the organization does not hold.
{
"message": "The invoicing module is not active for this organization.",
"error": "module_locked",
"module": "invoicing",
"feature": null,
"current_tier": null,
"required_tier": null
}Retrying
429— wait the number of seconds inRetry-After, then retry. See Rate limits.5xxand network errors — retry with exponential backoff and a little jitter, and give up after a few attempts.- Every other
4xx— do not retry unchanged. The answer will be the same.
A request that timed out may still have succeeded. Before retrying a POST that timed out, check whether the first attempt created the record, so a retry does not create it twice.
// Retries 429 and 5xx with backoff; everything else is returned to the caller.
export async function closepact(path, init = {}, attempt = 0) {
const response = await fetch(`https://api.closepact.com/v1/api${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.CLOSEPACT_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
const retryable = response.status === 429 || response.status >= 500;
if (retryable && attempt < 4) {
const wait = response.status === 429
? Number(response.headers.get('Retry-After') ?? 1) * 1000
: 2 ** attempt * 500 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, wait));
return closepact(path, init, attempt + 1);
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) {
// Switch on body.error, never on body.message.
throw Object.assign(new Error(body.message), { status: response.status, code: body.error, body });
}
return body;
}