Receiving Chargeless webhooks: events/v1
This guide is for engineers at a merchant who receive Chargeless's webhooks in their own systems: a helpdesk, a CRM, an order system or a data warehouse. It says what each event means, what its body holds, how to verify it, and what to expect from retries, duplicates and ordering.
The contract is a set of files Chargeless gives you. Where this guide and the files differ, the files win.
| File | What it holds |
|---|---|
schemas/events/v1/handoff.json |
The envelope and headers every event shares; handoff.requested, action.failed, ticket.handed_to_team, ticket.message_received, ping |
schemas/events/v1/action-outcome.json |
action.executed, refund.reversed |
schemas/events/v1/customer-request.json |
customer_request.decided |
schemas/events/v1/ticket-status.json |
ticket.status_changed |
schemas/events/v1/payment-alert.json |
payment_alert.opened, payment_alert.resolved |
schemas/events/examples/ |
A valid example of each event |
CHANGELOG.md |
Every change to the contract |
Each schema is JSON Schema draft 2020-12 with $id
https://schemas.supportroute.dev/events/v1/<name>. "SupportRoute" in header names and schema ids
is Chargeless's earlier working name; it stays in the wire format.
1. What webhooks are, and what they are not
- Notifications, never questions. Chargeless never waits for your answer. A 2xx tells it the delivery arrived, nothing more. Nothing you answer approves, denies, retries or undoes anything.
- Two families.
- Handoff events (
handoff.requested,action.failed,ticket.handed_to_team) tell your team to take over. They are sent only while your handoff destination includes the webhook (Settings → General → Where Handoffs Go: Webhook only, or Team queue and webhook). While it does, each of the three must be received by at least one enabled endpoint: the panel refuses a change that would leave one without a receiver. - Notifications (
action.executed,refund.reversed,customer_request.decided,ticket.status_changed,payment_alert.opened,payment_alert.resolved) record what happened. They are opt-in per endpoint and are sent whatever your handoff destination is, the team queue alone included.
- Handoff events (
- What a body carries. Handoff events carry what your team needs to answer the customer: the requester's address and name, and up to 20 latest messages of the thread. Treat them as personal data. Notifications carry ids, codes, amounts and timestamps only: no address, name or message. No event ever carries a card number, a security code or a secret, and message text is card-masked.
- The panel is the record. Webhooks are delivered at least once and can be late (section 5).
To reconcile, read
CasesService.ListActionsandListCaseswith an API key that hascases.read, or open the case or ticket in the panel by its number.
2. Set up an endpoint
In the panel: Settings → Integrations → Webhooks → Add Webhook Endpoint (needs handoff.manage
and an authenticator code).
- Endpoint URL: public
httpson port 443. Chargeless refuses private and internal addresses, checks the address again when it connects, and never follows a redirect. Answer at the URL itself. - Label: your own name for it.
- Events It Receives: choose per endpoint. A new event type is never added to an existing endpoint automatically: subscribe to it when you are ready to receive it.
- Up to 10 endpoints per workspace. Each event goes to every enabled endpoint subscribed to it, as one delivery per endpoint.
- The endpoint's signing secret (
srwh_…) is shown once, when you create it. Store it in your secret manager. - Send Test Event sends a
pingto that endpoint alone, whatever its subscriptions (10 per endpoint per hour). A ping is never retried.
An endpoint created from the single handoff webhook of an older workspace signs with that workspace's handoff signing secret until you rotate it once.
3. The request
Every delivery is an HTTP POST with a JSON body, Content-Type: application/json, and these
headers:
| Header | Value |
|---|---|
X-SupportRoute-Event |
The event type, as in the body's type |
X-SupportRoute-Delivery |
The delivery id: one per event and endpoint, the same on every retry of that delivery |
X-SupportRoute-Timestamp |
Unix seconds when this attempt was signed (new on every attempt) |
X-SupportRoute-Signature |
v1= and the lowercase hex HMAC-SHA256 of {timestamp}.{body} |
X-SupportRoute-Signature-Previous |
The same with the previous secret, only for 7 days after a rotation |
User-Agent |
SupportRoute-Handoff/1 (payment alerts: SupportRoute-Alerts/1) |
The body is the envelope, the same for every event:
{
"id": "019a4f20-3c00-7d21-8e33-4f5a6b7c8d01",
"type": "ticket.status_changed",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-11-04T10:12:05Z",
"data": { "...": "depends on type" }
}id: the event id. The same on every retry, and the same on every endpoint that receives the event. Deduplicate on it.type: the event type. Route on the body'stype, not on the header.tenant_id: your workspace.created_at: when the event was built (RFC 3339, UTC). Each event'sdatahas its own time of the fact (changed_at,executed_at,decided_at,opened_at…); order by that.data: the event's own object (section 7).
4. Verify the signature
Verify every delivery before reading it:
- Read the raw body bytes, before any JSON parsing or re-encoding.
- Reject the delivery when
X-SupportRoute-Timestampis more than 5 minutes from your clock. - Compute
v1=+ hex(HMAC-SHA256(key = yoursrwh_…secret as UTF-8 bytes, message ={timestamp}.{raw body})). - Accept when it equals
X-SupportRoute-SignatureorX-SupportRoute-Signature-Previous, compared in constant time.
Comparing against both headers is what makes a rotation safe: while you still hold the old secret,
your signature matches the -Previous header; once you deploy the new one, it matches the main
header.
Node:
import crypto from 'node:crypto';
function verify(rawBody, headers, secret) {
const ts = headers['x-supportroute-timestamp'];
if (!/^\d+$/.test(ts ?? '') || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex');
return [headers['x-supportroute-signature'], headers['x-supportroute-signature-previous']].some(
(sig) => typeof sig === 'string' && sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}Go:
func verify(h http.Header, rawBody []byte, secret string, now time.Time) bool {
ts := h.Get("X-SupportRoute-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || now.Sub(time.Unix(sec, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, sig := range []string{h.Get("X-SupportRoute-Signature"), h.Get("X-SupportRoute-Signature-Previous")} {
if sig != "" && hmac.Equal([]byte(sig), expected) {
return true
}
}
return false
}Python:
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-SupportRoute-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
expected = "v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(sig and hmac.compare_digest(sig, expected)
for sig in (headers.get("X-SupportRoute-Signature"), headers.get("X-SupportRoute-Signature-Previous")))Rotating a secret. Rotate Secret on the endpoint shows the new secret once. For the next 7 days every delivery is signed with both secrets, so deploy the new one at your pace. Retire Previous ends the overlap early, after you have deployed it.
5. Answer, retries, duplicates and order
Answer with any 2xx within 5 seconds, before doing slow work: store the body, answer, then process it. The response body is ignored. (An attempt waits up to 5 seconds for a payment alert, 15 for any other event and 10 for the test event, so 5 seconds passes every delivery.)
| Your answer | What Chargeless does |
|---|---|
| 2xx | Delivered. Never sent again |
| A timeout, a connection error, 408, 429 or 5xx | Retried with growing delays |
| Any other status (400, 401, 403, 404, 410, 3xx…) | Final: recorded failed, never sent again |
Retries wait 30 seconds, then twice as long each time, up to 4 hours between attempts:
| Attempt | About this long after the first |
|---|---|
| 2 | 30 s |
| 3–6 | 1.5 min, 3.5 min, 7.5 min, 15.5 min |
| 7–10 | 31 min, 1 h, 2 h, 4 h 15 min |
| 11, 12 | 8 h 15 min, 12 h 15 min: the last for handoff events and notifications |
| 13–15 | 16, 20, 24 h: payment alerts only, whose last attempt is about a day after the first |
After the last attempt the delivery is recorded failed, and the panel's notifications name the endpoint with a failed delivery. A ping is never retried.
Duplicates. Delivery is at least once: a timeout on your side after you processed the body
brings it again. Keep the ids you processed (a week is enough) and skip one you have seen.
Order. None is promised, between event types or within one: a retried action.executed can
arrive after the refund.reversed of the same action, and two status changes of one ticket can
arrive in either order. Apply an event only when its time (changed_at, executed_at,
decided_at, opened_at, resolved_at) is newer than what you hold for that ticket, action or
alert.
Gaps. An event can be missing for good:
- Throttled. At most 250 notifications (
action.executed,refund.reversed,customer_request.decided,ticket.status_changed) wait per endpoint, and at most 25 payment alerts. One recorded over the limit is marked failed and never sent, and the panel's notifications name the endpoint. Handoff events are never throttled. - Given up. A notification still pending after 24 hours, or a payment alert after 25 hours, is given up.
- Final answers. A 4xx other than 408 and 429 ends that delivery.
Reconcile actions and cases with CasesService.ListActions and ListCases. A
payment_alert.resolved can arrive without its payment_alert.opened: treat it as the whole
alert.
When you disable or delete an endpoint, its deliveries still waiting stop. A handoff event or
notification is then recorded again for the endpoints subscribed to its type at that moment, with
the same body and id. A payment alert is dropped.
6. Evolving within v1
Chargeless adds event types, enum values and optional properties to v1 without a new version, and
lists each in CHANGELOG.md. Removing, renaming or retyping a property, or making one required, is
a new version (events/v2), announced ahead.
So a receiver:
- ignores an event type it does not know, and answers it with a 2xx;
- ignores a property it does not know;
- treats an enum value it does not know as unspecified: an unknown ticket channel as "some channel", an unknown reason code as "no reason", an unknown status as "changed";
- never answers 4xx because a body has something new. That answer is final, and the event is lost.
The schemas are closed (additionalProperties: false). They bind Chargeless: it never sends a
property or a value its schema lacks. If you validate bodies against them, validate with the
current files from the contract, and log a validation failure rather than refusing the delivery.
7. The events
| Type | Family | Sent when | Schema |
|---|---|---|---|
handoff.requested |
handoff | A case is handed to your team: the policy, the gate or the automation decided a person takes over | handoff |
action.failed |
handoff | An approved refund or cancellation could not be carried out | handoff |
ticket.handed_to_team |
handoff | A ticket leaves the automation for your team without an action of its own | handoff |
action.executed |
notification | A refund or cancellation succeeded at the payment provider | action-outcome |
refund.reversed |
notification | A refund that had succeeded was later failed by the provider | action-outcome |
customer_request.decided |
notification | A request from your own form was decided | customer-request |
ticket.status_changed |
notification | A ticket's status changed, whoever changed it | ticket-status |
payment_alert.opened |
notification | A payment monitor opened an alert | payment-alert |
payment_alert.resolved |
notification | A payment alert resolved | payment-alert |
ping |
test | You chose Send Test Event | handoff |
ticket.message_received |
— | Defined and listed in the panel, not sent yet | handoff |
Every amount is { "amount_minor": <integer>, "currency": "<ISO 4217>" } in minor units (cents for
USD, yen for JPY), never a float. Every id is a UUID unless the field says it is the provider's.
handoff.requested and action.failed
data describes the action and everything around it:
action:id,type(refund,cancel_subscription,handoff,dispute_accept,dispute_submit_evidence),status,target_id(the ledger charge, subscription or dispute, or null),amount(or null),reason_code,failure(why it failed, foraction.failed; never card data),requested_by,created_at.case: the case (id,type,status,customer_id,transaction_id,subscription_id, timestamps), or null. Casetypeischat_refund,chat_cancellation,fraud_alert,dispute,ticketorstaff_action(a team member's refund or cancellation from the panel).decision:outcome(allow,deny,handoff), the orderedreasons(code, Englishtext, andrule_idfor your own rules), the evaluatedtrace, the safetylimits_applied, and thepolicy_version(v7).timeline: the case's history, oldest first.ticket_id,ticketandthread_excerpt: the ticket this handoff concerns and what your helpdesk needs to answer it (below), or null and empty for a handoff without a ticket (an early fraud warning, a dispute).
Key the handoff on data.action.id. A ticket whose action caused the takeover gets that action's
handoff.requested or action.failed, never also a ticket.handed_to_team.
ticket.handed_to_team
{
"id": "0199a3d0-1c2e-7f10-8a55-3b9e6d2c4f05",
"type": "ticket.handed_to_team",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-09-25T13:41:07Z",
"data": {
"ticket_id": "0199a3f1-0b00-7a01-9b22-1c3d4e5f6a74",
"reason": "customer_requested",
"thread_excerpt": [
{ "author": "customer", "text": "My subscription renewed but I cancelled it last month.", "at": "2026-09-25T13:39:12Z" },
{ "author": "agent_llm", "text": "I can see the renewal on your account. Could you tell me when you cancelled?", "at": "2026-09-25T13:39:20Z" },
{ "author": "customer", "text": "I'd like someone from your team to look at this, please.", "at": "2026-09-25T13:41:02Z" }
],
"ticket": {
"number": 57,
"subject": "Renewal after cancellation",
"channel": "widget_message",
"requester_email": "[email protected]",
"requester_name": "Sam Rivera",
"requester_verified": true,
"identity_method": "email_otp"
}
}
}reason: a stable code, never free text. Among them:customer_requested,out_of_scope,unable_to_resolve,needs_identity,sender_unverified(an email whose sender could not be verified; nothing was sent to the customer),automation_stalled(the automation left the customer's message unanswered for an hour),automation_failed, the reply budgets'*_budget_exhaustedanddaily_automation_cap_exhausted,request_disownedandidentity_not_on_file(a form request, below), and for your own payment system or helpdeskprovider_unavailable,target_not_found,ownership_not_established,ambiguous_customer,ambiguous_target,helpdesk_taken_over. The set grows; an unknown code reads asunspecified.thread_excerpt: up to the 20 latest messages, oldest first, eachauthor(customer,agent_llmfor the automation,staff,system),text(card-masked, cut to 4,000 characters) andat. Internal notes are never included.ticket:number(as the panel shows it),subject,channel(email,widget_message,web_form,chat_handoff,helpdesk,merchant_form),requester_email(where replies go, or null),requester_name(as given, unverified),requester_verifiedandidentity_method(none,signed_token,email_otp,email_link). Reply only to a verified address with account details. Ahelpdeskticket also hasexternal_ref: the connector, the ticket's id in your helpdesk and its link.
Sent once per ticket, recorded in the same transaction as the takeover.
action.executed and refund.reversed
{
"id": "019a3410-2b00-7c11-8d22-3e4f5a6b7c01",
"type": "action.executed",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-10-30T09:14:07Z",
"data": {
"action_id": "019a3410-1f00-7a01-9b22-1c3d4e5f6a90",
"action_type": "refund",
"source": "merchant_user",
"status": "executed",
"amount": { "amount_minor": 2499, "currency": "EUR" },
"connection_id": "0199a1b0-0000-8000-8000-000000000001",
"target": { "type": "transaction", "id": "019a33f0-7a10-7b3e-9d41-5f2a8c6e1b02", "provider_id": "ch_3QxZ8aLkdIwHu7ix0abcDEF1", "public_ref": "PAY-7K3F9QS" },
"provider_operation_id": "re_3QxZ8aLkdIwHu7ix0ghiJKL2",
"case": { "id": "019a33fe-0000-7c0d-8e0f-101112131417", "number": 482 },
"ticket_id": "019a33fd-ff02-7a01-9b22-1c3d4e5f6a73",
"policy_version": "v7",
"manual_override": false,
"actor_user_id": "0199b2c0-4d5e-7f60-8a71-92a3b4c5d6e7",
"executed_at": "2026-10-30T09:14:06Z",
"revised_from_failed": false
}
}- Sent at most once per action and type, for every source: the widget's chat (
chat), an automated ticket turn (ticket), a team member (merchant_user, withactor_user_id), the early-fraud-warning and dispute workflows (fraud_alert,dispute), and your backend through the API (system). amountis in the charge's currency and never converted; null for cancellations and dispute actions.target.provider_id(a Stripech_…,sub_…ordp_…, or your own system's reference) is unique per payment connection only: key it withconnection_id.target.public_refis the payment's or subscription's short reference ("PAY-…", "SUB-…"; ADR 0042), unique in your workspace, the one your customers see; null for a dispute.events/v1/handoffcarries the same asaction.target_public_ref,case.transaction_public_refandcase.subscription_public_ref. These were added to v1 on 2026-11-08: if you validate payloads against a copy of the schema, update it (the objects do not allow unknown properties).policy_versionis null when no policy ran;manual_overrideis true when a member acted beyond the policy.revised_from_failed(onaction.executed): the action had been recorded failed, and a later success at the provider revised it. If you acted on itsaction.failed, check what you did.refund.reversedcarries the same fields andreversed_at; the action staysexecuted. When the reversal needs a person, ahandoff.requestedmay also come, under the destination rule: that one asks your team to act, this one records the fact.
customer_request.decided
Sent when a request your own form sent through RequestIntakeService.SubmitCustomerRequest is
decided: once when it arrives, and once more when the customer confirms it on their ticket page.
{
"id": "019a3b14-2b00-7c11-8d22-3e4f5a6b7c12",
"type": "customer_request.decided",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-11-01T09:31:40Z",
"data": {
"request_id": "order-1001-refund",
"ticket": { "id": "019a3b10-1f00-7a01-9b22-1c3d4e5f6a01", "number": 231 },
"action_id": "019a3b14-1f00-7a01-9b22-1c3d4e5f6a03",
"action_type": "refund",
"decision": "after_confirmation",
"outcome": "pending",
"reason_code": null,
"decided_at": "2026-11-01T09:31:39Z"
}
}request_id: your id for the request, as you sent it.decision:firstorafter_confirmation.outcome: where the request stood when the event was built:executed,pending(approved and on its way to the provider; its success arrives asaction.executed),denied,handofforidentity_required(waiting for the address's owner to confirm on the ticket page).reason_code: the deciding reason, or null forexecutedandpending.
A request the customer disowned ("This wasn't me"), or confirmed from an address on file for
nobody, is not decided again: its ticket goes to your team, which ticket.handed_to_team reports
(request_disowned, identity_not_on_file).
ticket.status_changed
Sent for every status change of every ticket, whoever made it: a member of your team, the customer writing again on a solved ticket, or Chargeless itself.
{
"id": "019a4f20-3c00-7d21-8e33-4f5a6b7c8d01",
"type": "ticket.status_changed",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-11-04T10:12:05Z",
"data": {
"ticket": { "id": "019a4f1e-2b00-7c11-8d22-3e4f5a6b7c10", "number": 412 },
"channel": "merchant_form",
"handling": "automation",
"from": "new",
"to": "solved",
"actor": { "kind": "system", "user_id": null },
"reason": "request_finished",
"changed_at": "2026-11-04T10:12:04Z"
}
}from,to:new,open,waiting_on_customer,on_hold,solved,closed.handling: who works the ticket after the change:automation,team(your team in the panel) orexternal(your own system; the ticket is a read-only record in Chargeless).actor.kind:staff(withactor.user_id, the member),customer(a reply reopened a solved ticket) orsystem.reason(system changes only; null otherwise):Reason The change request_finishedThe automation finished the request (an executed action, or one denied for good) and told the customer: to solvedcustomer_quietThe automation answered and the customer did not write back within the workspace's quiet time (72 hours by default): to solvedsolved_quietThe ticket stayed solved for the workspace's close time (7 days by default): to closedteam_entryThe ticket reached your team's queue: to newprovider_outcome_changedThe payment provider changed an outcome on the ticket's case after it was solved or closed: reopened A
closedticket may still be reopened by such a provider change; Chargeless never deletes a ticket.
Several changes of one ticket can come close together (for example team_entry then a member's
solved): order them by changed_at.
payment_alert.opened and payment_alert.resolved
Sent when a payment monitor (Payment Analytics → alerts) opens or resolves an alert. The body holds aggregate counts of your own payment attempts: no customer, card or amount.
alert_id: the same in both events of one alert; key on it.monitor:auth_rate_drop,failure_category_spike,fraud_warning_rate,dispute_rate,network_programme.status(open,resolved),sensitivity,scope(card country, brand or failure category, each null when not narrowed),connection(id,label; display only).currentandbaselinewindows (period,from,to, attempts,numerator,denominator,rate_bpin basis points), thethresholdthat applied, andchange_bp,multiple_milli,z_milli.network_programme(only for that monitor): the programme (visa_vamp_merchant,mastercard_ecm,mastercard_hecm, orcustom), network, tier (approaching,projected,breached), month and counts.resolution(onresolved):recovered,monitor_disabled,escalated,programme_exited,limit_removed.panel_url: the Payment Analytics page (a panel sign-in is needed).
Later evaluations of an open alert are not sent. A resolved alert can arrive without its opened one.
ping
{
"id": "019a2f4c-7d10-7a3e-9c41-5e6f708192a3",
"type": "ping",
"tenant_id": "0199a1b0-0000-7000-8000-000000000001",
"created_at": "2026-10-28T10:15:00Z",
"data": { "endpoint_id": "019a2f40-1b2c-7d3e-8f40-516273849506" }
}Signed like every event. Use it to check your URL, TLS and signature code before subscribing.
8. Checklist before you subscribe
- The URL is public
httpson 443 and answers there without a redirect. - The handler reads the raw body and verifies the signature against both signature headers, with a 5-minute timestamp window.
- It answers 2xx within 5 seconds and processes afterwards.
- It skips an
idit has already processed. - It orders by the event's own time, not by arrival.
- It answers 2xx to an unknown type, property or value, and never refuses a delivery on schema validation.
- Handoff events are stored as personal data: they carry the requester's address, name and messages.
- Send Test Event returns a ping your handler accepts.
- Someone watches the panel's notifications for a failed delivery, and you reconcile with
ListActionswhen one shows.