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.
  • 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.ListActions and ListCases with an API key that has cases.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 https on 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 ping to 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:

json
{
  "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's type, not on the header.
  • tenant_id: your workspace.
  • created_at: when the event was built (RFC 3339, UTC). Each event's data has 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:

  1. Read the raw body bytes, before any JSON parsing or re-encoding.
  2. Reject the delivery when X-SupportRoute-Timestamp is more than 5 minutes from your clock.
  3. Compute v1= + hex(HMAC-SHA256(key = your srwh_… secret as UTF-8 bytes, message = {timestamp}.{raw body})).
  4. Accept when it equals X-SupportRoute-Signature or X-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:

js
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:

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:

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, for action.failed; never card data), requested_by, created_at.
  • case: the case (id, type, status, customer_id, transaction_id, subscription_id, timestamps), or null. Case type is chat_refund, chat_cancellation, fraud_alert, dispute, ticket or staff_action (a team member's refund or cancellation from the panel).
  • decision: outcome (allow, deny, handoff), the ordered reasons (code, English text, and rule_id for your own rules), the evaluated trace, the safety limits_applied, and the policy_version (v7).
  • timeline: the case's history, oldest first.
  • ticket_id, ticket and thread_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

json
{
  "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_exhausted and daily_automation_cap_exhausted, request_disowned and identity_not_on_file (a form request, below), and for your own payment system or helpdesk provider_unavailable, target_not_found, ownership_not_established, ambiguous_customer, ambiguous_target, helpdesk_taken_over. The set grows; an unknown code reads as unspecified.
  • thread_excerpt: up to the 20 latest messages, oldest first, each author (customer, agent_llm for the automation, staff, system), text (card-masked, cut to 4,000 characters) and at. 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_verified and identity_method (none, signed_token, email_otp, email_link). Reply only to a verified address with account details. A helpdesk ticket also has external_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

json
{
  "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, with actor_user_id), the early-fraud-warning and dispute workflows (fraud_alert, dispute), and your backend through the API (system).
  • amount is in the charge's currency and never converted; null for cancellations and dispute actions.
  • target.provider_id (a Stripe ch_…, sub_… or dp_…, or your own system's reference) is unique per payment connection only: key it with connection_id. target.public_ref is 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/handoff carries the same as action.target_public_ref, case.transaction_public_ref and case.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_version is null when no policy ran; manual_override is true when a member acted beyond the policy.
  • revised_from_failed (on action.executed): the action had been recorded failed, and a later success at the provider revised it. If you acted on its action.failed, check what you did.
  • refund.reversed carries the same fields and reversed_at; the action stays executed. When the reversal needs a person, a handoff.requested may 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.

json
{
  "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: first or after_confirmation.
  • outcome: where the request stood when the event was built: executed, pending (approved and on its way to the provider; its success arrives as action.executed), denied, handoff or identity_required (waiting for the address's owner to confirm on the ticket page).
  • reason_code: the deciding reason, or null for executed and pending.

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.

json
{
  "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) or external (your own system; the ticket is a read-only record in Chargeless).

  • actor.kind: staff (with actor.user_id, the member), customer (a reply reopened a solved ticket) or system.

  • reason (system changes only; null otherwise):

    Reason The change
    request_finished The automation finished the request (an executed action, or one denied for good) and told the customer: to solved
    customer_quiet The automation answered and the customer did not write back within the workspace's quiet time (72 hours by default): to solved
    solved_quiet The ticket stayed solved for the workspace's close time (7 days by default): to closed
    team_entry The ticket reached your team's queue: to new
    provider_outcome_changed The payment provider changed an outcome on the ticket's case after it was solved or closed: reopened
  • A closed ticket 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).
  • current and baseline windows (period, from, to, attempts, numerator, denominator, rate_bp in basis points), the threshold that applied, and change_bp, multiple_milli, z_milli.
  • network_programme (only for that monitor): the programme (visa_vamp_merchant, mastercard_ecm, mastercard_hecm, or custom), network, tier (approaching, projected, breached), month and counts.
  • resolution (on resolved): 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

json
{
  "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

  1. The URL is public https on 443 and answers there without a redirect.
  2. The handler reads the raw body and verifies the signature against both signature headers, with a 5-minute timestamp window.
  3. It answers 2xx within 5 seconds and processes afterwards.
  4. It skips an id it has already processed.
  5. It orders by the event's own time, not by arrival.
  6. It answers 2xx to an unknown type, property or value, and never refuses a delivery on schema validation.
  7. Handoff events are stored as personal data: they carry the requester's address, name and messages.
  8. Send Test Event returns a ping your handler accepts.
  9. Someone watches the panel's notifications for a failed delivery, and you reconcile with ListActions when one shows.