Getting started

Everything you need to integrate — keys, errors, quotas, safe retries and pagination.

This page covers the whole account-scoped Public API: getting a key, making your first calls, and the four behaviours you will need to handle in production. Use the table of contents to jump to a section.

For endpoint-by-endpoint detail, see the Invoices, Contacts and Escalations reference. For provisioning accounts on behalf of your own customers, see the Partner API.

Quickstart

The shortest useful path: get a key, create a contact, raise an invoice, and get told when it is paid.

1. Generate an API key

Sign in to Chargetree and go to Settings → API, then choose Generate API key.

The key looks like ct_live_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3c. It is shown exactly once — copy it into your secret store straight away.

Rotating a key has no grace period

Generating a new key revokes the old one immediately. Anything still using it stops working that second, so plan rotations for a quiet window.

Keys belong to the account, not to you personally. A team member leaving does not invalidate the key.

2. Make your first request

Every request carries the key as a bearer token:

Create a contact
curl -X POST https://manage.chargetree.co/api/v1/contacts \
  -H "Authorization: Bearer ct_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Pty Ltd",
    "email": "ap@acme.example",
    "phone": "+61 400 000 000",
    "external_id": "crm-4821"
  }'

You get back 201 Created and the contact.

Passing external_id — your own identifier for this customer — is worth the small effort. It lets you call this endpoint again later without worrying about creating a duplicate: Chargetree matches on it and returns the existing contact with a 200 instead.

3. Raise an invoice

Create and send an invoice
curl -X POST https://manage.chargetree.co/api/v1/invoices \
  -H "Authorization: Bearer ct_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "contact": { "email": "ap@acme.example" },
    "invoice_date": "2026-07-01",
    "due_date": "2026-07-31",
    "line_items": [
      {
        "description": "Onsite EV charger installation",
        "quantity": 1,
        "unit_amount": 1850.00,
        "tax_rate": 10,
        "tax_type": "OUTPUT"
      }
    ],
    "send_invoice": true
  }'

With send_invoice: true the invoice is emailed to the contact immediately. Leave it out and the invoice is raised but not sent; pass is_draft: true instead and it is saved as a draft for someone to review.

The Idempotency-Key header means that if the connection drops and you retry, you get the original invoice back rather than raising a second one. It costs nothing to include and is covered under Idempotency below.

4. Find out when it is paid

Rather than polling, register a webhook endpoint under Settings → Webhooks and subscribe to invoice.payment_recorded. Chargetree posts to your URL when money arrives.

See Webhooks for the event shape and how to verify a delivery really came from us.

Authentication

Every request carries a key as a bearer token:

Authorization: Bearer ct_live_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3c

Two kinds of key

KeyLooks likeOpens
Account keyct_live_ + 32 charactersThe Public API: invoices, contacts, escalations
Partner keyct_partner_ + 32 charactersThe Partner API: creating and managing accounts

The two are separate spaces. Presenting an account key to a partner endpoint — or the reverse — is rejected as INVALID_API_KEY before any lookup happens. There is no overlap and no escalation path between them.

Account keys are created in the Chargetree dashboard under Settings → API, or issued through the Partner API when a partner provisions an account. Partner keys are issued by Chargetree directly.

What each authentication failure means

SituationStatusCode
No Authorization header401UNAUTHENTICATED
Header present but not a bearer token401UNAUTHENTICATED
Well-formed key that is unknown, revoked, or from the other key space401INVALID_API_KEY
Valid key, but the account has been switched off403ACCOUNT_DISABLED
Valid partner key, but the partner has been switched off403PARTNER_DISABLED

ACCOUNT_DISABLED is what an account key returns after a partner deactivates that account. The message names who switched it off, so your support team can tell a billing problem from a partner action.

How keys are stored

Only a SHA-256 digest of your key is kept in our database. We cannot read your key back to you, and a breach of our storage does not hand anyone a usable credential.

The practical consequence is that losing the plaintext means generating a new one. There is no recovery.

Rotating a key

Generating a new key immediately revokes the previous one. There is no overlap window, so:

  1. Schedule the rotation for a quiet period.
  2. Generate the new key.
  3. Deploy it everywhere before the next request goes out.

For partner-managed accounts, the same applies to POST /partner/accounts/{id}/api-key.

Keeping keys safe

  • Server-side only. These APIs send no CORS headers, so a browser cannot call them anyway — but the deeper reason is that a key in front-end code is a key you have given away.
  • HTTPS only. Plain HTTP requests are rejected at the edge, before reaching any handler.
  • Never log the key. Log the key's last four characters if you need to identify which credential a request used.
  • One key per integration where you can, so revoking one does not take down everything at once.

Errors

Every error uses the same shape, so you can handle them uniformly:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "One or more fields are invalid.",
    "field_errors": [
      { "field": "due_date", "message": "due_date must be an ISO date, like 2026-07-31." }
    ]
  }
}

Branch on the code, never the message

code is a stable contract. message is written for a human reading a log and its wording may change at any time.

field_errors appears on validation failures and names each field at fault. Nested fields use dots and indexes, for example line_items.0.quantity.

The full list of error codes

CodeStatusWhat it meansWhat to do
UNAUTHENTICATED401No key, or not a bearer tokenCheck the Authorization header is present and well formed
INVALID_API_KEY401Unknown, revoked, or wrong key spaceConfirm you are using the right key for the right API
ACCOUNT_DISABLED403The account has been switched offThe message names who switched it off. Contact them or Chargetree
PARTNER_DISABLED403Your partner account has been switched offContact Chargetree
RATE_LIMITED429Quota exceededWait for Retry-After, then retry
VALIDATION_ERROR400The body or parameters were rejectedRead field_errors. Do not retry unchanged
NOT_FOUND404No such record in your accountCheck the identifier. Records in other accounts are never visible
INVOICE_LOCKED409The invoice is paid or cancelled and cannot be editedRaise a new invoice or a credit instead
INVOICE_NOT_CANCELLABLE400The invoice cannot be cancelled, typically because it is paidRefund through Stripe rather than cancelling
DUPLICATE_INVOICE_NUMBER409That invoice number is already used in your accountPick another, or let Chargetree generate one
PLAN_LIMIT_REACHED409The account's plan limit has been hitUpgrade the plan
IDEMPOTENCY_KEY_REUSED409The key was used before with a different bodyUse a fresh key, or resend the original body
IDEMPOTENT_REPLAY_IN_FLIGHT409An identical request is still being processedWait a moment and retry with the same key
CONFLICT409The request conflicts with current stateThe message explains. Common on draft transitions and duplicate emails
INTERNAL_ERROR500Something failed on our sideSafe to retry, ideally with the same idempotency key

Which errors are worth retrying

ResponseRetry?
429 RATE_LIMITEDYes — after Retry-After
500 INTERNAL_ERRORYes — with backoff
409 IDEMPOTENT_REPLAY_IN_FLIGHTYes — after a short pause, same key
502, 503, 504Yes — with backoff
Any 4xx not listed aboveNo — the request itself needs fixing

Retrying a 400 or a 404 unchanged will fail identically every time and only consumes quota.

A retry helper

Retry only what is worth retrying
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);

async function callChargetree(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, init);

    if (response.ok) return response;
    if (!RETRYABLE_STATUSES.has(response.status)) return response; // Caller's problem to fix.

    // Chargetree's own Retry-After wins; otherwise back off exponentially.
    const retryAfter = Number(response.headers.get('Retry-After') ?? 0);
    const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;

    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }

  throw new Error('Chargetree request failed after 4 attempts');
}

Validation errors in practice

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "One or more fields are invalid.",
    "field_errors": [
      { "field": "line_items.0.quantity", "message": "quantity must be a number." },
      { "field": "brand_color", "message": "brand_color must be a six digit hex colour, like #46E943" }
    ]
  }
}

Surfacing field_errors directly to whoever submitted the data usually beats a generic failure message — the wording is written to be read by a person.

Rate limits

Quotas apply per API key, over a rolling one-minute window.

Request typeQuota
Reads (GET, HEAD)120 per minute
Writes (POST, PUT, PATCH, DELETE)30 per minute

Reads and writes are counted in separate buckets, so a burst of invoice creation does not stop you reading. Partner keys get the same quotas, counted against the partner key rather than any account.

There is no longer-window quota

These per-minute windows are the only limits applied. There is no daily or monthly cap layered on top, so a job that stays under 30 writes per minute can run as long as it needs to.

When you exceed a quota

HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735689600
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "API rate limit exceeded. Try again after the Retry-After window."
  }
}
HeaderMeaning
Retry-AfterWhole seconds to wait. Always at least 1.
X-RateLimit-LimitThe quota for the bucket you hit.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetWhen the window resets, as a Unix timestamp in seconds.

Staying inside the quota

Honour Retry-After. It is the one number that is always correct. Retrying sooner just burns quota and extends the window.

Respect the wait Chargetree asks for
async function callWithBackoff(request: () => Promise<Response>): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await request();

    if (response.status !== 429) return response;

    // Chargetree tells us exactly how long to wait, so use that rather than
    // guessing with a doubling delay.
    const waitSeconds = Number(response.headers.get('Retry-After') ?? 1);
    await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
  }

  throw new Error('Still rate limited after 5 attempts');
}

Prefer webhooks to polling. Most quota problems come from repeatedly fetching invoices to check whether they have been paid. Subscribe to invoice.payment_recorded and let us tell you instead.

Batch with pagination, not parallelism. Requesting limit=100 once costs a single read; a hundred concurrent single fetches cost a hundred. See Pagination below.

Spread bulk work. If you raise invoices in a nightly run, a small pause between writes keeps you comfortably inside the 30-per-minute write quota.

Idempotency

When a request times out you rarely know whether it landed. Retrying blindly risks invoicing a customer twice; not retrying risks never invoicing them at all. An idempotency key removes the guess.

Send a unique value — a UUID is ideal — on the request:

Idempotency-Key: 0f2a5f1e-9f1a-4e0c-9a4e-2a9b7d1f6c33

If the same key arrives again with the same body, you get the original response back instead of a second invoice being raised.

Which endpoints support it

EndpointHonours Idempotency-Key
POST /invoicesYes
POST /contactsYes
Everything elseNo — the header is ignored

Cancelling is not covered

POST /invoices/{id}/cancel does not honour the header; it is silently ignored. In practice this is harmless, because cancelling an invoice twice is not destructive — the second attempt simply reports that the invoice is no longer cancellable.

How a replay is recognised

Chargetree stores the key alongside a fingerprint of the request body. The fingerprint is taken after sorting the fields, so the order you write your JSON in does not matter — the same data always produces the same fingerprint.

Three things can happen when a key is reused:

SituationResult
Same key, same body, original request finishedThe original response is replayed, with its original status code
Same key, different body409 IDEMPOTENCY_KEY_REUSED, with the original response included as original_response
Same key, same body, original still being processed409 IDEMPOTENT_REPLAY_IN_FLIGHT

IDEMPOTENT_REPLAY_IN_FLIGHT means "ask again shortly", not "this failed". Wait a second or two and retry with the same key.

If a request never finishes

Should a request stall — a crash mid-flight, say — its key would otherwise stay reserved forever. To avoid that, a reservation that has been in flight for more than 60 seconds is considered abandoned, and the next request using that key proceeds normally under its own fingerprint.

The practical advice: wait at least a minute before retrying a request you never got any response to.

How long keys are remembered

Keys are retained for at least 24 hours, and in practice longer — nothing currently deletes them on a schedule. Design for the guarantee rather than the current behaviour: if you need to reuse a key meaningfully, do it well inside a day.

A safe retry pattern

Create an invoice exactly once
import { randomUUID } from 'node:crypto';

async function createInvoiceOnce(body: unknown): Promise<Response> {
  // The key is generated once, outside the retry loop. Reusing it across
  // attempts is the whole point: a new key on each try would defeat the
  // protection and could raise several invoices.
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch('https://manage.chargetree.co/api/v1/invoices', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CHARGETREE_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    // A replay that is still being processed: pause briefly and ask again.
    if (response.status === 409) {
      const { error } = await response.clone().json();
      if (error?.code === 'IDEMPOTENT_REPLAY_IN_FLIGHT') {
        await new Promise((resolve) => setTimeout(resolve, 2000));
        continue;
      }
    }

    return response;
  }

  throw new Error('Invoice creation did not settle after 3 attempts');
}

Choosing keys

Generate a fresh key for each logical operation, not for each HTTP attempt. If your own system already has a unique identifier for the thing you are billing — a job number, an order id — deriving the key from it is even better, because a retry from a different process still lines up.

Pagination

The two list endpoints page differently, because they solve different problems.

EndpointStyle
GET /invoicesCursor
GET /partner/accountsLimit and offset

Both default to 25 records and cap at 100. Asking for more than 100 is not an error — you simply get 100.

Invoices: cursor pagination

Each page carries a next_cursor. Pass it back to get the following page, and stop when has_more is false.

{
  "data": [ /* … invoices … */ ],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0wMVQwMDowMDowMFoiLCJpZCI6Ii4uLiJ9",
  "has_more": true
}
Walk every unpaid invoice
async function* allInvoices(status: string): AsyncGenerator<unknown> {
  let cursor: string | null = null;

  do {
    const params = new URLSearchParams({ status, limit: '100' });
    if (cursor) params.set('cursor', cursor);

    const response = await fetch(`https://manage.chargetree.co/api/v1/invoices?${params}`, {
      headers: { Authorization: `Bearer ${process.env.CHARGETREE_API_KEY}` },
    });

    const page = await response.json();
    yield* page.data;

    // Stop on has_more rather than on an empty page: a full page can still be
    // the last one.
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
}

Why cursors

Invoices are created while you page through them. An offset would shift underneath you, showing some invoices twice and skipping others. A cursor points at a fixed position, so the walk stays stable.

Treat the cursor as opaque. It encodes a position and its format may change.

Filtering

GET /invoices accepts status, contact_id, invoice_number, date_from and date_to. status takes a comma-separated list:

curl "https://manage.chargetree.co/api/v1/invoices?status=UNPAID,OVERDUE&limit=100" \
  -H "Authorization: Bearer ct_live_..."

Filtering server-side is far cheaper than fetching everything and filtering locally — it uses less of your request quota.

Partner accounts: limit and offset

curl "https://manage.chargetree.co/api/v1/partner/accounts?limit=100&offset=200" \
  -H "Authorization: Bearer ct_partner_..."
{
  "data": [ /* … accounts … */ ],
  "total": 438,
  "limit": 100,
  "offset": 200
}

Keep increasing offset by limit until you have collected total records.

Looking up a single account by your own identifier short-circuits all of this:

curl "https://manage.chargetree.co/api/v1/partner/accounts?external_id=crm-9931" \
  -H "Authorization: Bearer ct_partner_..."

That response contains only data and total, without limit and offset, and returns 404 if you have no account with that identifier.

On this page