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_8x3kQp9zR2mN6vT4yL1bH7sW0jD5fA3c

Requests 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-2e5b9f5d8e14

Invoices

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

CodeHTTPMeaning
UNAUTHENTICATED401Missing or malformed Authorization header
INVALID_API_KEY401Key not found or revoked
RATE_LIMITED429Over the per-key quota
VALIDATION_ERROR400Bad request shape — see field_errors
NOT_FOUND404Resource does not exist in this account
INVOICE_LOCKED403Financial fields on a paid invoice can't change
INVOICE_NOT_CANCELLABLE400Attempted to cancel a PAID invoice
DUPLICATE_INVOICE_NUMBER409invoice_number already used in this account
PLAN_LIMIT_REACHED402Subscription blocks new invoices
ACCOUNT_NOT_READY_TO_SEND409send_invoice: true, but no checkout slug configured
IDEMPOTENCY_KEY_REUSED409Same Idempotency-Key, different body — includes original_response
IDEMPOTENT_REPLAY_IN_FLIGHT409Prior request with this key is still executing
INTERNAL_ERROR500Something broke on our side