The Chargeless API: your backend
This guide is for engineers who call Chargeless from their own servers: to refund or cancel for a customer who is signed in on your site, to send requests from your own support form, or to read the policy, the actions and the cases into your systems.
Chargeless decides every refund and cancellation with the policy you publish in the panel. Your backend proposes an action and Chargeless decides it, executes it at your payment provider and records it, exactly as it does for the chat widget. No call can get past the policy, the safety limits or an emergency stop.
| File | What it holds |
|---|---|
openapi/backend-api/v1.yaml |
The API as OpenAPI 3.1: every procedure, request and response below, with its fields |
proto/sr/**/v1/*.proto |
The same API as Protocol Buffers, to generate a typed client (below) |
CHANGELOG.md |
Every change to the contracts |
Every procedure, with its fields and an example answer, is in the API reference. Related guides: Receiving Chargeless webhooks (what happened after your call), the widget and customer identity (the signed identity token) and Connecting your own billing system (the opposite direction: Chargeless calling you).
1. Basics
Your API key
Create a key in Settings → API Keys (owners, admins and developers). It starts with
sr_live_, or sr_test_ in a test workspace (below), and is shown once: store it in your server's
secret store, never in a browser, an app or a repository. Send it on every call:
Authorization: Bearer sr_live_…A key belongs to one workspace; there is no tenant or workspace field anywhere in the API. Revoke a key in the same place; Last used shows which keys are still in use.
A key can verify customers and look up their charges and subscriptions; submit refunds and
cancellations; send requests from your own form; read the policy and preview a decision; read the
actions, cases, refunds, disputes, fraud warnings, subscriptions and the provider health. It
cannot sign in to the panel, publish the policy, change settings, the team, keys or secrets,
read or answer tickets, or get past the policy, the safety limits or an emergency stop. While
Chargeless has suspended your workspace, every call but GetTenant is refused.
Test keys and test workspaces
Build and try your integration in a test workspace. Create one from the workspace menu (Create a Workspace) with the Test workspace switch on. It works like any workspace: its own policy, help site, widget, webhooks and team, the same procedures, decisions and safety limits. Two things differ:
- Its API keys start with
sr_test_. - It connects only test-mode payment accounts: Stripe test keys, a Paddle sandbox, FastSpring in
test mode, an Adyen test account, the dLocal sandbox and an Ecommpay test project. A live account
is refused with
failed_preconditionand nothing is stored. Your own billing system connects as usual; point it at your test system.
Refunds and cancellations in a test workspace run against your provider's sandbox, so you can follow the whole path (the decision, the execution, the outcome and the webhooks) without real money. Take it live (Go live on its home page) as you will the live workspace: actions run only after go-live, in both.
A workspace is test or live from the moment it is created, and stays that way. A sr_test_ key
reaches only its test workspace: when you go to production, switch to a sr_live_ key of the live
workspace. GetTenant answers "test": true for a test workspace.
Calling a procedure
The API is Connect over HTTPS: plain JSON over HTTP, no
special client needed. Every procedure is a POST of a JSON body to
https://api.chargeless.io/{package}.{Service}/{Method}with Content-Type: application/json. A first call that reads your workspace:
curl -sS https://api.chargeless.io/sr.tenant.v1.TenantService/GetTenant \
-H "Authorization: Bearer $CHARGELESS_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'The JSON follows the Protocol Buffers JSON mapping:
- Field names are lowerCamelCase (
customer_idsin the proto iscustomerIds). - Enums are their names as strings:
"ACTION_TYPE_REFUND". - 64-bit integers are strings:
"amountMinor": "1999"(numbers are accepted on input). - Times are RFC 3339 strings in UTC:
"2026-11-06T09:30:00Z". - Money is always minor units and an ISO 4217 code:
{"amountMinor": "1999", "currency": "USD"}is USD 19.99,{"amountMinor": "1500", "currency": "JPY"}is JPY 1,500. Never a float. - Ids are opaque strings. The ledger's ids (a transaction, a subscription, an action) are what the API returns and takes back; your provider's own ids are accepted only where a field says so.
- Fields you leave out take their default; fields Chargeless does not know are ignored. An answer leaves out a field at its default (empty, zero, false), so read a missing field as its default.
bytesfields arrive base64-encoded: the policy document ofGetPublishedis one.
The same call from Node (18 or later, no packages) and Go, with the retries described below:
// chargeless.mjs
const API = 'https://api.chargeless.io';
const RETRY = new Set(['unavailable', 'deadline_exceeded', 'internal', 'resource_exhausted']);
export async function call(procedure, body = {}) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(`${API}/${procedure}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHARGELESS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
} catch (err) {
if (attempt >= 4) throw err; // a network error: send the same request again
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
continue;
}
// An error body that is not JSON (a proxy in between) is read from its status.
const answer = await res.json().catch(() => ({
code: res.status === 429 ? 'resource_exhausted' : res.status >= 500 ? 'unavailable' : 'unknown',
message: res.statusText,
}));
if (res.ok) return answer;
if (!RETRY.has(answer.code) || attempt >= 4) {
throw Object.assign(new Error(`${procedure}: ${answer.code}: ${answer.message}`), answer);
}
const wait = Number(res.headers.get('Retry-After') ?? 0) * 1000;
await new Promise((r) => setTimeout(r, Math.max(wait, 1000 * 2 ** attempt)));
}
}
// const { tenant } = await call('sr.tenant.v1.TenantService/GetTenant');// A plain net/http call; section 5 shows a generated, typed client.
func call(ctx context.Context, procedure string, in, out any) error {
body, err := json.Marshal(in)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.chargeless.io/"+procedure, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CHARGELESS_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
var e struct{ Code, Message string }
_ = json.NewDecoder(res.Body).Decode(&e)
return fmt.Errorf("%s: %s: %s", procedure, e.Code, e.Message)
}
return json.NewDecoder(res.Body).Decode(out)
}Errors
A failed call answers a non-2xx status with a JSON body:
{"code": "failed_precondition", "message": "…", "details": [ … ]}code |
HTTP | What to do |
|---|---|---|
invalid_argument |
400 | The request is malformed; the message says which field. Do not retry as is. |
failed_precondition |
400 | The request is well formed but cannot apply now (an idempotency key reused for another intent, a target that does not exist, a workspace not live). details names the reason. |
unauthenticated |
401 | The key is missing, revoked or mistyped. |
permission_denied |
403 | A key may not call this procedure. |
not_found |
404 | No such object in your workspace. |
already_exists |
409 | The object exists. |
aborted |
409 | A concurrent change won; read again and retry. |
resource_exhausted |
429 | Too many requests: back off and retry. |
unavailable |
503 | Chargeless or your payment provider could not answer: retry with the same idempotency key. |
deadline_exceeded |
504 | The call took too long: retry with the same idempotency key. |
internal |
500 | Our fault: retry later with the same idempotency key. |
A decision is never an error. A refund the policy refuses is a successful call whose action says
ACTION_STATUS_DENIED and why.
Idempotency and retries
Every call that changes something takes a key you choose, and a retry with the same key returns the
first result instead of acting twice (replayed: true):
ActionsService/Submit:idempotencyKey, naming the intent, never the attempt. A good shape isbackend:{your request id}:{action}:{target}, for examplebackend:ord_8812:refund:01a1aff4-53d6-7334-8e2d-9d3d35bfcff5. Reuse it on every retry of that intent; never put a timestamp or a random value in it. The same key with another type or target is refused withfailed_precondition.RequestIntakeService/SubmitCustomerRequest:requestId, your id for the request.
Retry on unavailable, deadline_exceeded, internal and resource_exhausted with the same key,
backing off (1, 2, 4, 8 s …), and never sooner than a Retry-After header says. A network error after you sent the request is the same case: you do
not know whether it arrived, so send it again with the same key.
Limits
Each workspace may make 600 calls a minute with its API keys, all keys together, in bursts of up
to 100. The budget refills continuously, ten calls a second. A call over it does nothing and is
answered resource_exhausted (HTTP 429) with a Retry-After header: the whole seconds to wait
before the next call goes through. Every call a key makes counts, whatever its answer. A test
workspace has a budget of its own.
In front of that, the API host accepts at most 1,200 requests a minute from one client IP address
and answers 429 above it. Lists return at most pageSize items (each procedure has a
default and a maximum); pass nextPageToken back as pageToken unchanged, and treat it as opaque.
2. Refund or cancel for a signed-in customer
When a customer is signed in on your site, your server vouches for them with a signed identity token, and then acts on their behalf. The policy decides as it does for a chat in the widget.
Step 1: sign an identity token
The token is the widget's: an HS256 JWT signed with the Widget Signing Secret (srwgt_…,
Settings → Widget), created on your server only.
| Claim | Value |
|---|---|
sub |
the customer's id at your payment provider (Stripe cus_…, Paddle ctm_…, your own system's id) |
tenant |
your Workspace ID |
connection |
optional: the payment connection the customer belongs to (Settings → Integrations); without it, your first connection |
email |
optional |
iat, exp |
issue time; at most one hour ahead |
import jwt from 'jsonwebtoken';
const token = jwt.sign(
{ sub: customer.stripeCustomerId, tenant: process.env.CHARGELESS_WORKSPACE_ID },
process.env.CHARGELESS_WIDGET_SECRET, // srwgt_…
{ algorithm: 'HS256', expiresIn: '15m' },
);Step 2: verify it
curl -sS https://api.chargeless.io/sr.identity.v1.IdentityService/Verify \
-H "Authorization: Bearer $CHARGELESS_API_KEY" -H "Content-Type: application/json" \
-d '{"token": "eyJhbGciOiJIUzI1NiIs…"}'{
"customerId": "01a1aff4-5117-79a0-a966-68ed1a264ea1",
"providerCustomerId": "cus_Q8x…",
"method": "IDENTITY_METHOD_SIGNED_TOKEN",
"connectionId": "01a1aff4-50ab-74ce-9c05-b142b747606c",
"verifiedAt": "2026-11-06T09:30:00Z",
"expiresAt": "2026-11-06T09:45:00Z"
}customerId is the customer in Chargeless's ledger: pass it on in the next steps. A token that
does not verify or has expired is unauthenticated (the message says it is the token, not your
key); a customer the ledger does not hold yet is not_found.
Step 3: look up their charges and subscriptions
curl -sS https://api.chargeless.io/sr.transactions.v1.TransactionsService/Lookup \
-H "Authorization: Bearer $CHARGELESS_API_KEY" -H "Content-Type: application/json" \
-d '{"customerIds": ["01a1aff4-5117-79a0-a966-68ed1a264ea1"], "limit": 20}'Each transaction carries its ledger id, the provider's id, amount, refunded,
refundableRemainder, state, the card's brand and last four, and its payment connection; each
subscription its id, state and plan. Only the customers you pass are read.
Step 4 (optional): preview the decision
PolicyService/Evaluate answers what the policy would decide now, without recording anything:
curl -sS https://api.chargeless.io/sr.policy.v1.PolicyService/Evaluate \
-H "Authorization: Bearer $CHARGELESS_API_KEY" -H "Content-Type: application/json" \
-d '{
"actionType": "ACTION_TYPE_REFUND",
"targetId": "01a1aff4-53d6-7334-8e2d-9d3d35bfcff5",
"reasonCode": "duplicate",
"source": "SOURCE_CHAT",
"identityIds": ["01a1aff4-5117-79a0-a966-68ed1a264ea1"],
"identityMethod": "IDENTITY_METHOD_SIGNED_TOKEN"
}'decision.outcome is OUTCOME_ALLOW, OUTCOME_DENY or OUTCOME_HANDOFF, with reasons (a code
and a sentence each) and amount. A preview is not a promise: the facts can change before you
submit, and the submission decides again.
Step 5: submit the action
curl -sS https://api.chargeless.io/sr.actions.v1.ActionsService/Submit \
-H "Authorization: Bearer $CHARGELESS_API_KEY" -H "Content-Type: application/json" \
-d '{
"idempotencyKey": "backend:ord_8812:refund:01a1aff4-53d6-7334-8e2d-9d3d35bfcff5",
"type": "ACTION_TYPE_REFUND",
"targetId": "01a1aff4-53d6-7334-8e2d-9d3d35bfcff5",
"reasonCode": "duplicate",
"identity": {
"customerId": "01a1aff4-5117-79a0-a966-68ed1a264ea1",
"customerIds": ["01a1aff4-5117-79a0-a966-68ed1a264ea1"],
"method": "IDENTITY_METHOD_SIGNED_TOKEN"
},
"requestedBy": "account-page",
"waitSeconds": 5
}'type:ACTION_TYPE_REFUND(target: a transaction) orACTION_TYPE_CANCEL_SUBSCRIPTION(target: a subscription).ACTION_TYPE_HANDOFF, with ahandoffReasoncode and no target, hands the case to your team without a decision on money.reasonCode:duplicate,forgot_to_cancel,unrecognized,not_as_described,service_issueorother. The policy may decide by it.amount: optional, for a partial refund in the charge's currency; the whole refundable remainder when left out.requestedBy: your label for where the request came from, shown on the action. Required.waitSeconds: how long to wait for the provider's outcome before answering (default 5, at most 10).
The answer is the action:
{
"action": {
"id": "01a1aff5-427d-765f-a005-d2675fe290ab",
"type": "ACTION_TYPE_REFUND",
"status": "ACTION_STATUS_EXECUTED",
"targetId": "01a1aff4-53d6-7334-8e2d-9d3d35bfcff5",
"amount": {"amountMinor": "1900", "currency": "EUR"},
"decision": {"outcome": "OUTCOME_ALLOW", "policyVersion": "v7", "reasons": [ … ]},
"providerRef": "re_3Q…",
"createdAt": "2026-11-06T09:31:02Z",
"executedAt": "2026-11-06T09:31:03Z"
},
"replayed": false
}status |
Meaning |
|---|---|
ACTION_STATUS_EXECUTED |
Done at the provider. |
ACTION_STATUS_PENDING |
Approved and on its way, or waiting for the provider's outcome. |
ACTION_STATUS_DENIED |
The policy (or a stop) refused it; decision.reasons says why. |
ACTION_STATUS_HANDOFF |
Handed to your team; decision.reasons says why. |
ACTION_STATUS_FAILED |
The provider refused it at execution; failure says how. Your team gets it. |
Step 6: follow a pending action
Read it again with ActionsService/Get ({"actionId": "…"}), or, better, subscribe to the
action.executed and action.failed webhooks (webhooks.md). Some providers confirm
a refund later than others (Paddle reviews refunds; Adyen and Ecommpay confirm by notification);
an outcome that never comes is handed to your team after 24 hours.
3. Requests from your own support form
If you keep your own form instead of the widget, send each request to
RequestIntakeService/SubmitCustomerRequest. It opens a ticket and the policy decides the request
at once; the customer follows it on their ticket page.
curl -sS https://api.chargeless.io/sr.tickets.v1.RequestIntakeService/SubmitCustomerRequest \
-H "Authorization: Bearer $CHARGELESS_API_KEY" -H "Content-Type: application/json" \
-d '{
"requestId": "form-20261106-0042",
"action": "ACTION_TYPE_REFUND",
"targetId": "ch_3Q8x…",
"reasonCode": "duplicate",
"message": "I was charged twice for my order.",
"locale": "en",
"requesterName": "Kim Okafor",
"email": "[email protected]"
}'requestId: your id for the request, 1–128 characters of[A-Za-z0-9_-]. It is the idempotency key: a retry returns the same ticket withreplayed: trueand the request's current outcome, so you may poll with it.action:ACTION_TYPE_REFUNDorACTION_TYPE_CANCEL_SUBSCRIPTION.targetId: a ledger id, or your provider's id for the charge or subscription (a Stripech_…orsub_…); with a provider id,connectionIdnames its payment connection (empty: your first connection).- Exactly one proof:
identityToken, the signed token of section 2, for a signed-in customer: the request is decided at once, bound to that customer.email, what the form collected: a typed address proves nothing, so the request is decided as unverified (CUSTOMER_REQUEST_OUTCOME_IDENTITY_REQUIRED) and the address gets a mail with a single-use link. The owner of the address proves it and confirms the request on the ticket page; only then is it decided again.
The answer names the ticket and where the request stands:
{
"ticketId": "01a1b00b-…",
"ticketNumber": "1042",
"pageUrl": "https://acme.help.chargeless.io/t/9fK2…",
"outcome": "CUSTOMER_REQUEST_OUTCOME_IDENTITY_REQUIRED",
"reasonCode": "identity_required",
"actionId": "01a1b00b-…",
"replayed": false
}outcome is EXECUTED, PENDING, DENIED, HANDOFF or IDENTITY_REQUIRED (each prefixed
CUSTOMER_REQUEST_OUTCOME_). Show the customer pageUrl: it identifies the ticket and grants
nothing, since the page asks for proof first. The customer_request.decided webhook tells you when
a request is decided again after confirmation.
4. Reading into your systems
| Procedure | What it returns |
|---|---|
sr.tenant.v1.TenantService/GetTenant |
Your workspace: its status (live, onboarding, suspended), the emergency stop, the help site |
sr.policy.v1.PolicyService/GetPublished |
The published policy document (base64) and its version |
sr.policy.v1.PolicyService/ListVersions, GetVersion |
The policy's history and any earlier version |
sr.policy.v1.PolicyService/Evaluate |
A decision preview (section 2, step 4) |
sr.cases.v1.CasesService/ListActions |
Every action, newest first, filterable by time, type, status and source |
sr.cases.v1.CasesService/ListCases, GetCase |
The cases (chat, ticket, fraud warning, dispute) and one case's timeline and decision facts |
sr.cases.v1.CasesService/ListRefunds |
Refunds, by Chargeless, at the provider or in your own system, with totals per currency |
sr.cases.v1.CasesService/ListDisputes |
Disputes and inquiries, with their deadlines |
sr.cases.v1.CasesService/ListFraudWarnings |
Early fraud warnings and what was done about them |
sr.cases.v1.CasesService/ListSubscriptions |
Subscriptions across your payment connections |
sr.cases.v1.CasesService/ListProviderCalls |
The calls Chargeless made to your own payment system (merchant-hosted connections) |
sr.cases.v1.CasesService/GetProviderHealth |
Your own payment system's health, for monitoring |
sr.identity.v1.IdentityService/LookupCustomer |
The customers an email address belongs to, per payment connection |
sr.actions.v1.ActionsService/Get |
One action |
Lists filter by a time range (from, to) and their own fields, and page with pageSize and
pageToken; the reference gives each list's page size and whether its
range is required. Every field is in openapi/backend-api/v1.yaml.
5. A typed client
The OpenAPI document works with any OpenAPI tool. For a typed client with the exact field types, generate one from the protos with Buf and Connect's generator for your language (Go, TypeScript, Kotlin, Swift, Python, Dart):
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/connectrpc/go
out: gen
opt: paths=source_relative
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relativeclient := actionsv1connect.NewActionsServiceClient(http.DefaultClient, "https://api.chargeless.io")
req := connect.NewRequest(&actionsv1.SubmitRequest{ /* … */ })
req.Header().Set("Authorization", "Bearer "+os.Getenv("CHARGELESS_API_KEY"))
res, err := client.Submit(ctx, req)6. Versions and changes
Every package is versioned (sr.actions.v1). Within v1, fields and procedures are only added;
a field is never renamed, retyped or reused, and a change that would break a caller ships as v2
beside v1. Every change is in CHANGELOG.md. Ignore fields you do not know, and do not depend on
the order of fields or list items unless a field says it is ordered.