Webhooks

Events, signature verification and delivery behaviour — everything needed to build a handler.

Webhooks let Chargetree tell your system when something happens — most usefully, when an invoice gets paid — so you do not have to keep asking.

This page covers the whole subject: registering an endpoint, what we send, how to prove a delivery came from us, and how retries behave. Use the table of contents to jump to a section.

Registering an endpoint

In the Chargetree dashboard, go to Settings → Webhooks, add your HTTPS URL and choose the events you want.

You are shown a signing secret once, at that moment. Copy it into your secret store immediately — it cannot be read again, only replaced.

Partners provisioning accounts through the Partner API can instead supply a webhook_url when creating an account; the signing secret comes back in that response. See the Partner API.

An account may have up to 5 enabled endpoints.

What a delivery looks like

Chargetree sends a POST with a JSON body and these headers:

POST /hooks/chargetree HTTP/1.1
Content-Type: application/json
Chargetree-Signature: qDx0mB4kR7pY1sC8vN3tZ6wL9jH2fA5gE0uI7oP4qRs=
User-Agent: Chargetree-Webhooks/1.0

The body wraps events in an array:

{
  "events": [
    {
      "event_id": "6c1f0e0f-2a4b-4f7d-9c3e-8b5a1d2e3f40",
      "resource_url": "https://manage.chargetree.co/api/v1/invoices/0b2c…",
      "resource_id": "0b2c…",
      "event_date_utc": "2026-07-15T04:21:33.482",
      "event_type": "Payment",
      "event_category": "INVOICE",
      "account": "9f8e…"
    }
  ]
}

Today the array always holds exactly one event. It is an array so that batching can be added later without breaking your handler — so loop over it rather than reading events[0].

The shape of a good handler

Four rules cover almost every problem people hit:

  1. Verify the signature before anything else. An unverified request is just a stranger posting JSON.
  2. Respond 2xx quickly, then do the real work in a background job. Deliveries time out after 15 seconds.
  3. Treat event_id as the thing you deduplicate on. The same event can legitimately arrive more than once.
  4. Fetch resource_url for the current state. The payload deliberately carries identifiers rather than a snapshot of the invoice.
The overall shape
export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();

  // 1. Reject anything we cannot prove came from Chargetree.
  if (!isValidSignature(rawBody, request.headers.get('Chargetree-Signature'))) {
    return new Response('Invalid signature', { status: 401 });
  }

  const { events } = JSON.parse(rawBody);

  // 2. Hand off to a queue so we answer well inside the 15 second timeout.
  for (const event of events) {
    await enqueue(event);
  }

  return new Response('OK', { status: 200 });
}

Why the payload does not embed the invoice

Events carry a resource_url instead of a copy of the invoice. A retry two hours later would otherwise hand you a stale snapshot, and you would have no way of telling. Fetching the resource always gives you the truth as it stands now.

Events

Chargetree sends three events today.

EventSent when
invoice.payment_recordedA payment is recorded against an invoice, whether taken online or entered by hand
escalation.createdA customer raises hardship, a dispute, a request for a person, or refuses to pay
escalation.resolvedSomebody closes an escalation off

Registering an endpoint for an event you do not recognise is rejected with a 400, so a typo fails loudly rather than going quiet.

Fields on every event

FieldTypeNotes
event_idstringA UUID. Stable across retries — deduplicate on this
resource_urlstringWhere to fetch the current state of the thing that changed
resource_idstringThe invoice or escalation identifier
event_date_utcstringISO-8601 with milliseconds, without a trailing Z
event_typestringPayment, Created or Resolved
event_categorystringINVOICE or ESCALATION
accountstringThe Chargetree account the event belongs to

event_date_utc has no trailing Z

The timestamp is UTC but omits the Z, matching the Xero shape many integrators already parse. Some date libraries read a bare timestamp as local time, which silently shifts it by your server's offset. Append the Z yourself, or parse it explicitly as UTC.

Payment recorded

{
  "events": [
    {
      "event_id": "6c1f0e0f-2a4b-4f7d-9c3e-8b5a1d2e3f40",
      "resource_url": "https://manage.chargetree.co/api/v1/invoices/0b2c8f1e-…",
      "resource_id": "0b2c8f1e-…",
      "event_date_utc": "2026-07-15T04:21:33.482",
      "event_type": "Payment",
      "event_category": "INVOICE",
      "account": "9f8e7d6c-…"
    }
  ]
}

Note what is not here: the amount. A payment may be partial, so the invoice's status could now be either PARTIALLY_PAID or PAID. Fetch resource_url and read amount_paid, amount_due and status to know where things stand.

Escalation created

Escalation events carry extra fields describing the situation:

FieldValues
escalation_typehardship, dispute, human_request, refused
priorityhigh, medium, low
trigger_channelemail, sms, voice
invoice_idThe invoice concerned, or null
contact_idThe contact concerned, or null
{
  "events": [
    {
      "event_id": "b9d3a1c2-…",
      "resource_url": "https://manage.chargetree.co/api/v1/escalations/4e5f…",
      "resource_id": "4e5f…",
      "event_date_utc": "2026-07-15T04:21:33.482",
      "event_type": "Created",
      "event_category": "ESCALATION",
      "account": "9f8e7d6c-…",
      "escalation_type": "hardship",
      "priority": "high",
      "trigger_channel": "sms",
      "invoice_id": "0b2c8f1e-…",
      "contact_id": "7a6b5c4d-…"
    }
  ]
}

Treat priority as a three-value field

Only high and medium are produced at the moment, but low is a valid value that will deliver if it starts being used. Handle all three rather than assuming two.

Fetching resource_url gives you customer_statement — what the customer actually said, in their own words — which is usually the field a human wants to read first.

Escalation resolved

Everything the created event carries, plus:

FieldTypeNotes
resolution_outcomestring or nullHow it was settled
resolved_atstringISO-8601 with a trailing Z, unlike event_date_utc
{
  "events": [
    {
      "event_id": "c8e4b2d3-…",
      "resource_url": "https://manage.chargetree.co/api/v1/escalations/4e5f…",
      "resource_id": "4e5f…",
      "event_date_utc": "2026-07-16T09:02:11.907",
      "event_type": "Resolved",
      "event_category": "ESCALATION",
      "account": "9f8e7d6c-…",
      "escalation_type": "hardship",
      "priority": "high",
      "trigger_channel": "sms",
      "invoice_id": "0b2c8f1e-…",
      "contact_id": "7a6b5c4d-…",
      "resolution_outcome": "payment_plan_agreed",
      "resolved_at": "2026-07-16T09:02:11.907Z"
    }
  ]
}

The inconsistency between resolved_at and event_date_utc is real, not a typo here: one ends in Z and the other does not. Parse them separately.

Verifying signatures

Your webhook URL is a public address. Anyone who finds it can post JSON to it, so every delivery must be verified before you act on it.

Chargetree signs each delivery with the endpoint's signing secret:

Chargetree-Signature: qDx0mB4kR7pY1sC8vN3tZ6wL9jH2fA5gE0uI7oP4qRs=

The signature is an HMAC-SHA256 of the raw request body, encoded as standard base64.

Two rules that decide whether this works

Sign the raw bytes, not a re-serialised object. If your framework parses the JSON and you re-encode it to verify, key order and whitespace will differ and every signature will fail. Capture the body as a string first.

Compare in constant time. A plain === leaks, through timing, how much of the signature was correct, which is enough to forge one given patience. Every language below has a constant-time comparison function; use it.

There is no timestamp and no replay window

Unlike Stripe, Chargetree sends no timestamp header and applies no tolerance. The signature covers the body alone. A valid delivery captured by an attacker stays valid for ever, so replay protection is your responsibility: record each event_id you have processed and ignore repeats. You need that deduplication anyway, because genuine retries reuse the same event_id.

Node.js and TypeScript

app/api/webhooks/chargetree/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Confirms a delivery was signed with our endpoint's secret.
 * Returns false rather than throwing, so a malformed header is simply rejected.
 */
function isValidSignature(rawBody: string, header: string | null): boolean {
  if (!header) return false;

  const expected = createHmac('sha256', process.env.CHARGETREE_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest('base64');

  const received = Buffer.from(header, 'base64');
  const computed = Buffer.from(expected, 'base64');

  // timingSafeEqual throws if the lengths differ, so check that first.
  if (received.length !== computed.length) return false;

  return timingSafeEqual(received, computed);
}

export async function POST(request: Request): Promise<Response> {
  // Read the body as text: re-serialising parsed JSON would change the bytes.
  const rawBody = await request.text();

  if (!isValidSignature(rawBody, request.headers.get('Chargetree-Signature'))) {
    return new Response('Invalid signature', { status: 401 });
  }

  const { events } = JSON.parse(rawBody);

  for (const event of events) {
    // Retries reuse event_id, so this is what stops us acting twice.
    if (await alreadyProcessed(event.event_id)) continue;
    await enqueue(event);
  }

  return new Response('OK', { status: 200 });
}

Python

Flask
import hmac
import hashlib
import base64
from flask import Flask, request, abort

app = Flask(__name__)


def is_valid_signature(raw_body: bytes, header: str | None) -> bool:
    """Confirm the delivery was signed with our endpoint's secret."""
    if not header:
        return False

    expected = base64.b64encode(
        hmac.new(SECRET.encode(), raw_body, hashlib.sha256).digest()
    ).decode()

    # compare_digest is constant time, unlike ==
    return hmac.compare_digest(expected, header)


@app.post("/hooks/chargetree")
def chargetree_webhook():
    # request.data is the raw body; request.json would re-serialise it.
    if not is_valid_signature(request.data, request.headers.get("Chargetree-Signature")):
        abort(401)

    for event in request.get_json()["events"]:
        if not already_processed(event["event_id"]):
            enqueue(event)

    return "OK", 200

Ruby

Rails
class ChargetreeWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    raw_body = request.body.read

    return head :unauthorized unless valid_signature?(raw_body, request.headers['Chargetree-Signature'])

    JSON.parse(raw_body)['events'].each do |event|
      next if already_processed?(event['event_id'])

      enqueue(event)
    end

    head :ok
  end

  private

  # Confirms the delivery was signed with our endpoint's secret.
  def valid_signature?(raw_body, header)
    return false if header.blank?

    expected = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV.fetch('CHARGETREE_WEBHOOK_SECRET'), raw_body)
    )

    ActiveSupport::SecurityUtils.secure_compare(expected, header)
  end
end

Go

handler.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"io"
	"net/http"
	"os"
)

// isValidSignature confirms the delivery was signed with our endpoint's secret.
func isValidSignature(rawBody []byte, header string) bool {
	if header == "" {
		return false
	}

	mac := hmac.New(sha256.New, []byte(os.Getenv("CHARGETREE_WEBHOOK_SECRET")))
	mac.Write(rawBody)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// hmac.Equal is constant time.
	return hmac.Equal([]byte(expected), []byte(header))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	if !isValidSignature(rawBody, r.Header.Get("Chargetree-Signature")) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	var payload struct {
		Events []map[string]any `json:"events"`
	}
	if err := json.Unmarshal(rawBody, &payload); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	for _, event := range payload.Events {
		enqueue(event)
	}

	w.WriteHeader(http.StatusOK)
}

About the signing secret

The signing secret is 43 URL-safe characters, shown once when the endpoint is created. Unlike API keys it is stored in plaintext on our side, because an HMAC key has to be readable by both parties to work.

Rotate it by replacing the endpoint. Treat it with the same care as an API key: it is the only thing standing between your handler and anyone who knows your URL.

Delivery

Responding

A delivery counts as successful when your endpoint answers with any 2xx status. Anything else — a 4xx, a 5xx, a connection failure, or a timeout — counts as a failure and is retried.

Each attempt is given 15 seconds. That is the whole budget: connecting, your processing, and your response. Acknowledge first and do the work afterwards, in a queue.

Retries

A failing delivery is attempted up to 5 times in total. The waits between attempts get progressively longer:

After attemptNext attempt waits
15 minutes
230 minutes
32 hours
412 hours
5No further attempts

So the sequence of waits is 5m → 30m → 2h → 12h, spanning roughly 14 and a half hours from first attempt to last. If all five fail, the event is abandoned and not delivered again.

Retries can be slightly late, never early

Retries are picked up by a worker that runs once a minute, so an attempt may happen up to a minute after its scheduled time. Build for "at least this long", not "exactly this long".

An endpoint that is disabled or deleted while a retry is pending stops being retried, and the delivery is recorded as terminal.

Duplicates are normal

If your handler is slow and we time out at 15 seconds, we retry — even though your first run may have succeeded. The same event_id then arrives twice.

This is why deduplication is not optional:

Deduplicate on event_id
async function handleEvent(event: { event_id: string }): Promise<void> {
  // Recording the id first, atomically, means a concurrent redelivery loses the
  // race and exits instead of doing the work a second time.
  const isNew = await markProcessedIfNew(event.event_id);
  if (!isNew) return;

  await doTheWork(event);
}

event_id is stable across retries of the same event, which is exactly what makes it usable as the deduplication key.

Ordering is not guaranteed

Events carry no sequence number and may arrive out of order — a retried event can land after a newer one. Do not infer state from the order deliveries arrive in.

The safe pattern is to treat an event as a notification that something changed, then fetch resource_url for the current truth. That is correct regardless of ordering.

Delivery logs

Every attempt is recorded with its response status and a truncated copy of the response body, visible in the dashboard under Settings → Webhooks. Logs are kept for 30 days, which is usually enough to work out why a delivery failed last week.

Limits on endpoints

  • Up to 5 enabled endpoints per account.
  • Endpoints must be HTTPS. Event payloads carry customer details and must not travel in the clear.
  • Each endpoint has its own signing secret and its own event subscriptions.

A checklist

  • Verify the signature before parsing anything
  • Answer 2xx within 15 seconds, then work in the background
  • Deduplicate on event_id
  • Fetch resource_url rather than trusting the payload as a snapshot
  • Loop over events rather than reading events[0]
  • Expect events to arrive out of order
  • Alert yourself on repeated failures, so you notice before the attempts run out

On this page