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 AccessKey and AccessSecret, or X-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-* and X-SR-* are ours or the transport's and cannot be set.

They go with every request, the test request included, beside the signature:

http
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

http
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.json is a test vector: the request above, signed with srme_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):

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 has customer.kind: "guest" and no ref.
  • By your own ids, when you store them at the provider: metadata on 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 metadata field, 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.metadata and customer.metadata. A Checkout Session's own metadata is on none of them: set payment_intent_data.metadata or subscription_data.metadata when 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.

json
{
  "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_at are not used as accepted.
  • "requested": false under cancellation is 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.ip or purchase.device_id (or device_fingerprint) for the disputed purchase;
  • for at least two of the offered charges, an entry in prior_charges with the same IP address or device, and the same account_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.

js
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. A 401 or 403 from 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.json checks 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.