Connect your billing system in an afternoon
This is the short way through merchant-provider/v1: what to build, in which order, how to test each piece on your own machine, and how to go live. It is for an engineer whose customers, charges and subscriptions live in the company's own billing system (or behind several payment providers that system talks to). The rules in full are in Connecting your own billing system, and every request and answer is on The REST binding by example.
What you are building
A small HTTPS API in front of your billing system. Chargeless calls it; it never calls Chargeless (outcome callbacks aside, and those are optional).
customer ──► Chargeless ──(signed HTTPS)──► your API ──► your billing system
decides by reads facts,
your policy executes approved
refunds and cancellations- Chargeless decides. Your policy in Chargeless (windows, amount caps, velocity limits, your own rules) decides every refund and cancellation. Your API never decides; it reports facts and carries out what was approved.
- Every call is signed with a secret only you and Chargeless hold, and every refund and cancellation carries an idempotency key, so a retry never pays twice.
- No language model ever calls your API. The calls come from Chargeless's own code, after a decision is recorded.
The API is ten operations; seven are enough to start:
| Order | Operation | REST | What it does |
|---|---|---|---|
| 1 | describe |
GET /v1/describe |
Says what you support and in which currencies |
| 2 | find_customers |
POST /v1/customers/search |
Finds customers by email, phone, order, charge or last four digits |
| 2 | get_customer |
GET /v1/customers/{customer_ref} |
One customer, with recent refunds and chargebacks |
| 2 | list_transactions |
GET /v1/customers/{customer_ref}/transactions |
A customer's recent charges and subscriptions |
| 2 | get_transaction |
GET /v1/transactions/{transaction_ref} |
One charge, with its refunds |
| 3 | refund |
POST /v1/refunds |
Refunds an approved amount to the original payment method |
| 3 | get_operation |
GET /v1/operations/{idempotency_key} |
What happened to a key Chargeless sent |
| 4 | get_subscription |
GET /v1/subscriptions/{subscription_ref} |
One subscription (only with cancellations) |
| 4 | cancel_subscription |
POST /v1/subscriptions/{subscription_ref}/cancel |
Cancels an approved subscription (optional) |
| later | list_payment_attempts |
POST /v1/payment-attempts/list |
Card attempts for the insights page (optional) |
Choose REST unless your system already speaks MCP: it is plain HTTP and you can test it with curl. The MCP binding has the same operations and rules as tools.
Before you start
- The contract files. Download merchant-provider-v1-contract.zip: the OpenAPI binding, one JSON Schema per object, valid examples of every object and the MCP profile. Load the schemas into your tests: the examples are answers Chargeless accepts.
- The conformance kit. Download
providercheckfor your computer from providercheck. - A sandbox of your billing system, where a refund and a cancellation cost nothing.
- Node 18 or later if you want to run the example server below;
curlandopensslfor the signed requests.
Five minutes: watch a working server pass
Start from a server that already passes, so you know what passing looks like. Save the complete
server from The REST binding by example as
server.mjs and run it with a made-up sandbox secret:
SIGNING_SECRET=srmps_local_0123456789abcdef PORT=8080 node server.mjsIn another terminal, run the kit against it (-allow-private lets it use plain http on
localhost):
export PROVIDERCHECK_SANDBOX_SIGNING_SECRET=srmps_local_0123456789abcdef
./providercheck -sandbox -allow-private -base-url http://localhost:8080 \
-tenant 0199a3c1-0a0b-7c0d-8e0f-101112131415 \
-customer cus_1001 -transaction ch_1001 -subscription sub_1001It prints one line per check and ends with PASSED. Restart the server before running it again:
the kit really refunds and cancels. You can grow this server into yours (replace the in-memory
store with calls to your billing system and keep the rules), or write your own in any language.
A signed curl
Every request must be signed, so plain curl gets 401. This shell function signs a request the
way Chargeless does; it works in bash and zsh:
# srcurl METHOD PATH [BODY] [IDEMPOTENCY_KEY]: one call to your endpoint, signed as Chargeless signs it.
# Set SR_BASE to your base URL (https://billing.example.com/supportroute, or http://localhost:8080)
# and SR_SECRET to the signing secret your endpoint verifies with.
srcurl() {
local method=$1 target=$2 body=${3-} key=${4-}
local rest=${SR_BASE#*://}
local host=${rest%%/*}
local prefix=${rest#"$host"}; prefix=${prefix%/}
local ts sig
local -a extra=()
[ -n "$key" ] && extra+=(-H "Idempotency-Key: $key")
[ -n "$body" ] && extra+=(-H 'Content-Type: application/json' --data-binary "$body")
ts=$(date +%s)
sig=$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$ts" "$method" "$host" "$prefix$target" "$key" "$body" \
| openssl dgst -sha256 -hmac "$SR_SECRET" -hex | sed 's/^.*= *//')
curl -sS -X "$method" "${SR_BASE%%://*}://$host$prefix$target" \
-H "X-SupportRoute-Timestamp: $ts" -H "X-SupportRoute-Signature: v1=$sig" "${extra[@]}" \
-w '\nHTTP %{http_code}\n'
}
export SR_BASE=http://localhost:8080 SR_SECRET=srmps_local_0123456789abcdefUse it against the example server to see each answer, then against your own.
Step 1: verify signatures, answer describe
Do this first: nothing else is called until a signed describe answers.
- Read the raw request bytes, then verify the
X-SupportRoute-Signatureheader before you parse anything: an HMAC-SHA256 of six lines (timestamp, method, host, path, idempotency key, body), compared in constant time, with the timestamp within five minutes of your clock. Signing, worked through has the exact string, two test vectors and a Node verifier. A wrong signature is401. - Answer
describewith what you support. Start honest and small:partial_refundsonly if you can refund part of a charge,cancel_subscription: falseuntil step 4.
Test it:
srcurl GET /v1/describe # 200 and your describe
SR_SECRET=wrong srcurl GET /v1/describe # 401 credentials_rejectedBehind a proxy or load balancer, verify against the host name Chargeless calls (the public one), not the internal host your server sees, and against the path as sent, base path included.
Step 2: the reads
Implement find_customers, get_customer, list_transactions and get_transaction. These are
what a customer's chat, ticket or portal visit reads, so keep each under a second (the contract
check fails a read slower than 2.5 s).
The mistakes the contract check catches most:
- Money is an integer in minor units with its currency:
{"amount_minor": 4999, "currency": "USD"}. Never49.99,4999.0or"4999". JPY has no minor unit, KWD has three. - Every key is present, with
nullwhen you do not know the value. A missing key fails the whole answer, and the check names it:`amount.amount_minor` is missing. - References are 1 to 128 characters of letters, digits,
.,_,:and-, and never hold a run of 13 or more digits, which would look like a card number. Encode numeric ids (ord_2026101500012is refused;ord-2026-10-15-0001is fine). See References. - No match is not an error:
find_customersanswers200with{"candidates": []}. An unknown customer or charge on the other reads is404 not_found. - A charge lists every refund that took effect, each with Chargeless's idempotency key when
Chargeless made it and
nullotherwise.
Test them:
srcurl POST /v1/customers/search '{"email":"[email protected]"}'
srcurl GET /v1/customers/cus_1001
srcurl GET /v1/customers/cus_1001/transactions
srcurl GET /v1/transactions/ch_1001
srcurl GET /v1/transactions/does_not_exist # 404 not_foundCompare each answer with the matching example in the contract zip
(schemas/merchant-provider/examples/v1/), or validate it with your stack's JSON Schema library
(draft 2020-12, formats asserted), loading every file of schemas/merchant-provider/v1/ under its
$id.
Step 3: refunds and get_operation
This is the step that moves money, so it carries most of the rules.
- Dry runs. A body with
"dry_run": truehas no idempotency key. Check everything you would check for a real refund and answerwould_succeed, or the refusal you would give. Change nothing, store nothing. - Keys. A real refund comes with an
Idempotency-Keyheader. Store the key with the request and its result before you call your processor, in one transaction, and keep it for at least 30 days.- The same key and the same body again: return the stored result; never refund twice.
- The same key with another amount or charge:
409 idempotency_conflict, and do nothing. - The same key while the first call is still running:
409 in_progress.
- Your own over-refund check. Refuse more than the charge has left with
422 amount_exceeds_refundable, even though Chargeless checked it: someone may have refunded the charge elsewhere in between. - Refusals. A
422means you will never execute this key. Use the state codes when your records contradict the request (already_refunded,not_refundable, …) and a guard code or your ownx_…code when your own rule holds it. See Refusals. get_operationanswers for every key you received:succeeded,pending,failed(with the refusal), andunknownonly for a key you never received. It never answers404.- Show the key on the charge. After the refund,
get_transactionlists it inrefunds[]with the key byte for byte.
Test it:
REFUND='{"transaction_ref":"ch_1001","amount":{"amount_minor":150,"currency":"USD"},"reason":"duplicate","dry_run":false}'
KEY=0199a3c1-0a0b-7c0d-8e0f-101112131415:walkthrough:refund:1
srcurl POST /v1/refunds "${REFUND/false/true}" # a dry run: 200 would_succeed, nothing changes
srcurl POST /v1/refunds "$REFUND" "$KEY" # 200 succeeded (or pending)
srcurl POST /v1/refunds "$REFUND" "$KEY" # the same result again, no second refund
srcurl POST /v1/refunds "${REFUND/150/250}" "$KEY" # 409 idempotency_conflict
srcurl GET "/v1/operations/$KEY" # 200 succeeded, with the result
srcurl GET /v1/transactions/ch_1001 # refunds[] lists it under $KEYStep 4: cancellations (optional)
If customers can cancel subscriptions, implement get_subscription and cancel_subscription,
then declare cancel_subscription: true in describe (and cancel_at_period_end: true if you can
cancel at the end of the period). Keys, dry runs, replays and conflicts work exactly as for
refunds; a subscription that is already canceled is 422 already_canceled.
srcurl GET /v1/subscriptions/sub_1001
srcurl POST /v1/subscriptions/sub_1001/cancel '{"at_period_end":true,"reason":"forgot_to_cancel","dry_run":true}'Step 5: run the kit against your sandbox
Deploy your API in front of your billing system's sandbox, pick a fresh sandbox customer with a
refundable charge (and an active subscription, with cancellations), and run the kit as
providercheck describes. The kit really refunds one minor unit and cancels the
subscription, which a live system cannot show without moving money. It needs the sandbox signing
secret (srmps_…) that Chargeless shows when you first save your connection (step 6), so you may
do step 6 first and come back.
Every failing line names its check, a code and the request id, so you can find the call in your
logs. Fix, pick fresh fixtures, run again until it ends in PASSED, and keep the report file.
Step 6: connect it in the panel
- Add the connection. In the Chargeless panel, open Settings → Integrations and choose Connect on Your own payment system under Payments. Give it a name and the currency your system settles in.
- Point it at your production endpoint. Enter the base URL (
https://…on port 443, with a public certificate; no redirects), REST or MCP, and optionally a bearer token or edge headers your gateway needs. The panel lists every URL Chargeless will call as you type. - Save the secrets. The first save shows two secrets, once:
srmp_…, the signing secret: your production API verifies with it;srmps_…, the sandbox signing secret: only your sandbox verifies with it, and your production API must refuse it. The kit signs with it.
- Allow Chargeless's addresses. If your firewall allowlists callers, add every address under Egress on the same page.
- Choose test objects. A quiet customer, a refundable charge and, with cancellations, an active subscription in your live system. The contract check reads them and sends dry runs only: nothing is refunded or cancelled.
- Test the connection. "Test the connection" runs the contract check from Chargeless's own network against your production endpoint. Each failing line names the endpoint and, for a bad answer, the field: fix it and test again.
- Upload the kit's report under Conformance kit: the file from step 5, unchanged.
- Go live. Publish a policy, choose where handed-off cases go, and go live from the onboarding checklist. Go-live runs the contract check once more.
Common failures
| What you see | Why | Fix |
|---|---|---|
describe fails with signature_not_verified |
You accept a wrong signature | Verify every request, before anything else |
Every call 401 behind a proxy |
The proxy rewrites Host or the path |
Verify against the public host and the path as sent |
schema_invalid with `amount.amount_minor` must be an integer, not a string |
A decimal or quoted amount | Send an integer in minor units |
schema_invalid with `…` is missing |
A key left out | Send every key, null when unknown |
card_number_in_field |
A reference with 13 or more digits in a row | Encode numeric ids (base32, base62) |
fixture_not_found |
The test object does not exist where Chargeless calls | Use objects of the live system for the check, of the sandbox for the kit |
refunds_exceed_amount, fixture_not_refundable |
The test charge is refunded, disputed or failed | Pick a succeeded charge with something left to refund |
over_refund_not_refused |
Your API would refund more than is left | Refuse it with 422 amount_exceeds_refundable |
dry_run_changed_state |
A dry run executed | Never execute or store a dry run |
retryable_mismatch |
An error's retryable disagrees with its status |
false for 400, 401, 403, 404, 409 conflict and 422; true for 409 in progress, 429 and 5xx |
not_unknown |
get_operation answered 404 for a key never sent |
Answer 200 with "status": "unknown" |
latency_budget_exceeded |
A read took over 2.5 s, a dry run over 10 s | Speed it up; a cold start can fail once, so test again |
What happens after go-live
- Customers ask in the chat, by email, on your help site or through your helpdesk; Chargeless reads
your API, decides by your policy, and calls
refundorcancel_subscriptiononly for what it approved. - Anything your policy does not allow, and anything your API refuses with a guard code, goes to your team with the facts.
- Settings → your connection → Provider health and the call log show every call with its request id. Chargeless emails your owners, admins and developers when calls are refused or fail.