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

text
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 providercheck for 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; curl and openssl for 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:

sh
SIGNING_SECRET=srmps_local_0123456789abcdef PORT=8080 node server.mjs

In another terminal, run the kit against it (-allow-private lets it use plain http on localhost):

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

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

sh
# 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_0123456789abcdef

Use 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.

  1. Read the raw request bytes, then verify the X-SupportRoute-Signature header 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 is 401.
  2. Answer describe with what you support. Start honest and small: partial_refunds only if you can refund part of a charge, cancel_subscription: false until step 4.

Test it:

sh
srcurl GET /v1/describe                    # 200 and your describe
SR_SECRET=wrong srcurl GET /v1/describe    # 401 credentials_rejected

Behind 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"}. Never 49.99, 4999.0 or "4999". JPY has no minor unit, KWD has three.
  • Every key is present, with null when 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_2026101500012 is refused; ord-2026-10-15-0001 is fine). See References.
  • No match is not an error: find_customers answers 200 with {"candidates": []}. An unknown customer or charge on the other reads is 404 not_found.
  • A charge lists every refund that took effect, each with Chargeless's idempotency key when Chargeless made it and null otherwise.

Test them:

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

Compare 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.

  1. Dry runs. A body with "dry_run": true has no idempotency key. Check everything you would check for a real refund and answer would_succeed, or the refusal you would give. Change nothing, store nothing.
  2. Keys. A real refund comes with an Idempotency-Key header. 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.
  3. 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.
  4. Refusals. A 422 means 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 own x_… code when your own rule holds it. See Refusals.
  5. get_operation answers for every key you received: succeeded, pending, failed (with the refusal), and unknown only for a key you never received. It never answers 404.
  6. Show the key on the charge. After the refund, get_transaction lists it in refunds[] with the key byte for byte.

Test it:

sh
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 $KEY

Step 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.

sh
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

  1. 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.
  2. 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.
  3. 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.
  4. Allow Chargeless's addresses. If your firewall allowlists callers, add every address under Egress on the same page.
  5. 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.
  6. 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.
  7. Upload the kit's report under Conformance kit: the file from step 5, unchanged.
  8. 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 refund or cancel_subscription only 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.