DOCUMENTATION
Authentication
Sign in to your Chargetree account and go to Settings → API. Click Generate API key. The plaintext key is shown exactly once. Save it in your secret store immediately.
Rotating a key immediately invalidates the previous one. There is no grace period. Plan rotations during a maintenance window.
Generate an API Key
Sign in to your Chargetree account and go to Settings → API. Click Generate API key. The plaintext key is shown exactly once. Save it in your secret store immediately. Keys have the format:
ct_live_<32 characters> # eg. ct_live_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3c
The key belongs to the account, not the user – team members leaving the account do not invalidate the key.
Rotating a key immediately invalidates the previous one. There is no grace period. Plan rotations during a maintenance window.
Bearer token
Every request must include:
Authorization: Bearer ct_live_<your key>
Error codes
Header
Missing
Not a Bearer scheme
Valid Bearer, unknown or revoked key
Response
401 UNAUTHENTICATED
401 UNAUTHENTICATED
401 INVALID_API_KEY
HTTPS only. Plain-HTTP requests are rejected before they reach the handler.
No CORS. Server-to-server only. Browser-based integrations must proxy through your own backend.
Keys are stored hashed. Only the SHA-256 digest lives in our database. If you lose the plaintext, regenerate.
Rate limits
Quotas apply per API key, per rolling minute:
Method
GET
POST / PUT / DELETE
Limit / minute
120
30
Limit / hour
5,000
1,000
When you exceed a quota, the response is:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735689600
{
"error": {
"code": "RATE_LIMITED",
"message": "API rate limit exceeded. Try again after the Retry-After window."
}
}
Recommended client behaviour: back off for Retry-After seconds, then retry. Don't burn your quota with tight-loop retries — you'll just get more 429s.
Errors
All errors return the same envelope:
Method
GET
POST / PUT / DELETE
Limit / minute
120
30
Limit / hour
5,000
1,000
When you exceed a quota, the response is:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable message.",
"field_errors": [
{ "field": "line_items[0].unit_amount", "message": "must be >= 0" }
]
}
}
Code
UNAUTHENTICATED
INVALID_API_KEY
RATE_LIMITED
VALIDATION_ERROR
NOT_FOUND
INVOICE_LOCKED
INVOICE_NOT_CANCELLABLE
DUPLICATE_INVOICE_NUMBER
PLAN_LIMIT_REACHED
ACCOUNT_NOT_READY_TO_SEND
INTERNAL_ERROR
HTTP
401
401
429
400
404
403
400
409
402
409
500
Description
Missing or malformed Authorization header
Key not found or revoked
Over the per-key quota
Bad request shape; see `field_errors`
Resource does not exist in this account
Financial fields on a paid invoice cannot change
Attempted to cancel a PAID invoice
invoice_number already used in this account
Subscription blocks new invoices. Upgrade your plan
send_invoice: true but account has no checkout slug
Something broke on our side
Invoices
Create an invoice
POST /api/v1/invoices
Creates an invoice. If the referenced contact does not exist, one is created (or matched by `external_id`/`email` within your account).
{
"contact": {
"id": "existing-contact-uuid",
"name": "Acme Pty Ltd",
"email": "ap@acme.example",
"phone": "+61 400 000 000",
"external_id": "crm-12345",
"address": {
"line1": "1 George St",
"line2": "",
"city": "Sydney",
"region": "NSW",
"postal_code": "2000",
"country": "AU"
}
},
"invoice_number": "INV-1042",
"invoice_date": "2026-07-01",
"due_date": "2026-07-31",
"reference": "PO-9981",
"notes": "Thanks for your business.",
"staff": "Cory Mayfield",
"currency_code": "AUD",
"line_amount_types": "Exclusive",
"line_items": [
{
"description": "Onsite EV charger installation",
"quantity": 1,
"unit_amount": 1850.00,
"tax_type": "OUTPUT2",
"tax_rate": 10,
"account_code": "200",
"sort_order": 0
}
],
"send_invoice": true
}
Field
contact.id
contact.name
contact.email
contact.phone
contact.external_id
invoice_number
invoice_date
due_date
reference
notes
currency_code
line_amount_types
line_items
line_items[].description
line_items[].quantity
line_items[].unit_amount
line_items[].tax_type
line_items[].tax_rate
line_items[].account_code
line_items[].sort_order
send_invoice
Required
No
Yes
Yes
Yes
No
No
No
No
No
No
No
No
No
Yes
Yes
Yes
No
No
No
No
No
Type
string
string
string
string
string
string
date
date
string
string
string
enum
string
string
number
number
string
number
string
number
boolean
Description
UUID of an existing contact in your account.
Name of contact. Required if contact.id is not used.
Email address for the contact. Required if contact.id is not used.
Mobile number for the contact. Include the international code.
Your system's identifier for the contact.
Invoice number (must be unique). Auto-generated if not sent in the request.
ISO-8601 date (YYYY-MM-DD). Defaults to today if empty.
ISO-8601 date (YYYY-MM-DD). Defaults to today if empty.
Free-form reference (PO number, etc.). Shown to the customer.
Free-form notes appended to the invoice.
ISO 4217 (e.g. AUD). Defaults to your account currency.
Exclusive (default), Inclusive, or NoTax.
At least one line item required.
Description of the line item.
Must be > 0.
Must be >= 0
Xero tax types: OUTPUT (GST), EXEMPTOUTPUT, BASEXCLUDED.
Percentage 0-100. Defaults to 10.
Xero account code for revenue attribution.
Display order on the invoice.
If true, emails the invoice to the customer immediately after creation
Retrieve an invoice
POST /api/v1/invoices/:id
Returns the invoice or 404 NOT_FOUND if the id doesn't belong to your account.
List invoices
GET /api/v1/invoices
Cursor-paginated. Sort order is always created_at DESC.
Parameters
status
contact_id
invoice_number
date_from
date_to
limit
cursor
Description
Choose from DRAFT, UNPAID, OVERDUE, PARTIALLY_PAID, PAID, CANCELLED, SCHEDULED
Filter by contact UUID.
Exact match.
ISO date. Returns invoices with invoice_date >= date_from
ISO date. Returns invoices with invoice_date <= date_to
Page size. Default 25, max 100
Opaque cursor from the previous response's next_cursor
{
"data": [ /* invoice objects */ ],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0wMVQwMDowMDowMFoiLCJpZCI6IjEyMyJ9",
"has_more": true
}
When has_more is false, next_cursor is null.
async function* allInvoices() {
let cursor = null;
while (true) {
const url = new URL('https://app.chargetree.co/api/v1/invoices');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` }, }); const { data, next_cursor, has_more } = await res.json(); for (const invoice of data) yield invoice; if (!has_more) return; cursor = next_cursor;
}
}
Update an invoice
PUT /api/v1/invoices/:id
Partial update — supply only the fields you want to change.
Field
contact.id
reference
notes
staff
send_invoice
invoice_number
invoice_date
due_date
currency_code
line_amount_types
line_items (full replace)
Editable when unlocked
Editable when locked
An invoice is locked once a payment has been recorded against it. Locked invoices reject financial-field changes with 403 INVOICE_LOCKED.
When you include line_items in the payload, they fully replace the existing items. Partial line-item updates are not supported. Totals are recalculated.
{
"reference": "PO-9981-v2",
"notes": "Updated to reflect scope change.",
"line_items": [
{ "description": "Onsite install", "quantity": 1, "unit_amount": 2000, "tax_rate": 10, "tax_type": "OUTPUT" },
{ "description": "Extended warranty", "quantity": 1, "unit_amount": 250, "tax_rate": 10, "tax_type": "OUTPUT" }
]
}
Cancel an invoice
POST /api/v1/invoices/:id/cancel
Soft-cancels the invoice: sets status: "CANCELLED", amount_due: 0. If the invoice was previously synced to Xero, we best-effort void it there too — the local cancellation succeeds either way.
{
"reason": "Customer requested cancellation"
}
reason is optional, must be a string, and is stored on the audit log entry.
Cannot cancel a
PAIDinvoice – returns400 INVOICE_NOT_CANCELLABLE.Cancelling twice is safe; the second call just re-writes the same state.
Invoice object
{
"id": "717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"invoice_number": "INV-1042",
"status": "UNPAID",
"contact": {
"id": "c1",
"name": "Acme Pty Ltd",
"email": "ap@acme.example",
"phone": "+61 400 000 000"
},
"invoice_date": "2026-07-01",
"due_date": "2026-07-31",
"currency": "AUD",
"line_amount_types": "Exclusive",
"sub_total": 1850.00,
"tax_total": 185.00,
"total": 2035.00,
"amount_paid": 500.00,
"amount_due": 1535.00,
"line_items": [
{
"id": "li-1",
"description": "Onsite EV charger installation",
"quantity": 1,
"unit_amount": 1850.00,
"tax_rate": 10,
"tax_amount": 185.00,
"line_amount": 1850.00
}
],
"payments": [
{
"id": "pay-1",
"amount": 500.00,
"currency": "AUD",
"received_at": "2026-07-05T09:12:00Z",
"method": "stripe",
"reference": "pi_3Nz_test",
"recorded_by": "stripe"
}
],
"reference": "PO-9981",
"notes": "Thanks for your business.",
"staff": "Cory Mayfield",
"is_editable": false,
"sent_to_contact": true,
"last_sent_at": "2026-07-01T05:12:33Z",
"send_count": 1,
"pay_url": "https://pay.chargetree.co/acme/INV-1042",
"xero_invoice_id": "abc-123",
"created_at": "2026-07-01T05:12:30Z",
"updated_at": "2026-07-05T09:12:00Z"
}
status
values:DRAFT,SCHEDULED,UNPAID,OVERDUE,PARTIALLY_PAID,PAID,CANCELLED.pay_urlisnullwhen your account has no public checkout slug configured.paymentsis chronological (oldest first).payments[].methodvalues:stripe,cash,card,bank_transfer,cheque,manual,other.payments[].recorded_byvalues:stripe(Stripe webhook),user(recorded via the in-app UI),api(recorded via this public API).is_editableflips tofalseonce any payment is recorded.
Contacts
Create/Match an invoice
POST /api/v1/contacts
if a contact matching external_id or email already exists in your account, it's returned unchanged with matched: true. Otherwise a new one is created.
{
"name": "Acme Pty Ltd",
"email": "ap@acme.example",
"phone": "+61 400 000 000",
"external_id": "crm-12345",
"address": {
"line1": "1 George St",
"city": "Sydney",
"region": "NSW",
"postal_code": "2000",
"country": "AU"
}
}
At least name and one of email / phone are required to create a new contact.
Matching precedence: external_id → email (case-insensitive). Matches are always scoped to your account – a matching email in a different Chargetree account is never returned.
Retrieve a contact
GET /api/v1/contacts/:id
Returns the contact or 404 NOT_FOUND.
Search a contact
GET /api/v1/contacts?email=ap@acme.example
GET /api/v1/contacts?external_id=crm-12345
Exactly one of `email` or `external_id` is required. Returns the contact or 404 NOT_FOUND.
Contact object
{
"id": "c1",
"name": "Acme Pty Ltd",
"email": "ap@acme.example",
"phone": "+61 400 000 000",
"external_id": "crm-12345",
"address": {
"line1": "1 George St",
"line2": null,
"city": "Sydney",
"region": "NSW",
"postal_code": "2000",
"country": "AU"
},
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-06-01T10:00:00Z"
}
The address object is `null` when no address fields are set. Only the address fields you provide on create are stored; omitted fields stay null.
Webhooks
Chargetree can POST signed event envelopes to your endpoint when payments are recorded against invoices. This lets you stay in sync without polling.
Registering an endpoint
In the Chargetree dashboard, go to Settings / API and add a webhook endpoint:
1. Enter your HTTPS URL (e.g. https://your-app.example/webhooks/chargetree).
2. Select the events you want to receive (currently only `invoice.payment_recorded`).
3. Copy the signing secret shown once at creation. Store it in your secret manager.
Up to 5 active endpoints per account.
Event type
invoice.payment_recorded
Trigger
A payment is recorded against an invoice.
Chargetree POSTs the envelope to your URL as JSON.
POST https://your-app.example/webhooks/chargetree HTTP/1.1
Content-Type: application/json
Chargetree-Signature: 3l2Kbf4x7HvR9GqJm2pN0aBcDeFgHiJkLmNoPqRsTuVw
User-Agent: Chargetree-Webhooks/1.0
{
"events": [
{
"resource_url": "https://app.chargetree.co/api/v1/invoices/717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"resource_id": "717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"event_date_utc": "2026-07-15T03:14:22.451",
"event_type": "Payment",
"event_category": "INVOICE",
"account": "c2cc9b6e-9458-4c7d-93cc-f02b81b0594f"
}
]
}
Field
events[].resource_url
events[].resource_id
events[].event_date_utc
events[].event_type
events[].event_category
events[].account
Description
GET this URL (with your API key) to fetch the canonical current state of the invoice.
The Chargetree invoice UUID. Same as the id in resource_url.
When the payment was recorded, server-side, with millisecond precision.
Always Payment for invoice.payment_recorded
Always INVOICE for this event.
The Chargetree account UUID.
The envelope points at `resource_url` rather than including the invoice inline. Reason: if delivery is delayed (e.g. hit a retry), you always see the latest state when you fetch via GET rather than a stale snapshot from when the event was queued.
Verifying signatures
Every request includes a Chargetree-Signature header — base64-encoded HMAC-SHA256 of the raw request body, using your endpoint's signing secret as the key.
import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';
const SIGNING_SECRET = process.env.CHARGETREE_WEBHOOK_SECRET!;
function verifyChargetreeSignature(
rawBody: string,
signatureHeader: string | null,
signingSecret: string,
): boolean {
if (!signatureHeader) return false;
const expected = createHmac('sha256', signingSecret).update(rawBody).digest();
let received: Buffer;
try {
received = Buffer.from(signatureHeader, 'base64');
} catch {
return false;
}
if (received.length !== expected.length) return false;
return timingSafeEqual(received, expected);
}
const app = express();
// IMPORTANT: use express.raw so req.body is a Buffer of the raw bytes.
// express.json() parses the body BEFORE you can verify it, which breaks the HMAC.
app.post(
'/webhooks/chargetree',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const sig = req.get('Chargetree-Signature');
if (!verifyChargetreeSignature(raw, sig, SIGNING_SECRET)) {
return res.status(401).send('bad signature');
}
const { events } = JSON.parse(raw);
for (const event of events) {
// Process each event...
}
res.status(200).send('ok');
},
);
Delivery guarantees
Timeout: your endpoint must respond within 15 seconds.
Retry schedule on non-2xx or timeout: 1 minute → 5 minutes → 30 minutes → 2 hours → 12 hours — 5 attempts total.
Retries are best-effort — the actual retry time depends on when the retry worker's cron tick runs.
After 5 failed attempts the delivery is marked exhausted. It won't be retried further.
Successful deliveries are not retried.
The dashboard's Settings → API page shows recent delivery attempts per endpoint for debugging.
Handler best practices
Always verify the signature. Reject unverified requests with
401.Return
200 OKquickly. Do the actual processing asynchronously (job queue, worker, etc.) — the 15-second timeout is a hard limit.Fetch the current state via
resource_url. The envelope intentionally doesn't embed the invoice. GET the URL to see the latest values. This works whether the event fires now or 12 hours later after retries.Don't rely on ordering. If two payments hit within seconds, delivery order isn't guaranteed. Sort by
event_date_utcif ordering matters.Log the raw body. Store the full envelope + signature for at least 30 days. Makes debugging signature-verification failures trivial.
Support
Questions or issues? Email support@chargetree.co.