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.

Response · 404
{
  "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

StatusMeaning
200OK. The body has the result.
201Created. The body has the new record.
204Done, nothing to return — typically a delete.
401The key is missing, invalid, revoked, expired, or its creator lost access.
402The organization has not bought what this needs, or it has lapsed to read-only.
403The key or the member it acts as may not do this, or the organization or network is refused.
404No such record in this organization, or no such endpoint.
405Wrong HTTP method for this path.
422Validation failed. errors says which fields and why.
429Rate limited. Wait Retry-After seconds.
5xxSomething 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 — write to change data, admin to manage webhook endpoints.

    Create a key with the scope named in required.

    Also returns required: the missing scope

  • The 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

  • 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 from

  • The 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 /me for the modules the organization holds. The module can be added under Settings → Subscription.

    Also returns module, feature, current_tier, required_tier

  • The 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_tier

  • The change would need more seats than the module’s subscription has.

    Add seats to the module, or free one up.

    Also returns module, seats, needed

  • A metered allowance for the period is spent.

    Wait until resets_at, or raise the allowance.

    Also returns metric, limit, resets_at

  • 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 errors and send the request again. Do not retry unchanged.

    Also returns errors: field name → list of problems

  • The key has made more requests this minute than its limit allows.

    Wait retry_after seconds (also sent as the Retry-After header), 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.

Response · 422
{
  "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.

Response · 402
{
  "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 in Retry-After, then retry. See Rate limits.
  • 5xx and 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;
}