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:

http
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-Key comes only with a refund or cancel_subscription that is not a dry run. Reads, get_operation and 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 adds X-SR-External-Ticket.
  • Bodies are JSON, sent only with POST. Answer with Content-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):

text
{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):

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

text
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=1b03ea29388ff9f1dd2bbf894f1e6e359f0fa1003ca246c678ad485249503070

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

text
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=d243cf01428686ccff9f852f3f3af7af4fd7b608aa72c8d23bf9dc1ca8c2f70e

Check your verifier against both before you deploy it. The same in a few lines of Node:

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

json
{"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, a 5xx or 409 in_progress, backing off from 200 ms to 2 s, with the same idempotency key and a new X-SR-Request-Id. It honours Retry-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, describe and get_operation; 20 s for refund and cancel_subscription.

The bodies for each error class, ready to copy:

json
{"error": {"code": "invalid_request", "message": "amount.amount_minor must be at least 1.", "retryable": false}}
json
{"error": {"code": "not_found", "message": "No such transaction.", "retryable": false}}
json
{"error": {"code": "idempotency_conflict", "message": "This key was used with other parameters.", "retryable": false}}
json
{"error": {"code": "in_progress", "message": "The first request with this key is still running.", "retryable": true}}
json
{"error": {"code": "rate_limited", "message": "Too many requests; retry after a second.", "retryable": true}}
json
{"error": {"code": "internal_error", "message": "The billing database did not answer.", "retryable": true}}
json
{"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:

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

json
{"error": {"code": "merchant_hold", "message": "Finance hold on this account.", "retryable": false}}
json
{"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.

http
GET /v1/describe

200:

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

json
{
  "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_version is the revision you implement: "1.0", "1.1" (adds list_payment_attempts and the payment_attempts capability) or "1.2" (adds optional subscription fields).
  • Declare only what you support. Without partial_refunds Chargeless only ever asks for the whole remainder; without cancel_subscription it never calls get_subscription or cancel_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.

http
POST /v1/customers/search
json
{"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:

json
{"order_ref": "ord_5521", "card_last4": "4242"}

200, at most 10 candidates:

json
{"candidates": [{"customer_ref": "cus_7Hk2Lm"}]}

No match is still 200, never a 404:

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

http
GET /v1/customers/cus_7Hk2Lm

200:

json
{
  "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.
  • emails and phones are 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.

http
GET /v1/customers/cus_7Hk2Lm/transactions

200, at most 50 of each, all of the customer asked for:

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

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

http
GET /v1/transactions/ch_1001

200, with every refund that took effect:

json
{
  "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_ref is 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): 4999 is valid, 49.99, 4999.0 and "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.

http
GET /v1/subscriptions/sub_88

200:

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

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

http
POST /v1/refunds
Idempotency-Key: 0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6
json
{"transaction_ref": "ch_1001", "amount": {"amount_minor": 2000, "currency": "USD"}, "reason": "service_issue", "dry_run": false}

200 when it is done:

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

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

json
{"transaction_ref": "ch_1001", "amount": {"amount_minor": 3499, "currency": "USD"}, "reason": "duplicate", "dry_run": true}
json
{"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.

http
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
json
{"at_period_end": true, "reason": "forgot_to_cancel", "dry_run": false}

200:

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

json
{"idempotency_key": null, "status": "would_succeed", "subscription_ref": "sub_88", "at_period_end": false, "effective_at": null}

A subscription that is already canceled:

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

http
GET /v1/operations/0199a3c1-0a0b-7c0d-8e0f-101112131415:ticket:0199e2c4-5b6a-7c8d-9e0f-a1b2c3d4e5f6:refund:0199e2c4-7777-7c8d-9e0f-a1b2c3d4e5f6

200 for a refund that took effect:

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

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

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

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

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

http
POST /v1/payment-attempts/list
json
{"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:

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

sh
SIGNING_SECRET=srmps_… node server.mjs     # serves http://localhost:8080
js
// 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 answer 409 in_progress, not run again. A lock per key does it.
  • Pending outcomes. A refund your processor confirms later answers pending, and get_operation reports 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 the srmps_ sandbox secret. During a rotation it accepts both the old and the new srmp_ 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:

sh
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_1001

Restart 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).