Your evidence service
When a customer disputes a charge with their bank, the bank asks one question: did this cardholder buy the thing, receive it and use it, having seen your terms? Your payment provider knows the charge. You know the rest: where the purchase came from, whether the customer signed in afterwards, what they accepted at checkout, what you wrote to each other.
An evidence service is one HTTPS endpoint of yours that Chargeless asks when it answers a dispute. It works with any payment provider: a workspace on Stripe connects it the same way as one that runs its own billing.
This page is the contract by example. The schemas are schemas/merchant-evidence/v1/ in the
contracts (request.json, response.json); where this page and they differ, they win. Download
them with their examples and the signature test vector as
merchant-evidence-v1-contract.zip.
What happens with your answer
- Chargeless builds the evidence from four sources: its own record of the charge, what passed through it (requests, replies), what your payment provider states about the charge, and your answer. Each field of the evidence names its source.
- Raw values go to the fields that are theirs: the purchase's IP address to "customer purchase IP", your usage events to "access activity log", your documents to their file slots.
- An assistant writes the argument (the free-text part) from a brief: sentences such as "14 of 16 usage events came from the purchase's IP address". It never sees an IP address, a device id, a name, an email or an address, and a sentence your records do not back is thrown away.
- Your answer is not stored. Chargeless keeps which parts were present and where each field came from, never the values. Your payment provider holds what was sent, and the panel shows it by reading it back.
- Your answer is your statement. It is sent to the card network in your name. Answer what your records show.
Because nothing is kept, your service is asked more than once for one dispute: when the dispute opens (to show on its case what is there and what is missing), and again when the evidence is sent. It is a read. Answer it the same way each time.
Keep the records. A dispute can arrive 120 days after the charge, and in some cases up to 540. A service that has rotated its logs by then has nothing to say.
Connecting it
Settings → Evidence Service (under Connections). Enter the URL, save, and copy the signing
secret: it is shown once (srme_…). Send Test Request makes one signed call with
"test": true and synthetic ids; any valid answer passes, an empty one included. Changing the
URL or the secret needs your authenticator code.
The URL is https on port 443, at an address Chargeless can reach over the internet. Reachable
is not open: every request is signed, and the service can stay behind your own API's credentials
(below). Redirects are not followed.
The URL is one fixed address with no query string (nothing after a ?). Which charge and which
customer is asked about comes in the request's body: your payment provider's ids of the charge,
the payment, the customer and the subscription (for Stripe ch_…, pi_…, cus_…, sub_…),
and the metadata you stored on them there. Your service finds its own records by those
(see "Finding your records" below).
Keeping it behind your own API
If the service sits behind your API gateway, give Chargeless what the gateway asks for, under Your API's Credentials on the same page:
- Access token: sent as
Authorization: Bearer <token>. 1 to 4,096 visible ASCII characters. - Your own headers: up to three, for an API key and its secret or whatever your gateway
names them, for example
AccessKeyandAccessSecret, orX-Api-Key. A name is 1 to 64 letters, digits and hyphens; a value is up to 1,024 printable ASCII characters.Authorization(the access token's),Host,Cookie,Content-*,User-Agent,X-SupportRoute-*andX-SR-*are ours or the transport's and cannot be set.
They go with every request, the test request included, beside the signature:
POST /v1/chargeless/evidence HTTP/1.1
Host: api.example.com
Authorization: Bearer 9f31c2…
AccessKey: ak_7721…
AccessSecret: as_5510…
X-SupportRoute-Timestamp: 1794400800
X-SupportRoute-Signature: v1=…
X-SR-Request-Id: 019a7c1e-5b0a-7c3e-9d21-4f6a8b0c2d4f- Stored encrypted, never shown again. The panel says whether an access token is stored and which headers are, by name. To change one, enter it again; to stop sending it, remove it.
- Bound to the URL. A stored credential is sent to the URL it was entered for and to no other. When you change the URL, enter each stored credential again or remove it; the save is refused otherwise.
- The signature is still there. Your gateway can check its own credentials, your service the signature, or both. The signature also proves that the body was not changed and that the request is recent, which a static token does not: check it where you can.
- Issue Chargeless a credential of its own, one that reads only what this service answers, so you can revoke it without touching anything else. Basic authentication (a user name and a password) is not supported.
The request
POST /chargeless/evidence HTTP/1.1
Host: evidence.example.com
Accept: application/json
Content-Type: application/json
User-Agent: Chargeless (merchant-evidence/v1)
X-SupportRoute-Timestamp: 1794400800
X-SupportRoute-Signature: v1=a6f60ff81a282bf03b40eb77ff15daff81b27cef33f100d312bd16a96a4fb248
X-SR-Request-Id: 019a7c1e-5b0a-7c3e-9d21-4f6a8b0c2d4f
{"contract":"merchant-evidence/1.0","request_id":"019a7c1e-5b0a-7c3e-9d21-4f6a8b0c2d4f",…}- The signature is
v1=and the lower-case hex HMAC-SHA256, with your signing secret, of{timestamp}\n{METHOD}\n{host}\n{path}\n\n{body}: the timestamp header as sent,POST, the host as sent (lower case, no port when it is 443), the path as sent, an empty line, and the raw body bytes. It is the merchant-provider signature with no idempotency key, so one verifier serves both.schemas/merchant-evidence/examples/v1/signature.jsonis a test vector: the request above, signed withsrme_test_7d1e4b9c2a6f. - Refuse a signature that does not match, or a timestamp more than five minutes off, with
401. Compare signatures in constant time. - Answer within 10 seconds. A slower answer, another status, or a body the contract refuses is no answer: the evidence is built without your part, and the dispute's case says why. A failing service never delays a dispute.
A real request (schemas/merchant-evidence/examples/v1/request.fraud.json):
{
"contract": "merchant-evidence/1.0",
"request_id": "019a7c1e-5b0a-7c3e-9d21-4f6a8b0c2d4e",
"tenant_id": "01999999-0000-7000-8000-000000000001",
"asked_at": "2026-11-11T12:00:00Z",
"test": false,
"dispute": {
"ref": "du_1SabcDEF",
"reason": "fraudulent",
"category": "fraud",
"amount": { "amount_minor": 4999, "currency": "USD" },
"opened_at": "2026-11-09T08:00:00Z",
"evidence_due_by": "2026-11-23T23:59:59Z"
},
"charge": {
"provider": "stripe",
"connection_id": "01999999-0000-7000-8000-0000000000c1",
"ref": "ch_3SabcDEF",
"payment_ref": "pi_3SabcDEF",
"amount": { "amount_minor": 4999, "currency": "USD" },
"created_at": "2026-10-01T10:00:00Z",
"metadata": { "order_id": "ord_88213" },
"card": { "brand": "visa", "last4": "4242" }
},
"customer": { "kind": "account", "ref": "cus_Rabc123", "metadata": { "user_id": "user_771" } },
"subscription": { "ref": "sub_1Sabc", "metadata": { "plan": "pro", "workspace": "ws_4410" } },
"prior_charges": [
{ "ref": "ch_3RoldA", "payment_ref": "pi_3RoldA", "amount": { "amount_minor": 4999, "currency": "USD" }, "created_at": "2026-05-01T10:00:00Z" },
{ "ref": "ch_3RoldB", "amount": { "amount_minor": 4999, "currency": "USD" }, "created_at": "2026-04-01T10:00:00Z" }
]
}Finding your records
Chargeless adds no email, name or address of its own to the request. There are two ways to find the purchase and its customer:
- By the provider's ids, which your system stores: the charge (
charge.ref), its payment (charge.payment_ref, on Stripe the PaymentIntent), the customer (customer.ref) or the subscription (subscription.ref). A guest checkout hascustomer.kind: "guest"and noref. - By your own ids, when you store them at the provider:
metadataon the charge, the customer and the subscription is what you attached to those objects there (Stripe:metadata), handed back as the provider holds it now. An order number on the payment or your user id on the customer is enough.
About metadata:
- It is yours, returned to you. Chargeless reads it from the provider for this request, keeps none of it, shows it to no model and does not interpret it. If you store an email in it, that email comes back to you in the request; nobody else sees it.
- At most 50 pairs per object, a key of 1 to 40 characters, a value of at most 500. A pair
outside those bounds, or one shaped like a card number, is left out. An object without metadata
has no
metadatafield, and neither has one whose metadata could not be read. - On Stripe, what you set on a PaymentIntent is on its charge as it was when the charge was
made. What you set on a Subscription or a Customer is not on the charge: look in
subscription.metadataandcustomer.metadata. A Checkout Session's ownmetadatais on none of them: setpayment_intent_data.metadataorsubscription_data.metadatawhen you create the session, or store your id on the Customer. - Today Stripe payments carry it. For another provider the field is absent.
dispute.category says what the customer claims, and so which parts of your answer matter most.
The answer
HTTP 200 with a JSON body of at most 6 MiB. Only request_id is required: answer what you
know and leave the rest out.
{
"request_id": "019a7c1e-5b0a-7c3e-9d21-4f6a8b0c2d4e",
"customer": {
"account_id": "user_771",
"account_created_at": "2026-01-13T09:30:00Z",
"name": "Jane Roe",
"email": "[email protected]",
"email_verified_at": "2026-01-13T09:41:00Z"
},
"purchase": {
"at": "2026-10-01T10:00:00Z",
"ip": "203.0.113.7",
"device_id": "dev_9f8e7d6c",
"billing_address": { "line1": "1 Main Street", "city": "Springfield", "state": "IL", "postal_code": "62701", "country": "US" },
"product": { "name": "Pro plan", "description": "Monthly access to the editor and its exports.", "kind": "subscription" }
},
"terms": [
{ "kind": "refund_policy", "accepted_at": "2026-10-01T10:00:00Z", "version": "2026-05", "how": "checkbox at checkout, linked to the full text", "url": "https://acme.example/refunds" }
],
"usage": {
"first_at": "2026-10-01T10:05:00Z",
"last_at": "2026-11-10T18:20:00Z",
"count": 41,
"events": [
{ "at": "2026-10-01T10:05:00Z", "action": "login", "ip": "203.0.113.7", "device_id": "dev_9f8e7d6c" },
{ "at": "2026-10-03T14:00:00Z", "action": "export", "ip": "203.0.113.7", "device_id": "dev_9f8e7d6c", "detail": "project 12, 3 files" }
]
},
"delivery": { "kind": "digital", "delivered_at": "2026-10-01T10:00:30Z" },
"cancellation": { "requested": false, "reminder_sent_at": "2026-09-28T09:00:00Z" },
"communications": [
{ "at": "2026-10-04T09:00:00Z", "from": "customer", "channel": "email", "text": "Thanks, the export works now." }
],
"prior_charges": [
{ "ref": "ch_3RoldA", "ip": "203.0.113.7", "account_id": "user_771" },
{ "ref": "ch_3RoldB", "device_id": "dev_9f8e7d6c", "account_id": "user_771" }
]
}| Part | What it is | Limits |
|---|---|---|
customer |
The account the purchase was made from: your id for it, when it was opened, name, email, when the email was verified | |
purchase |
When and from where: IP address, device id and fingerprint, user agent, billing and shipping address, the product (name, description, digital / physical / service / subscription) |
|
terms[] |
What the customer accepted: terms_of_service, refund_policy, cancellation_policy; when, which version, how it was shown, where it is published, its text |
10 |
usage |
First and last use, a total, and events (time, action, IP address, device, detail) | 200 events |
delivery |
When it reached the customer; for goods the carrier, tracking number, shipping date and address | |
cancellation |
Whether the customer asked to cancel (requested, required), when, when it took effect, the last renewal reminder |
|
communications[] |
Messages between you and the customer: time, customer or merchant, channel, text |
50, 4,000 characters each |
prior_charges[] |
For each earlier charge the request offered: its IP address, device, account, email, shipping address, product | 5 |
records[] |
Anything else on record: a title, a time, the record in plain words | 20, 4,000 characters each |
documents[] |
Files, base64: receipt, customer_communication, service_documentation, shipping_documentation, refund_policy, cancellation_policy, customer_signature, duplicate_charge_documentation, uncategorized |
8; PDF, PNG or JPEG; 2 MiB each, 4 MiB together |
Rules:
- Never send a card number. A value shaped like one in an id, a name, an email or an address fails the whole answer. Inside a message, a record or other text a person wrote, and in a tracking number, it is removed before anything else reads the answer.
- Times are RFC 3339 with an offset (
2026-10-01T10:00:00Z). Countries are two letters. - Terms without
accepted_atare not used as accepted. "requested": falseundercancellationis a statement: you have no record that the customer asked to cancel. Send it only when that is so.- A payment provider takes one file per slot: a second file of the same slot is attached as
uncategorized, a third is left out. - Unknown fields are ignored. A field of the wrong shape (an IP address that is not one, a date without a time) fails the answer.
What answers which claim
Chargeless grades the evidence it has for a dispute (weak, medium, strong) by which of these parts are present. The grade is counted, not predicted, and is recorded with the dispute.
category |
The customer says | What decides it |
|---|---|---|
fraud |
"I did not make this purchase" | Visa Compelling Evidence 3.0 (below); or the purchase's IP address or device, usage from it, and an account |
unrecognized |
"I do not recognize this charge" | An account, usage and your conversation; the receipt |
not_received |
"It never arrived" | delivery together with usage or communications |
not_as_described |
"It was not what was sold" | usage, the refund policy the customer accepted, and communications |
subscription_canceled |
"I had cancelled" | cancellation.requested: false together with the cancellation policy accepted, or with usage |
duplicate |
"I was charged twice" | delivery or usage that shows two purchases |
credit_not_processed |
"I was promised a refund" | The refund policy the customer accepted, together with usage |
general |
Anything else | An account, usage and accepted terms; delivery |
A charge already refunded in full answers every claim; Chargeless knows that from the provider.
Visa Compelling Evidence 3.0
For a fraud dispute on a Visa card, the liability moves to the cardholder's bank when two earlier payments on the same card, 120 to 365 days old and never disputed, share the customer's IP address or device with the disputed one, and one more thing (the account, the shipping address).
Chargeless finds the earlier payments and offers them in prior_charges. For the package to be
sent your answer needs:
purchase.iporpurchase.device_id(ordevice_fingerprint) for the disputed purchase;- for at least two of the offered charges, an entry in
prior_chargeswith the same IP address or device, and the sameaccount_id(or shipping address) as the disputed one.
The payment provider decides whether it qualifies. Without those, the dispute is answered with the rest of your evidence.
A complete server
Node.js, no dependencies. It verifies the signature, looks the charge up, and answers.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.CHARGELESS_EVIDENCE_SECRET; // srme_…
const HOST = 'evidence.example.com'; // as Chargeless calls it
const PATH = '/chargeless/evidence';
function verified(req, body) {
const ts = req.headers['x-supportroute-timestamp'] ?? '';
const sent = req.headers['x-supportroute-signature'] ?? '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const mac = createHmac('sha256', SECRET);
mac.update(`${ts}\nPOST\n${HOST}\n${PATH}\n\n`);
mac.update(body);
const want = Buffer.from('v1=' + mac.digest('hex'));
const got = Buffer.from(sent);
return got.length === want.length && timingSafeEqual(got, want);
}
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== PATH) return res.writeHead(404).end();
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks);
if (!verified(req, body)) return res.writeHead(401).end();
const q = JSON.parse(body);
const answer = { request_id: q.request_id };
if (!q.test) {
// Your own lookups, by the provider's ids.
const order = await orders.byPayment(q.charge.payment_ref ?? q.charge.ref);
if (order) {
answer.customer = { account_id: order.userId, account_created_at: order.user.createdAt };
answer.purchase = { at: order.createdAt, ip: order.ip, device_id: order.deviceId,
product: { name: order.plan, kind: 'subscription' } };
answer.usage = await usage.since(order.userId, order.createdAt); // { first_at, last_at, count, events }
answer.cancellation = { requested: Boolean(order.cancelRequestedAt), requested_at: order.cancelRequestedAt ?? undefined };
answer.prior_charges = await Promise.all((q.prior_charges ?? []).map(async (p) => {
const o = await orders.byPayment(p.payment_ref ?? p.ref);
return o && { ref: p.ref, ip: o.ip, device_id: o.deviceId, account_id: o.userId };
})).then((list) => list.filter(Boolean));
}
}
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(answer));
}).listen(8443);Testing it
- Send Test Request in the panel: a signed call with
"test": true, carrying your API's credentials when you stored some. It checks the URL, the credentials, the signature and that your answer is valid. Nothing is used as evidence. A401or403from your gateway shows as "The service answered HTTP 401, not 200." - The contract's examples (
schemas/merchant-evidence/examples/v1/, in the contract zip) are valid requests and answers;signature.jsonchecks a verifier.
What you see in the panel
On Settings → Evidence Service: whether an access token is stored and which of your own headers are (never a value), the last test and the last request made for a dispute, each with how it ended (answered, no answer, an answer outside the contract and why) and the request id for your logs.
On a dispute's case, the timeline says when the evidence was prepared and when it was sent. The evidence itself is with your payment provider: open the dispute there to read what was sent.