The REST binding by example
This page shows merchant-provider/v1's REST binding on the wire. For every operation it gives the
request Chargeless sends, the answer you return when it works (200), the answer for each kind of
error, and what Chargeless does next. A complete server you can start from is at the end. The
rules behind each answer are in Connecting your own billing system; the
binding itself is openapi/merchant-provider/v1.yaml in the contracts, and where this page and
the contract files differ, the files win. To build it step by step, follow
Connect your billing system in an afternoon. The same
operations over MCP are on The MCP binding.
Every JSON body on this page is checked against the contract's schemas by Chargeless's own tests,
and most are copies of the contract's examples (schemas/merchant-provider/examples/v1/), so you
can paste them into your tests as they are.
Every request
Chargeless calls your base URL (here https://billing.example.com; it may include a prefix such
as /supportroute, which then comes before every /v1/… path) with these headers:
POST /v1/refunds HTTP/1.1
Host: billing.example.com
Accept: application/json
Content-Type: application/json
User-Agent: Chargeless (merchant-provider/v1)
Idempotency-Key: 0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6
X-SupportRoute-Timestamp: 1760520000
X-SupportRoute-Signature: v1=1b03ea29388ff9f1dd2bbf894f1e6e359f0fa1003ca246c678ad485249503070
X-SR-Request-Id: 01a1b7d7-f079-7ba8-97a7-851c6d4c1303
X-SR-Correlation-Id: 0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6
X-SR-Channel: ticket
{"transaction_ref":"ch_1001","amount":{"amount_minor":2000,"currency":"USD"},"reason":"service_issue","dry_run":false}Idempotency-Keycomes only with arefundorcancel_subscriptionthat is not a dry run. Reads,get_operationand dry runs carry none.Authorization: Bearer …and your edge headers come too when you set them in the panel.- The
X-SR-*headers are for your logs; the panel's call log shows the same ids. A helpdesk ticket addsX-SR-External-Ticket. - Bodies are JSON, sent only with
POST. Answer withContent-Type: application/json.
Signing, worked through
Verify every request before you read it. The signature is v1= and the lower-case hex
HMAC-SHA256, keyed with your signing secret, of six fields joined by a line feed (\n):
{timestamp}\n{METHOD}\n{host}\n{path}\n{idempotency_key}\n{body}For the refund above, with the test secret srmp_test_4f7c1e9a2b6d, the string to sign is these
six lines (the last one is the raw body, byte for byte):
1760520000
POST
billing.example.com
/v1/refunds
0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6
{"transaction_ref":"ch_1001","amount":{"amount_minor":2000,"currency":"USD"},"reason":"service_issue","dry_run":false}and the header your verifier must compute is:
secret srmp_test_4f7c1e9a2b6d
timestamp 1760520000
method POST
host billing.example.com
path /v1/refunds
key 0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6
body {"transaction_ref":"ch_1001","amount":{"amount_minor":2000,"currency":"USD"},"reason":"service_issue","dry_run":false}
signature v1=1b03ea29388ff9f1dd2bbf894f1e6e359f0fa1003ca246c678ad485249503070A read has no key and no body, so the fifth line and the sixth are empty. Under a base path the path is signed as sent, prefix included:
secret srmp_test_4f7c1e9a2b6d
timestamp 1760520005
method GET
host billing.example.com
path /supportroute/v1/transactions/gp.4031-2290
key (empty)
body (empty)
signature v1=d243cf01428686ccff9f852f3f3af7af4fd7b608aa72c8d23bf9dc1ca8c2f70eCheck your verifier against both before you deploy it. The same in a few lines of Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, req, rawBody) {
const ts = req.headers['x-supportroute-timestamp'] ?? '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const msg = [ts, req.method.toUpperCase(), req.headers.host.toLowerCase(), req.url.split('?')[0],
req.headers['idempotency-key'] ?? '', rawBody.toString('utf8')].join('\n');
const want = Buffer.from('v1=' + createHmac('sha256', secret).update(msg).digest('hex'));
const got = Buffer.from(req.headers['x-supportroute-signature'] ?? '');
return got.length === want.length && timingSafeEqual(got, want);
}A missing or wrong signature, or a timestamp more than five minutes from your clock, is answered
401 before anything else, with the error envelope:
{"error": {"code": "credentials_rejected", "message": "The signature does not verify.", "retryable": false}}The go-live contract check sends one request signed with a wrong key and one signed ten minutes
ago, and both must get 401 (or 403).
How Chargeless reads every answer
Every error is the same envelope: {"error": {"code", "message", "retryable"}}. The code goes
with the status, and retryable must agree with it. message is plain text of at most 500
characters, shown to your staff only, never to a customer or a model: put no card data, contact
data or secrets in it.
| Status | code |
retryable |
On a read | On a refund or cancellation |
|---|---|---|---|---|
200 (201, 202 read the same) |
— | — | The value is used | succeeded or pending (polled) |
200 with a body that breaks the contract |
— | — | The read fails; a Contract violation alert opens | Outcome unknown: confirmed with get_operation |
400 |
invalid_request |
false |
Final failure | Final: nothing is retried, the case goes to your team |
401, 403 |
credentials_rejected |
false |
Final failure; a Credentials rejected alert opens | Final, as for a read |
404 |
not_found |
false |
The object does not exist | A guard refusal: the case goes to your team |
409 |
idempotency_conflict |
false |
— | Final, nothing executed, recorded as a security event |
409 |
in_progress |
true |
— | Retried with the same key, then confirmed with get_operation |
422 |
a refusal code | false |
— | A refusal: denied (state class) or to your team (guard class) |
429 |
rate_limited |
true |
Retried, then a failure | Retried with the same key, then confirmed with get_operation |
500, 503 |
internal_error, unavailable |
true |
Retried, then a failure | Retried with the same key, then confirmed with get_operation |
a timeout, a 3xx, anything else |
— | — | Retried (not a 3xx), then a failure |
Never retried in the same call; confirmed with get_operation |
- Retries. Within one call Chargeless makes up to three attempts after a connection error, a
429, a5xxor409 in_progress, backing off from 200 ms to 2 s, with the same idempotency key and a newX-SR-Request-Id. It honoursRetry-After(seconds) up to 2 s. - A failed read on a live conversation. A chat is handed to your team at once with a scripted message; a ticket is retried for up to 15 minutes and then handed off. Five or more such failures that are at least a quarter of the last 15 minutes' calls open a Provider unavailable alert.
- Timeouts. 5 s for reads,
describeandget_operation; 20 s forrefundandcancel_subscription.
The bodies for each error class, ready to copy:
{"error": {"code": "invalid_request", "message": "amount.amount_minor must be at least 1.", "retryable": false}}{"error": {"code": "not_found", "message": "No such transaction.", "retryable": false}}{"error": {"code": "idempotency_conflict", "message": "This key was used with other parameters.", "retryable": false}}{"error": {"code": "in_progress", "message": "The first request with this key is still running.", "retryable": true}}{"error": {"code": "rate_limited", "message": "Too many requests; retry after a second.", "retryable": true}}{"error": {"code": "internal_error", "message": "The billing database did not answer.", "retryable": true}}{"error": {"code": "unavailable", "message": "Down for maintenance until 03:00 UTC.", "retryable": true}}Refusals (422)
Only refund and cancel_subscription refuse. A refusal means you will never execute that key:
no money moved and none ever will. Its code has a class, and the class decides what happens.
| Class | Codes | When to use it | What Chargeless does |
|---|---|---|---|
| State | already_refunded, amount_exceeds_refundable, not_refundable, subscription_not_active, already_canceled, currency_mismatch |
Your records contradict the facts Chargeless decided on | The action ends denied; Chargeless reads the target again and the customer gets the scripted denial |
| Guard | account_restricted, merchant_hold, any x_ code of your own, any code not listed |
Your own execution guard holds it | The action ends failed and the case goes to your team (provider_refused) with your code and message |
A state refusal:
{"error": {"code": "amount_exceeds_refundable", "message": "Only 34.99 USD is left to refund on this charge.", "retryable": false}}A guard refusal, with one of the listed codes or your own x_ code:
{"error": {"code": "merchant_hold", "message": "Finance hold on this account.", "retryable": false}}{"error": {"code": "x_chargeback_pending", "message": "A chargeback review is open for this customer.", "retryable": false}}Custom codes are x_ and 1 to 62 characters of [a-z0-9_]. A refusal is not where your rules
belong: if you refuse a whole class of requests, write that rule into your Chargeless policy
instead, so Chargeless never asks.
describe
What you implement and in which currencies. Called by the contract check and when your connection settings change, never on a customer's path.
GET /v1/describe200:
{
"contract_version": "1.0",
"capabilities": {
"partial_refunds": true,
"cancel_subscription": true,
"cancel_at_period_end": true,
"outcome_callbacks": false
},
"currencies": ["USD", "EUR", "JPY", "KWD"],
"refunds_to_original_payment_method": true
}A system that refunds only in full and has no subscriptions:
{
"contract_version": "1.0",
"capabilities": {
"partial_refunds": false,
"cancel_subscription": false,
"cancel_at_period_end": false,
"outcome_callbacks": false
},
"currencies": [
"TRY"
],
"refunds_to_original_payment_method": true
}contract_versionis the revision you implement:"1.0","1.1"(addslist_payment_attemptsand thepayment_attemptscapability) or"1.2"(adds optional subscription fields).- Declare only what you support. Without
partial_refundsChargeless only ever asks for the whole remainder; withoutcancel_subscriptionit never callsget_subscriptionorcancel_subscription. - Chargeless uses the capabilities recorded by the last passing contract check, so run the check again after you change this answer.
| You answer | What Chargeless does |
|---|---|
200 as above |
The check goes on to the reads |
401 / 403 |
The check's describe line fails; nothing else is checked |
| a body that breaks the schema | The describe line fails with schema_invalid and names the field |
5xx, timeout |
The reachability or describe line fails |
find_customers
Narrows candidates from what a customer wrote. A match never proves who is writing: Chargeless proves identity separately (a signed token, an email code or link), and compares every identifier again itself on the fresh customer.
POST /v1/customers/search{"email": "[email protected]"}Or any combination of email, phone (E.164), customer_ref, transaction_ref, order_ref
and card_last4; return only the customers that fit every identifier given:
{"order_ref": "ord_5521", "card_last4": "4242"}200, at most 10 candidates:
{"candidates": [{"customer_ref": "cus_7Hk2Lm"}]}No match is still 200, never a 404:
{"candidates": []}| You answer | When | What Chargeless does |
|---|---|---|
200 with candidates |
One or more customers fit | Reads each with get_customer before using it |
200 with [] |
Nobody fits | Treats it as no match |
400 invalid_request |
The query has no identifier, or one you cannot read | Final failure for that lookup |
404 |
Never | A 404 here is read as a failed call, not as "no match" |
Match an email case-insensitively against every address on file.
get_customer
The customer, with what your policy may read: refunds and chargebacks of the last 365 days. Called for identity proofs and with every decision.
GET /v1/customers/cus_7Hk2Lm200:
{
"customer_ref": "cus_7Hk2Lm",
"emails": ["[email protected]", "[email protected]"],
"phones": ["+905551234567"],
"account_status": "active",
"account_created_at": "2025-03-02T10:15:00Z",
"prior_refunds": [
{
"at": "2026-09-30T08:01:12Z",
"amount": {"amount_minor": 1999, "currency": "USD"},
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:chat:0199d0aa-1111-7222-8333-944455566677:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"reversed_at": null
},
{
"at": "2026-04-11T16:20:00Z",
"amount": {"amount_minor": 500, "currency": "USD"},
"idempotency_key": null,
"reversed_at": null
}
],
"prior_chargebacks": [
{"at": "2026-02-01T09:00:00Z", "amount": {"amount_minor": 4999, "currency": "USD"}, "status": "won"}
],
"attributes": {"credits_used": 0, "segment": "self_serve", "beta": true}
}- A refund Chargeless made carries its key byte for byte; a refund made any other way carries
null. That is how Chargeless counts its own refunds once. emailsandphonesare used only to send identity proofs to an address on file. They are never shown to a model, to the customer or in the panel.attributes(optional, at most 20) are readable by your policy's rules. Put no contact or card data in them.
| You answer | When | What Chargeless does |
|---|---|---|
200 |
The customer exists | Uses it for the proof or the decision |
404 not_found |
No such customer | The customer does not exist: no proof binds to it and nothing is decided on it |
5xx, 429, timeout |
You cannot answer now | Retried, then a failed read (see the table above) |
list_transactions
The customer's recent charges and subscriptions, every time a verified customer's transactions are looked up.
GET /v1/customers/cus_7Hk2Lm/transactions200, at most 50 of each, all of the customer asked for:
{
"transactions": [
{
"transaction_ref": "gp.4031-2290",
"customer_ref": "cus_7Hk2Lm",
"order_ref": "ord_5521",
"subscription_ref": null,
"state": "succeeded",
"amount": {
"amount_minor": 1200,
"currency": "JPY"
},
"created_at": "2026-10-10T01:02:03Z",
"description": null,
"product_type": null,
"charge_type": "top_up",
"processor": "google_play",
"refund_route": "unsupported",
"card_last4": null,
"card_brand": null,
"disputed": false,
"refunds": [],
"settlement": {
"amount": {
"amount_minor": 739,
"currency": "EUR"
}
}
},
{
"transaction_ref": "ch_1001",
"customer_ref": "cus_7Hk2Lm",
"order_ref": null,
"subscription_ref": "sub_88",
"state": "succeeded",
"amount": {
"amount_minor": 4999,
"currency": "USD"
},
"created_at": "2026-10-01T06:00:03Z",
"description": "Pro plan, monthly",
"product_type": "pro_plan",
"charge_type": "renewal",
"processor": "adyen",
"refund_route": "provider",
"card_last4": "4242",
"card_brand": "visa",
"disputed": false,
"refunds": [
{
"at": "2026-10-02T11:00:00Z",
"amount": {
"amount_minor": 1000,
"currency": "USD"
},
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:chat:0199d0aa-1111-7222-8333-944455566677:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"reversed_at": "2026-10-05T09:30:00Z"
},
{
"at": "2026-10-03T12:00:00Z",
"amount": {
"amount_minor": 500,
"currency": "USD"
},
"idempotency_key": null,
"reversed_at": null
}
],
"settlement": null,
"attributes": {
"seats": 3
}
}
],
"subscriptions": [
{
"subscription_ref": "sub_88",
"customer_ref": "cus_7Hk2Lm",
"phase": "active",
"cancel_at_period_end": false,
"current_period_end": "2026-11-01T06:00:00Z",
"trial_end": "2026-05-15T06:00:00Z",
"billing_interval": {
"unit": "month",
"count": 1
},
"plan_ref": "pro_monthly",
"renewal_count": 5,
"created_at": "2026-05-01T06:00:00Z",
"last_charge": {
"transaction_ref": "ch_1001",
"at": "2026-10-01T06:00:03Z",
"amount": {
"amount_minor": 4999,
"currency": "USD"
}
},
"attributes": {
"seats": 3
}
}
]
}Every key of a transaction and a subscription is present, with null where the value is unknown.
A customer with nothing yet:
{"transactions": [], "subscriptions": []}| You answer | When | What Chargeless does |
|---|---|---|
200 |
The customer exists, even with nothing to list | Shows the customer these, after its proof |
404 not_found |
No such customer | The customer does not exist |
| a list with another customer's item | Never | The whole answer is a contract violation |
get_transaction
One charge, read fresh before every decision on it: the refund window counts from created_at,
the refundable remainder comes from amount and refunds.
GET /v1/transactions/ch_1001200, with every refund that took effect:
{
"transaction_ref": "ch_1001",
"customer_ref": "cus_7Hk2Lm",
"order_ref": null,
"subscription_ref": "sub_88",
"state": "succeeded",
"amount": {"amount_minor": 4999, "currency": "USD"},
"created_at": "2026-10-01T06:00:03Z",
"description": "Pro plan, monthly",
"product_type": "pro_plan",
"charge_type": "renewal",
"processor": "adyen",
"refund_route": "provider",
"card_last4": "4242",
"card_brand": "visa",
"disputed": false,
"refunds": [
{
"at": "2026-10-02T11:00:00Z",
"amount": {"amount_minor": 1000, "currency": "USD"},
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:chat:0199d0aa-1111-7222-8333-944455566677:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"reversed_at": "2026-10-05T09:30:00Z"
},
{
"at": "2026-10-03T12:00:00Z",
"amount": {"amount_minor": 500, "currency": "USD"},
"idempotency_key": null,
"reversed_at": null
}
],
"settlement": null,
"attributes": {"seats": 3}
}- A reversed refund stays listed with
reversed_at; a pending one is not listed until it takes effect. customer_refis the current owner: Chargeless acts only when it is the proven customer.- Amounts are integers in the currency's minor unit (JPY 0 digits, KWD 3, most others 2):
4999is valid,49.99,4999.0and"4999"are not. - References follow one rule: 1 to 128 characters of letters, digits,
.,_,:and-, and never a run of 13 or more digits (it would look like a card number), except a canonical UUID (References).
| You answer | When | What Chargeless does |
|---|---|---|
200 |
The charge exists | Decides on these facts, read in the last 60 seconds |
404 not_found |
No such charge | The charge does not exist: nothing is decided on it |
| a body that breaks the schema | A field missing or of the wrong type | The read fails; the contract check names the field, for example `amount.amount_minor` is missing |
5xx, 429, timeout |
You cannot answer now | Retried, then a failed read: no decision without a fresh read |
get_subscription
One subscription, read fresh before every decision on it. Only with the cancel_subscription
capability.
GET /v1/subscriptions/sub_88200:
{
"subscription_ref": "sub_88",
"customer_ref": "cus_7Hk2Lm",
"phase": "active",
"cancel_at_period_end": false,
"current_period_end": "2026-11-01T06:00:00Z",
"trial_end": "2026-05-15T06:00:00Z",
"billing_interval": {
"unit": "month",
"count": 1
},
"plan_ref": "pro_monthly",
"renewal_count": 5,
"created_at": "2026-05-01T06:00:00Z",
"last_charge": {
"transaction_ref": "ch_1001",
"at": "2026-10-01T06:00:03Z",
"amount": {
"amount_minor": 4999,
"currency": "USD"
}
},
"attributes": {
"seats": 3
}
}Revision 1.2 adds the optional canceled_at, ended_at, price and plan_name, shown in the
panel and never used in a decision:
{
"subscription_ref": "sub_93",
"customer_ref": "cus_7Hk2Lm",
"phase": "canceled",
"cancel_at_period_end": false,
"current_period_end": null,
"trial_end": null,
"billing_interval": {"unit": "year", "count": 1},
"plan_ref": "pro_yearly",
"renewal_count": 1,
"created_at": "2024-09-01T06:00:00Z",
"last_charge": {"transaction_ref": "ch_0907", "at": "2025-09-01T06:00:02Z", "amount": {"amount_minor": 49900, "currency": "EUR"}},
"canceled_at": "2026-03-14T09:12:00Z",
"ended_at": "2026-03-14T09:12:00Z",
"price": {"amount_minor": 49900, "currency": "EUR"},
"plan_name": "Pro (yearly)"
}phase is trial, active, grace, past_due, paused, canceled or expired; the last two
are final. Errors as for get_transaction.
refund
Called only for a refund Chargeless's policy approved and recorded, with its idempotency key. Refunds always go to the original payment method; Chargeless never sends a destination.
POST /v1/refunds
Idempotency-Key: 0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6{"transaction_ref": "ch_1001", "amount": {"amount_minor": 2000, "currency": "USD"}, "reason": "service_issue", "dry_run": false}200 when it is done:
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "succeeded",
"transaction_ref": "ch_1001",
"amount": {"amount_minor": 2000, "currency": "USD"},
"refund_ref": "re_77",
"processed_at": "2026-10-15T09:12:44Z"
}200 when your processor confirms it later; Chargeless then asks get_operation 30 s, 2 min,
10 min and 30 min after the call, then every hour for up to 24 hours:
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "pending",
"transaction_ref": "ch_1001",
"amount": {"amount_minor": 2000, "currency": "USD"},
"refund_ref": null
}The result echoes the key, the charge and the amount exactly; an answer that does not is an
unknown outcome, confirmed with get_operation.
A dry run carries no key, validates fully, changes nothing and stores nothing. Only the contract check and the conformance kit send one, never a customer's case:
{"transaction_ref": "ch_1001", "amount": {"amount_minor": 3499, "currency": "USD"}, "reason": "duplicate", "dry_run": true}{"idempotency_key": null, "status": "would_succeed", "transaction_ref": "ch_1001", "amount": {"amount_minor": 3499, "currency": "USD"}, "refund_ref": null}| You answer | When | What Chargeless does |
|---|---|---|
200 succeeded |
The refund took effect | The action is executed; the customer is told from a template |
200 pending |
Your processor confirms later | Polls get_operation until it settles, for up to 24 hours, then hands the case to your team as unconfirmed |
422 state code |
already_refunded, amount_exceeds_refundable, not_refundable, currency_mismatch |
Denied; the customer gets the scripted denial |
422 guard code |
account_restricted, merchant_hold, x_… |
Failed; the case goes to your team with your code and message |
404 not_found |
No such charge | A guard refusal: the case goes to your team |
409 idempotency_conflict |
This key came before with another amount or charge | Final, nothing executed, a security event and a Contract violation alert |
409 in_progress |
The key's first call is still running | Retried with the same key, then confirmed with get_operation |
400 invalid_request |
A body you cannot read, or a real refund without a key | Final: the case goes to your team |
401, 403 |
The signature or your credentials refused | Final on the first attempt; a later attempt's 401 leaves the outcome open |
429, 5xx, timeout |
You cannot answer now | Retried with the same key (not after a timeout), then confirmed with get_operation |
The same key again:
| Sent again | Answer |
|---|---|
| with the same parameters | The original result, without refunding again (200); a pending may have become succeeded |
| with other parameters (an amount, a charge) | 409 idempotency_conflict, nothing runs |
| while the first call still runs | 409 in_progress |
| after a refusal | The same refusal (422) |
Never answer 401, or anything else, to a key you have already executed: replay its result.
Refuse a refund above your own refundable amount with amount_exceeds_refundable, even though
Chargeless checked it first: something may have refunded the charge elsewhere in between.
cancel_subscription
Called only for a cancellation Chargeless's policy approved, with its key. Only with the
cancel_subscription capability; at_period_end: true only when you declare
cancel_at_period_end.
POST /v1/subscriptions/sub_88/cancel
Idempotency-Key: 0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:cancel_subscription:0199e2c4-8888-7c8d-9e0f-a1b2c3d4e5f6{"at_period_end": true, "reason": "forgot_to_cancel", "dry_run": false}200:
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:cancel_subscription:0199e2c4-8888-7c8d-9e0f-a1b2c3d4e5f6",
"status": "succeeded",
"subscription_ref": "sub_88",
"at_period_end": true,
"effective_at": "2026-11-01T06:00:00Z"
}A dry run:
{"idempotency_key": null, "status": "would_succeed", "subscription_ref": "sub_88", "at_period_end": false, "effective_at": null}A subscription that is already canceled:
{"error": {"code": "already_canceled", "message": "Canceled on 2026-10-02.", "retryable": false}}| You answer | When | What Chargeless does |
|---|---|---|
200 succeeded |
Canceled (or scheduled for the period's end) | Executed; the customer is told from a template |
200 pending |
Your system confirms later | Polls get_operation, as for a refund |
422 already_canceled, subscription_not_active |
Your records contradict the decision | Denied; the customer gets the scripted denial |
422 guard code, 404 |
Your guard holds it, or no such subscription | Failed; the case goes to your team |
409, 400, 401, 429, 5xx, timeout |
As for refund |
As for refund |
get_operation
The state of a key Chargeless sent, to confirm an outcome that was left open (a pending, a
timeout, an unreadable answer) and after an outcome callback. It never answers 404.
GET /v1/operations/0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6200 for a refund that took effect:
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "succeeded",
"operation": "refund",
"target_ref": "ch_1001",
"result": {
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "succeeded",
"transaction_ref": "ch_1001",
"amount": {"amount_minor": 2000, "currency": "USD"},
"refund_ref": "re_77",
"processed_at": "2026-10-15T09:12:44Z"
},
"updated_at": "2026-10-15T09:14:02Z"
}Still pending:
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "pending",
"operation": "refund",
"target_ref": "ch_1001",
"result": {
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "pending",
"transaction_ref": "ch_1001",
"amount": {"amount_minor": 2000, "currency": "USD"},
"refund_ref": null
},
"updated_at": "2026-10-15T09:12:44Z"
}Refused (the same refusal the 422 carried):
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "failed",
"operation": "refund",
"target_ref": "ch_1001",
"refusal": {"code": "amount_exceeds_refundable", "message": "Only 34.99 USD is left to refund on this charge."},
"updated_at": "2026-10-15T09:12:44Z"
}A succeeded refund you later returned (a reversal, the one change allowed after succeeded):
{
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "failed",
"operation": "refund",
"target_ref": "ch_1001",
"result": {
"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6",
"status": "succeeded",
"transaction_ref": "ch_1001",
"amount": {"amount_minor": 2000, "currency": "USD"},
"refund_ref": "re_77",
"processed_at": "2026-10-15T09:12:44Z"
},
"reversed_at": "2026-10-18T10:00:00Z",
"updated_at": "2026-10-18T10:00:00Z"
}A key you never received, and only then:
{"idempotency_key": "0199a3c1-0a0b-7c0d-8e0f-101112131415:providercheck:never-used", "status": "unknown"}| You answer | What Chargeless does |
|---|---|
succeeded or failed |
Final for the key; polling ends |
pending |
Asks again on the schedule |
unknown within 24 hours |
Sends the mutation again with the same key (safe, since you never received it), unless a kill switch or your connection settings changed since |
succeeded after failed, or unknown after any answer |
A contract violation: ignored, the refund still counts against the charge |
404 |
A contract violation; never answer it |
Keep every key for at least 30 days.
list_payment_attempts
Only with the payment_attempts capability (revision 1.1). After go-live it fills the payment
insights page; it never feeds a refund or a cancellation. The query is the signed body:
POST /v1/payment-attempts/list{"updated_since": "2025-09-20T00:00:00Z", "limit": 200}The answer is a page of attempts in (updated_at, attempt_ref) order, with has_more and, when
there are more, next_cursor to send back as cursor. A last page:
{
"attempts": [],
"next_cursor": null,
"has_more": false
}| You answer | When | What Chargeless does |
|---|---|---|
200 |
Always with the capability, an empty page included | Stores the attempts (the BIN truncated, never a card number) |
400 invalid_request with a cursor |
The cursor expired or is unknown | Reads again from its bound without a cursor |
404 |
You do not declare the capability | Never called then |
429 with Retry-After |
You want it to slow down | Waits; pages are read one after another, at most 10 a minute |
The fields and the rules (never a card number) are in Payment attempts.
A complete server
The server below implements the REST binding in plain Node, with no packages: one customer, one
charge and one subscription in memory stand in for your billing system. It verifies every
request's signature over the raw bytes, keeps idempotency and finality per key, answers dry runs
without changing anything, refuses over-refunds, and passes every line of providercheck -sandbox (checked with providercheck/2). Replace the in-memory store with calls to your billing
system; keep the rules.
SIGNING_SECRET=srmps_… node server.mjs # serves http://localhost:8080// A minimal merchant-provider/v1 server, REST binding, in plain Node (18 or later, no packages).
// One customer, one charge and one subscription kept in memory stand in for your billing system.
// SIGNING_SECRET=srmps_… node server.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.SIGNING_SECRET; // srmps_… on your sandbox, srmp_… in production
const PORT = Number(process.env.PORT ?? 8080);
if (!SECRET) throw new Error('set SIGNING_SECRET');
const now = () => new Date().toISOString();
const days = (n) => new Date(Date.now() + n * 86_400_000).toISOString();
const usd = (minor) => ({ amount_minor: minor, currency: 'USD' });
// --- Your system of record ----------------------------------------------------------------------
const customers = {
cus_1001: { customer_ref: 'cus_1001', emails: ['[email protected]'], phones: [],
account_status: 'active', account_created_at: '2025-03-02T10:15:00Z' },
};
const transactions = {
ch_1001: { transaction_ref: 'ch_1001', customer_ref: 'cus_1001', order_ref: null,
subscription_ref: 'sub_1001', state: 'succeeded', amount: usd(4999), created_at: days(-3),
description: 'Pro plan, monthly', product_type: null, charge_type: 'renewal',
processor: 'in_house', refund_route: 'provider', card_last4: '4242', card_brand: 'visa',
disputed: false, refunds: [], settlement: null },
};
const subscriptions = {
sub_1001: { subscription_ref: 'sub_1001', customer_ref: 'cus_1001', phase: 'active',
cancel_at_period_end: false, current_period_end: days(27), trial_end: null,
billing_interval: { unit: 'month', count: 1 }, plan_ref: 'pro_monthly', renewal_count: 3,
created_at: days(-93), last_charge: { transaction_ref: 'ch_1001', at: days(-3), amount: usd(4999) } },
};
// Every key Chargeless sent, kept at least 30 days: key → { fingerprint, operation }.
const operations = new Map();
const ownRefunds = (ref) => Object.values(transactions).filter((t) => t.customer_ref === ref)
.flatMap((t) => t.refunds).sort((a, b) => b.at.localeCompare(a.at)).slice(0, 50);
const customerView = (c) => ({ ...c, prior_refunds: ownRefunds(c.customer_ref), prior_chargebacks: [] });
const refundable = (t) => t.amount.amount_minor -
t.refunds.filter((r) => r.reversed_at === null).reduce((sum, r) => sum + r.amount.amount_minor, 0);
// --- The signature (section 3) ------------------------------------------------------------------
function verified(req, body) {
const ts = String(req.headers['x-supportroute-timestamp'] ?? '');
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const head = [ts, req.method, String(req.headers.host ?? '').toLowerCase(), req.url.split('?')[0],
String(req.headers['idempotency-key'] ?? '')].join('\n') + '\n';
const want = Buffer.from('v1=' + createHmac('sha256', SECRET).update(head).update(body).digest('hex'));
const got = Buffer.from(String(req.headers['x-supportroute-signature'] ?? ''));
return got.length === want.length && timingSafeEqual(got, want);
}
// --- Answers ------------------------------------------------------------------------------------
const ok = (body) => [200, body];
function fail(status, code, message) {
const retryable = status === 429 || status >= 500 || (status === 409 && code === 'in_progress');
return [status, { error: { code, message, retryable } }];
}
const notFound = () => fail(404, 'not_found', 'no such object');
const invalid = (message) => fail(400, 'invalid_request', message);
// A mutation's key rule (section 4), and a replay: the first answer again, or a conflict.
function keyRule(key, dryRun) {
if (dryRun && key) return invalid('a dry run carries no Idempotency-Key');
if (!dryRun && !key) return invalid('Idempotency-Key is required');
return null;
}
function replay(key, fingerprint) {
const { fingerprint: first, operation } = operations.get(key);
if (first !== fingerprint) return fail(409, 'idempotency_conflict', 'this key was used with other parameters');
if (operation.status === 'failed') return fail(422, operation.refusal.code, operation.refusal.message);
return ok(operation.result);
}
function refuse(key, dryRun, fingerprint, kind, target, code, message) {
if (!dryRun) operations.set(key, { fingerprint, operation: { idempotency_key: key, status: 'failed',
operation: kind, target_ref: target, refusal: { code, message }, updated_at: now() } });
return fail(422, code, message);
}
// --- The operations (section 2) -----------------------------------------------------------------
function describe() {
return ok({ contract_version: '1.0', currencies: ['USD'], refunds_to_original_payment_method: true,
capabilities: { partial_refunds: true, cancel_subscription: true, cancel_at_period_end: true,
outcome_callbacks: false } });
}
function findCustomers(q) {
const tests = {
email: (c, v) => c.emails.some((e) => e.toLowerCase() === String(v).toLowerCase()),
phone: (c, v) => c.phones.includes(v),
customer_ref: (c, v) => c.customer_ref === v,
transaction_ref: (c, v) => transactions[v]?.customer_ref === c.customer_ref,
order_ref: (c, v) => Object.values(transactions).some((t) => t.order_ref === v && t.customer_ref === c.customer_ref),
card_last4: (c, v) => Object.values(transactions).some((t) => t.card_last4 === v && t.customer_ref === c.customer_ref),
};
const given = Object.entries(q ?? {});
if (given.length === 0 || given.some(([k]) => !(k in tests))) return invalid('give one or more known identifiers');
const hits = Object.values(customers).filter((c) => given.every(([k, v]) => tests[k](c, v)));
return ok({ candidates: hits.slice(0, 10).map((c) => ({ customer_ref: c.customer_ref })) });
}
function refund(key, r) {
const amount = r?.amount;
if (typeof r?.transaction_ref !== 'string' || !Number.isInteger(amount?.amount_minor) ||
amount.amount_minor < 1 || typeof amount.currency !== 'string' || typeof r.reason !== 'string' ||
typeof r.dry_run !== 'boolean') return invalid('the body does not match refund-request.json');
const bad = keyRule(key, r.dry_run);
if (bad) return bad;
const fingerprint = JSON.stringify(['refund', r.transaction_ref, amount.amount_minor, amount.currency, r.reason]);
if (!r.dry_run && operations.has(key)) return replay(key, fingerprint);
const t = transactions[r.transaction_ref];
if (!t) return notFound();
const refusal = amount.currency !== t.amount.currency ? ['currency_mismatch', 'the charge is in another currency']
: t.state !== 'succeeded' || t.disputed ? ['not_refundable', 'this charge cannot be refunded']
: amount.amount_minor > refundable(t) ? ['amount_exceeds_refundable', `only ${refundable(t)} minor units are left to refund`]
: null;
if (refusal) return refuse(key, r.dry_run, fingerprint, 'refund', t.transaction_ref, ...refusal);
if (r.dry_run) return ok({ idempotency_key: null, status: 'would_succeed',
transaction_ref: t.transaction_ref, amount, refund_ref: null });
// Execute once, at your processor, to the original payment method; then record it under the key.
const at = now();
t.refunds.push({ at, amount, idempotency_key: key, reversed_at: null });
const result = { idempotency_key: key, status: 'succeeded', transaction_ref: t.transaction_ref,
amount, refund_ref: `re_${operations.size + 1}`, processed_at: at };
operations.set(key, { fingerprint, operation: { idempotency_key: key, status: 'succeeded',
operation: 'refund', target_ref: t.transaction_ref, result, updated_at: at } });
return ok(result);
}
function cancel(key, ref, r) {
const s = subscriptions[ref];
if (typeof r?.at_period_end !== 'boolean' || typeof r.reason !== 'string' || typeof r.dry_run !== 'boolean')
return invalid('the body does not match cancel-request.json');
const bad = keyRule(key, r.dry_run);
if (bad) return bad;
const fingerprint = JSON.stringify(['cancel_subscription', ref, r.at_period_end, r.reason]);
if (!r.dry_run && operations.has(key)) return replay(key, fingerprint);
if (!s) return notFound();
if (s.phase === 'canceled' || s.phase === 'expired' || (r.at_period_end && s.cancel_at_period_end))
return refuse(key, r.dry_run, fingerprint, 'cancel_subscription', ref, 'already_canceled', 'already canceled');
if (r.dry_run) return ok({ idempotency_key: null, status: 'would_succeed', subscription_ref: ref,
at_period_end: r.at_period_end, effective_at: null });
const at = now();
let effective = at;
if (r.at_period_end) {
s.cancel_at_period_end = true;
effective = s.current_period_end;
} else {
Object.assign(s, { phase: 'canceled', cancel_at_period_end: false, current_period_end: null });
}
const result = { idempotency_key: key, status: 'succeeded', subscription_ref: ref,
at_period_end: r.at_period_end, effective_at: effective };
operations.set(key, { fingerprint, operation: { idempotency_key: key, status: 'succeeded',
operation: 'cancel_subscription', target_ref: ref, result, updated_at: at } });
return ok(result);
}
function getOperation(key) {
return ok(operations.get(key)?.operation ?? { idempotency_key: key, status: 'unknown' });
}
// --- Routing ------------------------------------------------------------------------------------
function route(method, path, key, body) {
let m;
if (method === 'GET' && path === '/v1/describe') return describe();
if (method === 'POST' && path === '/v1/customers/search') return findCustomers(body);
if (method === 'GET' && (m = path.match(/^\/v1\/customers\/([^/]+)$/)))
return customers[m[1]] ? ok(customerView(customers[m[1]])) : notFound();
if (method === 'GET' && (m = path.match(/^\/v1\/customers\/([^/]+)\/transactions$/))) {
if (!customers[m[1]]) return notFound();
const mine = (o) => o.customer_ref === m[1];
return ok({ transactions: Object.values(transactions).filter(mine).slice(0, 50),
subscriptions: Object.values(subscriptions).filter(mine).slice(0, 50) });
}
if (method === 'GET' && (m = path.match(/^\/v1\/transactions\/([^/]+)$/)))
return transactions[m[1]] ? ok(transactions[m[1]]) : notFound();
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/)))
return subscriptions[m[1]] ? ok(subscriptions[m[1]]) : notFound();
if (method === 'POST' && path === '/v1/refunds') return refund(key, body);
if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)\/cancel$/))) return cancel(key, m[1], body);
if (method === 'GET' && (m = path.match(/^\/v1\/operations\/([^/]+)$/))) return getOperation(m[1]);
return notFound();
}
createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
let [status, answer] = [401, { error: { code: 'credentials_rejected', message: 'bad signature', retryable: false } }];
if (verified(req, raw)) { // verify the raw bytes before parsing anything
let body;
try {
body = raw.length ? JSON.parse(raw) : undefined;
} catch {
body = null;
}
[status, answer] = body === null ? invalid('the body is not JSON')
: route(req.method, req.url.split('?')[0], String(req.headers['idempotency-key'] ?? ''), body);
}
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(answer));
});
}).listen(PORT, () => console.log(`merchant-provider/v1 (REST) on http://localhost:${PORT}`));What the example leaves out, and a real server needs:
- Persistence. Keys and their outcomes must survive a restart and be kept at least 30 days (section 4). Store the key in the same transaction as the refund it records.
in_progress. A second call with a key whose first call is still running must answer409 in_progress, not run again. A lock per key does it.- Pending outcomes. A refund your processor confirms later answers
pending, andget_operationreports it until it settles. - Every refusal. The example refuses only what the conformance kit provokes. A real server
also refuses with
subscription_not_active(a subscription it cannot cancel now), and with its own guard codes (account_restricted,merchant_hold,x_…) where its rules hold an action. - The production secret. Your production endpoint verifies with the
srmp_signing secret and must refuse thesrmps_sandbox secret. During a rotation it accepts both the old and the newsrmp_secret (section 3). - A base path. The example serves the paths at the root. Behind a prefix, verify the signature over the full path as sent, then strip the prefix to route.
- Strict validation. Validate request bodies against the contract's schemas, and refuse a
body you cannot read with
400 invalid_request.
Testing it
Run the conformance kit (download it) against the server, with
-allow-private for plain http on localhost:
export PROVIDERCHECK_SANDBOX_SIGNING_SECRET=srmps_… # the server's SIGNING_SECRET
./providercheck -sandbox -allow-private \
-base-url http://localhost:8080 \
-tenant 0199a3c1-0a0b-7c0d-8e0f-101112131415 \
-customer cus_1001 -transaction ch_1001 -subscription sub_1001Restart the example between runs: the kit cancels sub_1001 and refunds part of ch_1001. Then
run the kit against your own sandbox and upload its report, and run the live contract check in
the panel (section 10, Proving it).