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.

  1. 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.
  2. 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.
  3. 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

http
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, the Idempotency-Key header as sent, and the raw body bytes. It is the merchant-provider signature, so one verifier serves both. schemas/merchant-decision/examples/v1/signature.json is a test vector: the test request, signed with srmd_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 after asked_at where 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 answer handoff when you cannot tell.
  • The same decision_id gets the same answer. The Idempotency-Key names the request's intent, not the attempt ({workspace}:{the action's key}); the decision_id is 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), the decision_id is the same: answer it as you did the first time. Under the same key a different amount gets a different decision_id: it is a new question. Cache by decision_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) and X-SR-Channel (chat, ticket, staff or system) 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)

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" }
}
  • rule is the rule that asks, as you wrote it in the policy.
  • action.source is 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) or merchant_user (your team, without an override).
  • action.amount is what would be refunded, in the charge's own currency, never converted; partial says it is less than what is left to refund.
  • target.ref is your payment provider's id of the charge (Stripe ch_… or py_…; on your own provider, your reference). target.provider is stripe, merchant (your own), paddle, fastspring, adyen, dlocal, ecommpay, paypal or nuvei; accept a value you do not know.
  • test_workspace is true (and absent otherwise) when the request comes from a test workspace, so one service can route those to its sandbox.
  • customer.verified_by is how the customer proved who they are: signed_token, email_otp, email_link, identifier_match or none (a fraud warning, your team). A guest payment ("kind": "guest") has no ref; a charge with no customer on record has no customer.

A cancellation (request.cancel.json)

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:

json
{
  "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:

json
{ "decision_id": "019a7d2e-4c1b-7a3e-9d21-4f6a8b0c2d4f", "decision": "approve" }
json
{
  "decision_id": "019a7d2e-4c1b-7a3e-9d21-4f6a8b0c2d4f",
  "decision": "deny",
  "reason_code": "item_used",
  "reference": "dec_8812"
}
json
{
  "decision_id": "019a7d2e-6f00-7b11-8c22-0a1b2c3d4e5f",
  "decision": "handoff",
  "reason_code": "account_review"
}
  • decision_id echoes the request's.
  • decision is approve, deny or handoff.
  • 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:

js
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 https on 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_id gets the same answer (cache by it, not by the key alone).
  • decision_id is echoed; nothing but the four fields is sent back.
  • When unsure, the answer is handoff, not a slow approve.
  • The test request passes in the panel, the service is switched on, then the rules are published.