Your decision service
Chargeless refunds and cancels inside the policy you publish: the window, the amount cap, the reasons, the limits per customer and per hour. Some requests need something only your systems know. The credits were already spent, the account is under review, the order was shipped this morning.
A decision service is one HTTPS endpoint of yours that Chargeless asks before it refunds or cancels, when a rule of your policy says to. Your answer decides that request: approve, decline, or send it to your team. 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-decision/v1/ in the
contracts (request.json, response.json); where this page and they differ, they win.
When you are asked
Only when a rule of your published policy asks, and only after every other step passed.
- Chargeless checks the request against your policy in its fixed order: stops, identity, the charge's state, the reason, the window, the amount cap, what is left to refund, the limits per customer and per hour, the circuit breaker.
- Then your rules run, in order. The first one that matches decides. A rule's outcome is Decline, Send to your team, or Ask your system.
- Only a matching Ask your system rule sends a request to your service.
So your service is never asked about a request the policy would refuse anyway, and your approval can never allow more than the policy did: not another amount, not another charge, not past a limit. A request your rules do not route to it is decided without it.
Your other rules still decide. A rule that declines or sends to your team decides whenever it matches, wherever it stands in the list: your service is asked only when none of them matches. So an approval never lets through a request one of your own rules would stop. When several rules that ask match, the first one is the one your service is asked about, once.
A rule may ask on every request (the condition "Every request") or on some: refunds above an amount, one product type, customers with refunds already, cancellations only. Refunds (asked for by the customer, or after an early fraud warning) and subscription cancellations reach the rules; disputes never do.
A member of your team who refunds with an override in the panel skips your rules, so your service is not asked. Without an override, a team member's action is asked like any other.
What each answer does
| Your answer | What happens | What the customer sees |
|---|---|---|
approve |
Chargeless carries it out, exactly as the policy allowed it. | The refund or the cancellation, as usual. |
deny |
It is declined, and the case is closed. | Your scripted decline for a request that cannot be approved automatically. |
handoff |
It is declined automatically and sent to your team: the ticket queue in the panel and your handoff webhook. A person on your team decides. | That the team will look at it. |
| no valid answer in time | As handoff. |
As handoff. |
There is no "pending" answer. If a person on your side needs to look, answer handoff: the
request reaches your team like every other handoff, with your reason code beside it.
Your reason_code and reference are shown to your team (the case, the action log, "How it was
handled"). They are never shown to the customer and never given to the assistant.
Connecting it
Settings → Decision Service. Enter the URL, save, and copy the signing
secret: it is shown once (srmd_…). Send a test request makes one signed call with
"test": true and synthetic refs; any valid answer passes. Then switch the service on.
Publishing a policy with an Ask your system rule needs the service switched on and its last test passed. Switching it off or removing it later unpublishes nothing: the rules that ask then send their requests to your team, and the policy page says so.
The URL is https on port 443, at an address Chargeless can reach over the internet, with a path
and no query string. Redirects are not followed. Changing the URL clears the last test: test the
new address before you publish rules again.
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 in the same section: an access token (sent as
Authorization: Bearer <token>) and up to three headers of your own (for example
X-Api-Key). They are stored encrypted, never shown again, sent with every request beside the
signature, and only to the URL they were entered for: when you change the URL, enter them again
or remove them.
Rotating the secret
- Stage a new secret: shown once; Chargeless still signs with the current one. Make your verifier accept both and deploy.
- Activate: Chargeless sends one test signed with the staged secret and makes it current only when your service answers it validly. Keep accepting the old secret for ten minutes, then retire it.
- Rotate now, for a leaked secret: the new one signs at once. Until you deploy it your service refuses Chargeless's requests, and the requests your rules ask about go to your team.
The request
POST /chargeless/decide HTTP/1.1
Host: decide.example.com
Accept: application/json
Content-Type: application/json
User-Agent: Chargeless (merchant-decision/v1)
X-SupportRoute-Timestamp: 1794484800
X-SupportRoute-Signature: v1=dce746d25963a23f20f55d82243cb102fb5cccd67b053b3a8ef101fe7d608898
Idempotency-Key: 01999999-0000-7000-8000-000000000001:decision-test:019a7d2e-8000-7000-8000-000000000000
X-SR-Request-Id: 019a7d2e-8000-7000-8000-000000000001
X-SR-Correlation-Id: 019a7d2e-8000-7000-8000-0000000000aa
X-SR-Channel: chat
{"contract":"merchant-decision/1.0","decision_id":"019a7d2e-8000-7000-8000-000000000000",…}- The signature is
v1=and the lower-case hex HMAC-SHA256, with your signing secret, of{timestamp}\n{METHOD}\n{host}\n{path}\n{idempotency_key}\n{body}: the timestamp header as sent,POST, the host as sent (lower case, no port when it is 443), the path as sent, theIdempotency-Keyheader as sent, and the raw body bytes. It is the merchant-provider signature, so one verifier serves both.schemas/merchant-decision/examples/v1/signature.jsonis a test vector: the test request, signed withsrmd_test_5b8e2c7a1f9d. - Refuse a signature that does not match, or a timestamp more than five minutes off, with
401. Compare signatures in constant time. - The deadline is in the body (
deadline): 4 seconds afterasked_atwhere a person or an API client waits (the chat, the customer portal, the panel, your form), 10 seconds in a background workflow (an email ticket, an early fraud warning). An answer after it is not read. There is no retry: answer fast, and answerhandoffwhen you cannot tell. - The same
decision_idgets the same answer. TheIdempotency-Keynames the request's intent, not the attempt ({workspace}:{the action's key}); thedecision_idis derived from it, the target and the amount asked about. If Chargeless asks again for the same intent (after a crash on its side, before it recorded the decision), thedecision_idis the same: answer it as you did the first time. Under the same key a different amount gets a differentdecision_id: it is a new question. Cache bydecision_id, never by the key alone. Chargeless never asks again about a request it has recorded, and applies your answer only to the amount it asked about. X-SR-Correlation-Id(the case the request is about, when there is one) andX-SR-Channel(chat,ticket,stafforsystem) are for your logs; nothing depends on them.- No contact data. The request carries ids, amounts, codes and times: your payment provider's ids of the charge or subscription and of the customer. No name, no email, no address, nothing the customer wrote.
A refund (request.refund.json)
{
"contract": "merchant-decision/1.0",
"decision_id": "019a7d2e-4c1b-7a3e-9d21-4f6a8b0c2d4f",
"tenant_id": "01999999-0000-7000-8000-000000000001",
"test": false,
"policy_version": "v12",
"asked_at": "2026-11-12T10:00:00Z",
"deadline": "2026-11-12T10:00:04Z",
"rule": { "id": "big-refunds-ask-us", "reason": "large_refund" },
"action": {
"type": "refund",
"source": "chat",
"reason_code": "not_as_described",
"amount": { "amount_minor": 8900, "currency": "USD" },
"partial": false
},
"target": {
"type": "transaction",
"provider": "stripe",
"connection_id": "01999999-0000-7000-8000-0000000000c1",
"ref": "ch_3QxY2bL8",
"amount": { "amount_minor": 8900, "currency": "USD" },
"created_at": "2026-11-02T15:21:09Z"
},
"customer": { "kind": "account", "ref": "cus_R2k9aZ", "verified_by": "email_otp" }
}ruleis the rule that asks, as you wrote it in the policy.action.sourceis who asked, as the policy reads it:chat(the assistant and the customer portal),ticket(an email or helpdesk ticket, your form),fraud_alert(an early fraud warning) ormerchant_user(your team, without an override).action.amountis what would be refunded, in the charge's own currency, never converted;partialsays it is less than what is left to refund.target.refis your payment provider's id of the charge (Stripech_…orpy_…; on your own provider, your reference).target.providerisstripe,merchant(your own),paddle,fastspring,adyen,dlocal,ecommpay,paypalornuvei; accept a value you do not know.test_workspaceistrue(and absent otherwise) when the request comes from a test workspace, so one service can route those to its sandbox.customer.verified_byis how the customer proved who they are:signed_token,email_otp,email_link,identifier_matchornone(a fraud warning, your team). A guest payment ("kind": "guest") has noref; a charge with no customer on record has nocustomer.
A cancellation (request.cancel.json)
{
"contract": "merchant-decision/1.0",
"decision_id": "019a7d2e-6f00-7b11-8c22-0a1b2c3d4e5f",
"tenant_id": "01999999-0000-7000-8000-000000000001",
"test": false,
"policy_version": "v12",
"asked_at": "2026-11-12T10:05:00Z",
"deadline": "2026-11-12T10:05:04Z",
"rule": { "id": "every-cancellation-asks-us", "reason": "cancellation_check" },
"action": { "type": "cancel_subscription", "source": "chat", "reason_code": "forgot_to_cancel" },
"target": {
"type": "subscription",
"provider": "merchant",
"connection_id": "01999999-0000-7000-8000-0000000000c1",
"ref": "sub-88213",
"created_at": "2026-05-01T09:00:00Z"
},
"customer": { "kind": "account", "ref": "cust-4410", "verified_by": "signed_token" }
}The refund that comes with a cancellation (request.followup.json)
When your policy refunds the last charge of a subscription cancelled for a reason you chose
(refund_last_charge_if), that refund is a request of its own. If a rule asks about it, it gets
a request of its own too, sent at the same time as the cancellation's, under its own key, with
action.followup_of naming the subscription:
{
"contract": "merchant-decision/1.0",
"decision_id": "019a7d2e-6f00-7b11-8c22-0a1b2c3d4e60",
"tenant_id": "01999999-0000-7000-8000-000000000001",
"test": false,
"policy_version": "v12",
"asked_at": "2026-11-12T10:05:00Z",
"deadline": "2026-11-12T10:05:04Z",
"rule": { "id": "big-refunds-ask-us", "reason": "large_refund" },
"action": {
"type": "refund",
"source": "chat",
"reason_code": "forgot_to_cancel",
"amount": { "amount_minor": 5900, "currency": "EUR" },
"partial": false,
"followup_of": { "type": "cancel_subscription", "target_ref": "sub-88213" }
},
"target": {
"type": "transaction",
"provider": "merchant",
"connection_id": "01999999-0000-7000-8000-0000000000c1",
"ref": "txn-77120",
"amount": { "amount_minor": 5900, "currency": "EUR" },
"created_at": "2026-11-10T08:00:00Z",
"charge_type": "renewal"
},
"customer": { "kind": "account", "ref": "cust-4410", "verified_by": "signed_token" }
}Each answer decides its own request. Declining the refund leaves the cancellation as its own answer decided it; declining the cancellation means no refund is made, whatever you answered for it.
The test request (request.test.json)
"test": true, synthetic refs (ch_test, cus_test), the rule test. Nothing is decided;
any valid answer passes the test.
The answer
HTTP 200, Content-Type: application/json, at most 4 KiB, before the deadline:
{ "decision_id": "019a7d2e-4c1b-7a3e-9d21-4f6a8b0c2d4f", "decision": "approve" }{
"decision_id": "019a7d2e-4c1b-7a3e-9d21-4f6a8b0c2d4f",
"decision": "deny",
"reason_code": "item_used",
"reference": "dec_8812"
}{
"decision_id": "019a7d2e-6f00-7b11-8c22-0a1b2c3d4e5f",
"decision": "handoff",
"reason_code": "account_review"
}decision_idechoes the request's.decisionisapprove,denyorhandoff.reason_code(optional) is a code of your own,^[a-z0-9_]{1,64}$.reference(optional) is your own id for the decision, to find it in your logs: up to 128 printable ASCII characters, and never anything shaped like a card number. Use an id of letters, digits and. _ : -: one with other characters (a space, an@) or with a run of twelve or more digits is accepted but not kept.
The answer is strict. Anything else is no answer, and the request goes to your team: another
status (a 401, a 500), a redirect, a timeout, a body over 4 KiB, a body that is not JSON, a
field this page does not name, a decision_id that is not the request's, a reference shaped like
a card number. The panel's recent calls show which it was (timeout, http_status, schema,
decision_id, card_number, …).
Limits
- If the refund's amount changes between your answer and the decision (another refund of the charge settled or failed in between), your answer is not used and the request goes to your team.
- At most 16 requests to your service are in flight per workspace on each Chargeless server (and
256 in all on one server). A request over that is not sent and goes to your team
(
merchant_decision_busy). - The service's page (Settings → Decision Service) warns when, over the last 15 minutes, more than 20% of at
least five requests got no valid answer, when your service refused Chargeless (
401,403), or when it answered outside the contract.
An example endpoint
A minimal service in Node, with the signature checked and a rule of your own:
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.CHARGELESS_DECISION_SECRET; // srmd_…
const app = express();
app.use(express.raw({ type: 'application/json', limit: '64kb' }));
function verified(req) {
const ts = req.get('X-SupportRoute-Timestamp') ?? '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const signed = [ts, 'POST', req.get('Host'), req.originalUrl, req.get('Idempotency-Key') ?? '', req.body].join('\n');
const want = 'v1=' + crypto.createHmac('sha256', SECRET).update(signed).digest('hex');
const got = req.get('X-SupportRoute-Signature') ?? '';
return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
app.post('/chargeless/decide', async (req, res) => {
if (!verified(req)) return res.status(401).end();
const q = JSON.parse(req.body.toString('utf8'));
if (q.test) return res.json({ decision_id: q.decision_id, decision: 'approve' });
// Your own records, found by the provider's ids.
const account = await accounts.byProviderCustomer(q.customer?.ref);
if (account?.underReview) {
return res.json({ decision_id: q.decision_id, decision: 'handoff', reason_code: 'account_review' });
}
if (q.action.type === 'refund' && (await usage.creditsSpentOn(q.target.ref)) > 0) {
return res.json({ decision_id: q.decision_id, decision: 'deny', reason_code: 'credits_used' });
}
return res.json({ decision_id: q.decision_id, decision: 'approve' });
});The body is read as raw bytes for the signature before it is parsed. Answer the same way for the
same decision_id; a cache keyed by it for a day is enough (never by the Idempotency-Key alone: a
key asked again about another amount carries another decision_id, and is a new question).
Checklist
- The endpoint is
httpson 443, reachable from the internet, answers in well under 4 seconds. - The signature and the timestamp are checked; a bad one gets
401. - The same
decision_idgets the same answer (cache by it, not by the key alone). -
decision_idis echoed; nothing but the four fields is sent back. - When unsure, the answer is
handoff, not a slowapprove. - The test request passes in the panel, the service is switched on, then the rules are published.