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:
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
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_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3cTwo kinds of key
| Key | Looks like | Opens |
|---|---|---|
| Account key | ct_live_ + 32 characters | The Public API: invoices, contacts, escalations |
| Partner key | ct_partner_ + 32 characters | The 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
| Situation | Status | Code |
|---|---|---|
No Authorization header | 401 | UNAUTHENTICATED |
| Header present but not a bearer token | 401 | UNAUTHENTICATED |
| Well-formed key that is unknown, revoked, or from the other key space | 401 | INVALID_API_KEY |
| Valid key, but the account has been switched off | 403 | ACCOUNT_DISABLED |
| Valid partner key, but the partner has been switched off | 403 | PARTNER_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:
- Schedule the rotation for a quiet period.
- Generate the new key.
- 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
| Code | Status | What it means | What to do |
|---|---|---|---|
UNAUTHENTICATED | 401 | No key, or not a bearer token | Check the Authorization header is present and well formed |
INVALID_API_KEY | 401 | Unknown, revoked, or wrong key space | Confirm you are using the right key for the right API |
ACCOUNT_DISABLED | 403 | The account has been switched off | The message names who switched it off. Contact them or Chargetree |
PARTNER_DISABLED | 403 | Your partner account has been switched off | Contact Chargetree |
RATE_LIMITED | 429 | Quota exceeded | Wait for Retry-After, then retry |
VALIDATION_ERROR | 400 | The body or parameters were rejected | Read field_errors. Do not retry unchanged |
NOT_FOUND | 404 | No such record in your account | Check the identifier. Records in other accounts are never visible |
INVOICE_LOCKED | 409 | The invoice is paid or cancelled and cannot be edited | Raise a new invoice or a credit instead |
INVOICE_NOT_CANCELLABLE | 400 | The invoice cannot be cancelled, typically because it is paid | Refund through Stripe rather than cancelling |
DUPLICATE_INVOICE_NUMBER | 409 | That invoice number is already used in your account | Pick another, or let Chargetree generate one |
PLAN_LIMIT_REACHED | 409 | The account's plan limit has been hit | Upgrade the plan |
IDEMPOTENCY_KEY_REUSED | 409 | The key was used before with a different body | Use a fresh key, or resend the original body |
IDEMPOTENT_REPLAY_IN_FLIGHT | 409 | An identical request is still being processed | Wait a moment and retry with the same key |
CONFLICT | 409 | The request conflicts with current state | The message explains. Common on draft transitions and duplicate emails |
INTERNAL_ERROR | 500 | Something failed on our side | Safe to retry, ideally with the same idempotency key |
Which errors are worth retrying
| Response | Retry? |
|---|---|
429 RATE_LIMITED | Yes — after Retry-After |
500 INTERNAL_ERROR | Yes — with backoff |
409 IDEMPOTENT_REPLAY_IN_FLIGHT | Yes — after a short pause, same key |
502, 503, 504 | Yes — with backoff |
Any 4xx not listed above | No — the request itself needs fixing |
Retrying a 400 or a 404 unchanged will fail identically every time and only consumes quota.
A retry helper
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 type | Quota |
|---|---|
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."
}
}| Header | Meaning |
|---|---|
Retry-After | Whole seconds to wait. Always at least 1. |
X-RateLimit-Limit | The quota for the bucket you hit. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | When 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.
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-2a9b7d1f6c33If 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
| Endpoint | Honours Idempotency-Key |
|---|---|
POST /invoices | Yes |
POST /contacts | Yes |
| Everything else | No — 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:
| Situation | Result |
|---|---|
| Same key, same body, original request finished | The original response is replayed, with its original status code |
| Same key, different body | 409 IDEMPOTENCY_KEY_REUSED, with the original response included as original_response |
| Same key, same body, original still being processed | 409 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
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.
| Endpoint | Style |
|---|---|
GET /invoices | Cursor |
GET /partner/accounts | Limit 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
}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.
Chargetree API
REST APIs and webhooks for invoicing, contacts, escalations and partner account provisioning.
Contacts
The people and companies you invoice. Contacts are matched rather than blindly duplicated: supply an `id`, `external_id` or `email` and Chargetree reuses the existing record where it can.