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_precondition and 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:

sh
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_ids in the proto is customerIds).
  • 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.
  • bytes fields arrive base64-encoded: the policy document of GetPublished is one.

The same call from Node (18 or later, no packages) and Go, with the retries described below:

js
// 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');
go
// 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:

json
{"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 is backend:{your request id}:{action}:{target}, for example backend: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 with failed_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
js
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

sh
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…"}'
json
{
  "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

sh
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:

sh
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

sh
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) or ACTION_TYPE_CANCEL_SUBSCRIPTION (target: a subscription). ACTION_TYPE_HANDOFF, with a handoffReason code and no target, hands the case to your team without a decision on money.
  • reasonCode: duplicate, forgot_to_cancel, unrecognized, not_as_described, service_issue or other. 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:

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

sh
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 with replayed: true and the request's current outcome, so you may poll with it.
  • action: ACTION_TYPE_REFUND or ACTION_TYPE_CANCEL_SUBSCRIPTION.
  • targetId: a ledger id, or your provider's id for the charge or subscription (a Stripe ch_… or sub_…); with a provider id, connectionId names 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:

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

yaml
# 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_relative
go
client := 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.