Chargetree API v1
Server-to-server REST API for creating, updating, and cancelling invoices. All requests are JSON over HTTPS and authenticate with a bearer API key.
Base URL: https://manage.chargetree.co/api/v1
Authentication
Generate an API key in Settings → API. The plaintext is shown exactly once — save it somewhere secure. Rotating a key immediately invalidates the previous one; there is no grace period.
Include the key in every request using the Authorization header:
Authorization: Bearer ct_live_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3cRequests without a valid key return 401 UNAUTHENTICATED or 401 INVALID_API_KEY. Requests over plain HTTP are rejected. The API is designed for server-to-server use — CORS headers are deliberately not returned, so browser-side fetches from third-party origins will fail. Proxy through your own backend if you need to trigger requests from a browser.
Rate limits
Per-key, per-minute quotas:
- GET requests: 120 / minute (5,000 / hour)
- POST / PUT / DELETE requests: 30 / minute (1,000 / hour)
Over-limit responses return 429 RATE_LIMITED with a Retry-After header (seconds) and X-RateLimit-Remaining. Back off and retry after the window.
Idempotency
POST /invoices and POST /contacts accept an optional Idempotency-Key header. Any UUID or opaque string works. Same key + same body within 24 hours returns the original response without re-executing. Same key + different body returns 409 IDEMPOTENCY_KEY_REUSED with the original response embedded under error.original_response.
While a request with a given key is still executing, a concurrent retry returns 409 IDEMPOTENT_REPLAY_IN_FLIGHT. Wait briefly and retry.
Idempotency-Key: 7c3e8fb1-2d1a-4c7d-9f6f-2e5b9f5d8e14Invoices
POST /invoices
Create an invoice. Contact is resolved (or created) within your account.
POST /api/v1/invoices
Authorization: Bearer ct_live_...
Content-Type: application/json
Idempotency-Key: <uuid> (optional but strongly recommended)
{
"contact": {
"name": "Acme Pty Ltd",
"email": "[email protected]",
"phone": "+61 400 000 000",
"external_id": "crm-12345"
},
"invoice_number": "INV-1042", (optional; auto-generated if omitted)
"invoice_date": "2026-07-01", (optional; YYYY-MM-DD only; defaults to today)
"due_date": "2026-07-31", (optional; YYYY-MM-DD only; defaults to invoice_date)
"reference": "PO-9981",
"notes": "Thanks for your business.",
"currency_code": "AUD",
"line_amount_types": "Exclusive", (Exclusive | Inclusive | NoTax)
"line_items": [
{
"description": "Onsite EV charger installation",
"quantity": 1,
"unit_amount": 1850.00,
"tax_type": "OUTPUT",
"tax_rate": 10
}
],
"payment_link": "https://pay.example.com/checkout/abc", (optional; overrides the Chargetree payment portal for this invoice)
"send_invoice": true, (email the customer immediately)
"is_draft": false (true saves a draft; default false raises it live)
}Response: 201 Created with the full invoice object.
Draft vs live. Omitting is_draft (or sending false) raises the invoice live: UNPAID, or OVERDUE when due_date has already passed. Send is_draft: true to save it as a DRAFT instead. Live invoices count toward outstanding balances and are picked up by the automated reminder sequence even if Chargetree never emailed them. Combining is_draft: true with send_invoice: true returns 400 VALIDATION_ERROR — sending an invoice inherently makes it live.
Payment link override. payment_link replaces Chargetree's payment portal for this invoice only. When set, it is used everywhere the customer sees a pay link — reminder emails and SMS, voice call scripts, the QR code on the invoice PDF, and the pay_url field below. Anyone who opens the Chargetree portal address for that invoice is redirected to it, and Chargetree refuses to take a card payment for the invoice. Must be an absolute https:// URL of 2048 characters or fewer; anything else returns 400 VALIDATION_ERROR. Send payment_link: null on PUT to clear it and revert to the Chargetree portal.
Reconciliation. Payments made on an external page do not reach Chargetree, so an overridden invoice stays UNPAID — and keeps being chased by the reminder sequence — until the payment is recorded through the API or arrives via the Xero sync.
Date format. invoice_date and due_date must be plain YYYY-MM-DD calendar dates on both POST and PUT. Timestamps and other formats return 400 VALIDATION_ERROR, because whether an invoice is raised overdue is decided by comparing the date as written. Overdue-ness is judged against the Sydney calendar, matching the hourly job that ages live invoices.
Contact matching precedence: contact.id, then external_id, then email (all account-scoped). If none match, a new contact is created (requires name + one of email or phone).
GET /invoices
List invoices with cursor pagination.
GET /api/v1/invoices?limit=25&status=UNPAID&date_from=2026-01-01
{
"data": [ Invoice, Invoice, ... ],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiLi4uIiwiaWQiOiIuLi4ifQ==",
"has_more": true
}Query params: status (CSV of statuses), contact_id, invoice_number, date_from, date_to, limit (default 25, max 100), cursor. Sort is always created_at DESC.
GET /invoices/:id
Fetch a single invoice. Returns 404 NOT_FOUND if the id belongs to another account.
PUT /invoices/:id
Partial update. Supply only the fields you want to change. When you include line_items, they fully replace the existing items and totals are recalculated.
Once a payment has been recorded (is_editable = false), financial fields (invoice_number, invoice_date, due_date, currency_code, line_amount_types, line_items) are locked and return 403 INVOICE_LOCKED. Informational fields (reference, notes, staff, contact.id, payment_link) remain editable, and send_invoice: true can still be used to re-send the invoice as a receipt.
Changing the draft state. is_draft: false finalises a draft to UNPAID or OVERDUE (a due_date sent in the same request is used for that decision). is_draft: true reverts a live invoice to DRAFT, but only while it has never been sent to the customer and carries no payment — otherwise it returns 409 CONFLICT, as does any attempt to change the draft state of a PAID, PARTIALLY_PAID or CANCELLED invoice. Every transition is written to the invoice history with API attribution.
POST /invoices/:id/cancel
Soft-cancel: sets status = CANCELLED and amount_due = 0. If the invoice has been synced to Xero, Chargetree best-effort-voids it there too; the local cancellation succeeds regardless. Optional body: { "reason": "..." }.
Cancelling a PAID invoice returns 400 INVOICE_NOT_CANCELLABLE.
Invoice response shape
{
"id": "uuid",
"invoice_number": "INV-1042",
"status": "UNPAID", (DRAFT | UNPAID | OVERDUE | PARTIALLY_PAID | PAID | CANCELLED | SCHEDULED)
"is_draft": false, (read-only mirror of status == "DRAFT")
"contact": { "id", "name", "email", "phone" },
"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": 0,
"amount_due": 2035.00,
"line_items": [ ... ],
"payments": [ (full chronological payment history)
{
"id", "amount", "currency", "received_at",
"method": "stripe|cash|card|bank_transfer|...",
"reference": "pi_...",
"recorded_by": "user|api|stripe"
}
],
"reference": "PO-9981",
"notes": "...",
"staff": null,
"is_editable": true,
"sent_to_contact": true,
"last_sent_at": "2026-07-01T05:12:33Z",
"send_count": 1,
"pay_url": "https://pay.chargetree.co/acme/INV-1042", (effective pay link: the payment_link override when set, else the portal; null if neither)
"payment_link": null, (the raw override, or null when using the Chargetree portal)
"xero_invoice_id": "abc-...",
"created_at": "2026-07-01T05:12:30Z",
"updated_at": "2026-07-01T05:12:33Z"
}Contacts
POST /contacts
Create or match by external_id or email. Returns 201 + matched: false when a new contact is created, or 200 + matched: true when an existing contact is reused.
POST /api/v1/contacts
{
"name": "Acme Pty Ltd",
"email": "[email protected]",
"phone": "+61 400 000 000",
"external_id": "crm-12345", (optional)
"address": {
"line1": "1 George St",
"city": "Sydney",
"region": "NSW",
"postal_code": "2000",
"country": "AU"
}
}GET /contacts
Lookup by ?email= or ?external_id=. Returns the contact or 404 NOT_FOUND. Lookup is always account-scoped — matches in other accounts are never returned.
GET /contacts/:id
Fetch a single contact.
Escalations
GET /escalations/:id
Fetch a single escalation by id. Response includes the escalation type, priority, status, trigger channel, related invoice + contact ids, and resolution details when the escalation has been closed. Used by webhook receivers to retrieve the authoritative current state after processing an escalation.created or escalation.resolved event.
GET /api/v1/escalations/9f3e6b1a-83c8-42f0-bf2b-6d3e3d21a4a1
Authorization: Bearer ct_live_...
{
"id": "9f3e6b1a-83c8-42f0-bf2b-6d3e3d21a4a1",
"account_id": "c2cc9b6e-9458-4c7d-93cc-f02b81b0594f",
"invoice_id": "717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"contact_id": "3b2a1d40-9f5c-4a8c-8e7a-1e2f6d4b3a9e",
"escalation_type": "hardship",
"priority": "high",
"status": "resolved",
"trigger_channel": "sms",
"customer_statement": "Can't afford this month, going through a hard time.",
"resolution_outcome": "hardship_arrangement",
"resolution_notes": "Split into 3 monthly instalments.",
"resolved_at": "2026-07-16T14:02:11.207Z",
"created_at": "2026-07-15T03:14:22.451Z",
"updated_at": "2026-07-16T14:02:11.207Z"
}Returns 404 NOT_FOUND for escalations in a different account or that don't exist. There is no list endpoint for escalations — subscribe to the webhook events to receive them as they happen.
Webhooks
Register outbound endpoints in Settings → API. Chargetree POSTs signed event envelopes when subscribed events fire. The signing secret is shown once at endpoint creation — save it to verify incoming signatures.
Supported events
invoice.payment_recorded— fires whenever a payment is recorded against an invoice (Stripe capture or manual entry).escalation.created— fires when a customer signal (hardship, dispute, human request, or refusal to pay) is captured during automated collections.escalation.resolved— fires when an escalation is moved into a terminal state (resolved or closed) by a team member.
Envelope shape — invoice.payment_recorded
POST https://your-endpoint.example/webhooks/chargetree
Content-Type: application/json
Chargetree-Signature: <base64-hmac-sha256>
User-Agent: Chargetree-Webhooks/1.0
{
"events": [
{
"event_id": "a1f8d3c2-4b7e-4f0a-9c1e-2d3f4a5b6c7d",
"resource_url": "https://manage.chargetree.co/api/v1/invoices/717f...",
"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"
}
]
}The invoice envelope deliberately does not embed the invoice or payment. GET resource_url to retrieve the canonical current state — this way retries always see the latest values, even if delivery was delayed.
Envelope shape — escalation.created
{
"events": [
{
"event_id": "b5c1c9e4-6a5c-4d0f-9f3e-2b3e8e3a1c7f",
"resource_url": "https://manage.chargetree.co/api/v1/escalations/9f3e...",
"resource_id": "9f3e6b1a-83c8-42f0-bf2b-6d3e3d21a4a1",
"event_date_utc": "2026-07-15T03:14:22.451",
"event_type": "Created",
"event_category": "ESCALATION",
"account": "c2cc9b6e-9458-4c7d-93cc-f02b81b0594f",
"escalation_type": "hardship",
"priority": "high",
"trigger_channel": "sms",
"invoice_id": "717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"contact_id": "3b2a1d40-9f5c-4a8c-8e7a-1e2f6d4b3a9e"
}
]
}Envelope shape — escalation.resolved
{
"events": [
{
"event_id": "c7d3f1b0-9a2e-4b3c-8d5f-6e7a8b9c0d1e",
"resource_url": "https://manage.chargetree.co/api/v1/escalations/9f3e...",
"resource_id": "9f3e6b1a-83c8-42f0-bf2b-6d3e3d21a4a1",
"event_date_utc": "2026-07-16T14:02:11.207",
"event_type": "Resolved",
"event_category": "ESCALATION",
"account": "c2cc9b6e-9458-4c7d-93cc-f02b81b0594f",
"escalation_type": "hardship",
"priority": "high",
"trigger_channel": "sms",
"invoice_id": "717f2bfc-c6d4-41fd-b238-3f2f0c0cf777",
"contact_id": "3b2a1d40-9f5c-4a8c-8e7a-1e2f6d4b3a9e",
"resolution_outcome": "hardship_arrangement",
"resolved_at": "2026-07-16T14:02:11.207Z"
}
]
}The embedded fields (type, priority, trigger channel, invoice_id, contact_id) let integrators route the alert without an extra round-trip. GET resource_url with your API key to retrieve the authoritative current state — same contract as the invoice envelope, and the reliable path if a delayed retry might carry stale embedded values.
Deduplication. event_id is stable across retries of the same dispatch, and unique across independent dispatches — even for two events on the same escalation (e.g. a resolved then a closed transition both send escalation.resolved with distinct event_ids). Idempotently process on event_id.
Verifying the signature
Compute the base64 HMAC-SHA256 of the raw request body using your endpoint's signing secret. Compare against the Chargetree-Signature header using a constant-time comparison to avoid timing attacks.
// Node.js / TypeScript
import { createHmac, timingSafeEqual } from 'crypto';
export 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);
}
// Express example:
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, process.env.CHARGETREE_WEBHOOK_SECRET!)) {
return res.status(401).send('bad signature');
}
const { events } = JSON.parse(raw);
// process events...
res.status(200).send('ok');
});Delivery guarantees
Endpoints must return a 2xx within 15 seconds. Failures are retried on a schedule (1 min, 5 min, 30 min, 2 h, 12 h) for up to 5 total attempts before the delivery is considered exhausted. Retry cadence is best-effort — actual delivery time depends on the cron tick that runs the retry worker.
Design your handler to be idempotent: process each event by resource_id + event_date_utc so a re-delivered event doesn't double-count.
Errors
All errors return a consistent envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable message.",
"field_errors": [
{ "field": "line_items[0].unit_amount", "message": "must be >= 0" }
]
}
}Error code reference
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | Missing or malformed Authorization header |
INVALID_API_KEY | 401 | Key not found or revoked |
RATE_LIMITED | 429 | Over the per-key quota |
VALIDATION_ERROR | 400 | Bad request shape — see field_errors |
NOT_FOUND | 404 | Resource does not exist in this account |
INVOICE_LOCKED | 403 | Financial fields on a paid invoice can't change |
INVOICE_NOT_CANCELLABLE | 400 | Attempted to cancel a PAID invoice |
DUPLICATE_INVOICE_NUMBER | 409 | invoice_number already used in this account |
PLAN_LIMIT_REACHED | 402 | Subscription blocks new invoices |
ACCOUNT_NOT_READY_TO_SEND | 409 | send_invoice: true, but no checkout slug configured |
IDEMPOTENCY_KEY_REUSED | 409 | Same Idempotency-Key, different body — includes original_response |
IDEMPOTENT_REPLAY_IN_FLIGHT | 409 | Prior request with this key is still executing |
INTERNAL_ERROR | 500 | Something broke on our side |