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.0The 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:
- Verify the signature before anything else. An unverified request is just a stranger posting JSON.
- Respond
2xxquickly, then do the real work in a background job. Deliveries time out after 15 seconds. - Treat
event_idas the thing you deduplicate on. The same event can legitimately arrive more than once. - Fetch
resource_urlfor the current state. The payload deliberately carries identifiers rather than a snapshot of the invoice.
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.
| Event | Sent when |
|---|---|
invoice.payment_recorded | A payment is recorded against an invoice, whether taken online or entered by hand |
escalation.created | A customer raises hardship, a dispute, a request for a person, or refuses to pay |
escalation.resolved | Somebody 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
| Field | Type | Notes |
|---|---|---|
event_id | string | A UUID. Stable across retries — deduplicate on this |
resource_url | string | Where to fetch the current state of the thing that changed |
resource_id | string | The invoice or escalation identifier |
event_date_utc | string | ISO-8601 with milliseconds, without a trailing Z |
event_type | string | Payment, Created or Resolved |
event_category | string | INVOICE or ESCALATION |
account | string | The 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:
| Field | Values |
|---|---|
escalation_type | hardship, dispute, human_request, refused |
priority | high, medium, low |
trigger_channel | email, sms, voice |
invoice_id | The invoice concerned, or null |
contact_id | The 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:
| Field | Type | Notes |
|---|---|---|
resolution_outcome | string or null | How it was settled |
resolved_at | string | ISO-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
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
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", 200Ruby
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
endGo
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 attempt | Next attempt waits |
|---|---|
| 1 | 5 minutes |
| 2 | 30 minutes |
| 3 | 2 hours |
| 4 | 12 hours |
| 5 | No 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:
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
2xxwithin 15 seconds, then work in the background - Deduplicate on
event_id - Fetch
resource_urlrather than trusting the payload as a snapshot - Loop over
eventsrather than readingevents[0] - Expect events to arrive out of order
- Alert yourself on repeated failures, so you notice before the attempts run out