Your customers and their credits
Add your customers, keep their credit accounts, top up, charge and adjust their credits, block a customer, and set what an operation costs in credits.
A customer here is a customer of your own product, named by the id your system gives them. Slen keeps credit accounts for your customers, takes credits from them when a flow runs for them, and refuses a run they cannot pay for. The operations of this page add and remove customers, set up their accounts, move their credits, block a customer and set what an operation costs in credits. The console shows the same customers on Customers.
Customers and accounts
A customer is named by
external_user_id, the same id a request that runs a flow sends incontext.user_id. It is an opaque id of your own, not an e-mail address or a name.PUT /v1/customers/{externalUserId}adds a customer without credits and without an account. It answers201when it created the customer and200when the customer was already there, so a repeat needs no key.An account belongs to one customer and one credit type. A credit type is a name of your own, and without one it is
default. A customer with two credit types has two accounts.An account is created by the first top-up of its credit type or by its settings. A charge and an adjustment create nothing.
An account carries
balance,reservedandavailable.reservedis what running calls hold, andavailableisbalanceminusreserved.A customer the project has never seen answers
404with the codecustomer_not_found. A customer without accounts answers with an emptyaccounts.
Reading takes customers:read, and every change takes customers:write, except setting a price, which takes management:write. A key of your application with gateway:write and customers:write runs flows and keeps its customers, and changes nothing else.
The settings of an account
PUT /v1/customers/{externalUserId}/accounts/{creditType} sets both settings of an account at once. It creates the customer and the account when they are missing and answers 201, and otherwise it changes the settings and answers 200.
allow_negativelets the account go below zero. It is off by default, and then a hold or a charge the account cannot cover is refused withinsufficient_credits. It can be switched off while the balance is below zero: the guard holds from the next movement.low_balance_thresholdsets the level of thecustomer.balance_lowwebhook event. The event is sent when a movement takesavailablefrom that level or above it to below it. Zero, the default, switches the event off.A top-up and an adjustment do not change the settings: a body that names either field is refused with
422.
How a run spends credits
A run spends credits when it names a customer in context.user_id and the project has a credit price for its operation. context.credit_type names the credit type, and without it the type is default. The credit type decides which account pays and which price applies.
An operation without a price for that credit type, or with a price of zero, takes no credits.
Each model call of the run holds the price on the account before the model is called. When the call is settled, the hold becomes spend, and a call that failed gives it back.
An account that cannot cover the price refuses the call with the code
insufficient_credits. A customer who has no account of the credit type is refused the same way.A customer the project does not know is refused with the code
customer_not_found.A blocked customer is refused with the code
customer_blocked— Block a customer.
A price is set for an operation code and a credit type, and the next hold uses it. Setting a price again replaces it, and a hold already taken keeps the price it was taken at.
Move credits
Three operations move credits. Each requires an Idempotency-Key header of at most 190 characters, and a request without it answers 400 with the code idempotency_key_required.
A top-up adds credits. It creates the customer and the account when they are missing.
A charge takes credits for something your application sells by itself — an export, a seat. The statement shows it as a
charge. A customer the project does not know answers404with the codecustomer_not_found, and a customer without an account of the credit type answers402with the codeinsufficient_credits.An adjustment corrects a balance. Its
creditsis signed, and zero is refused. An account that does not exist answers404with the codecustomer_not_found.
What the three share:
creditshas at most four digits after the point. A fifth digit is refused with422, not rounded.note, up to 255 characters, is written on the entry and shown in the statement: an invoice number, a reason.A charge or an adjustment cannot take the balance below what the account holds, unless the account allows a negative balance. Such a request answers
402with the codeinsufficient_credits. A top-up is never refused this way, also on an account below zero.A blocked customer can be topped up, charged and adjusted.
A charge is not spend: the margin report sets the credits of a call against what the call cost, and no call stands behind a charge.
A repeat under the key moves nothing.
Repeating the same request under its key answers
200. For 24 hours, unless the service is set up otherwise, the answer is the first one, with the balance of that moment. After that it is the account as it is now.A key that already names another top-up, charge or adjustment of the project answers
409with the codeidempotency_conflict. For those 24 hours any other body under the key is another request, anothernotetoo. After that another customer, another credit type, another kind of movement or another amount is another movement. A new movement needs a new key.A refused movement is not kept: the same request may be sent again under its key.
Block a customer
POST /v1/customers/{externalUserId}/block blocks a customer, and POST /v1/customers/{externalUserId}/unblock lets them in again.
A new run of a blocked customer is refused with
403and the codecustomer_blockedbefore anything is created.A run of theirs that is already under way — queued, or waiting for an approval — is refused at the next hold of credits of a model call. The model node leaves through Deny, and without a branch there the execution ends
rejectedwithcustomer_blocked. A run of an operation without a credit price holds no credits and runs to its end.A repeat of a request under a key it was sent with before the block is answered with that execution.
Credits still move, the settings still change and the statement still reads, so you can settle with a blocked customer.
blocked_atis the moment of the first block. Blocking again keeps it and replacesblock_reasononly when areasonis sent. Unblocking clears both.
Delete a customer
DELETE /v1/customers/{externalUserId} removes a customer and their accounts only when nothing ever happened to them: every account is at zero with nothing held, and the statement has no entry. Otherwise the customer stays, and the answer explains why.
{
"error": {
"code": "customer_has_history",
"message": "The customer has credits or a credit history and cannot be deleted; block it instead",
"request_id": "5d8a3f1c-2b6e-4c9a-8f17-3e0b9d4c6a25"
}
}The statement explains the balance, so it is kept. Block such a customer instead. A delete and a movement of the same customer never pass each other: a delete that meets a top-up under way waits and then refuses, and a top-up that meets a delete under way waits and then creates the customer again.
Read customers and statements
The list is paged, newest customer first, and each customer comes with all their accounts.
statuskeeps the active or the blocked customers,qthe customers whoseexternal_user_idcontains it, andcredit_typethose with an account of that credit type.low_balanceis a number and not a flag: it keeps the customers with an account whoseavailableis below it — an account ofcredit_typewhen both are sent. The filters choose customers and never shorten the list of their accounts.The statement of a customer lists every movement of their credits, newest first. An entry carries the account as the movement left it, in
balance_afterandavailable_after, and thenoteof the request.entry_typekeeps one kind of movement, andcredit_typethe movements of one account. A customer without accounts has an empty statement.
entry_type | What moved | credits |
|---|---|---|
topup | A top-up | Positive |
charge | A charge of your application | Negative |
adjust | An adjustment | Either |
hold | A model call held its price from available | Negative |
commit | The call was settled, and the held price left balance | Negative |
release | The hold went back to available | Positive |
Do not add credits up to get what a customer spent. A call that spent credits has two negative entries of the same price, its hold and its commit, and only the commit entries are spend.
Operations
End customers with their credit accounts
GET /v1/customers
- Scope:
customers:read - Available in the SDK for PHP and Node.js
One cursor page of the project's end customers, newest first, each with every credit account it holds (accounts, by credit_type; empty for a customer created by PUT /v1/customers/{externalUserId} that holds none yet). The filters choose customers: credit_type — those holding an account of that type; low_balance — those holding an account with fewer credits still spendable (balance - reserved) than the threshold, of credit_type when it is given too; status, q. The nested list is never narrowed: a customer that matches comes with all its accounts.
Parameters
| Parameter | Description |
|---|---|
cursor optionalquery | The |
view optionalquery |
|
limit optionalquery | Page size.
|
status optionalquery | Only customers in this status.
|
credit_type optionalquery | Customers holding an account of this credit type.
|
low_balance optionalquery | A threshold, not a flag: |
q optionalquery | Narrows the list to the customers whose
|
Responses
One cursor page of customers
{
"data": [
{
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"meta": {
"limit": 1,
"next_cursor": "string"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
One end customer with its credit accounts
GET /v1/customers/{externalUserId}
- Scope:
customers:read - Available in the SDK for PHP and Node.js
The customer and every credit account it holds, by credit_type. A customer with no account yet answers with accounts: []; an external_user_id the project has never seen is 404 customer_not_found, and so is one of another project. The external_user_id is the caller's opaque identifier — not an email address or a name.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Responses
The customer
{
"data": {
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. |
Headers
X-Request-Id
The body of every error is the error envelope.
Make sure an end customer exists
PUT /v1/customers/{externalUserId}
- Scope:
customers:write - Available in the SDK for PHP and Node.js
Creates the customer when the project has none by this external_user_id (201) and answers the one it has otherwise (200) — idempotent by nature, so no Idempotency-Key: a repeat, or two first calls at once, end with one customer. No credit account is created: a customer without accounts is a legal state, and the first top-up or account setting creates the account of its credit type. The body is an empty object; any field is 422.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Request body optional
{}- Maximum keys:
0
Responses
The customer already existed
{
"data": {
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}The customer was created
{
"data": {
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Delete an end customer without history
DELETE /v1/customers/{externalUserId}
- Scope:
customers:write - Available in the SDK for PHP and Node.js
Removes the customer and its credit accounts — only when nothing ever happened to them: every account at a zero balance with nothing reserved, and not one entry in its statement. Otherwise 409 customer_has_history: the journal is append-only, its entries explain the balance, so a customer with history is blocked (POST /v1/customers/{externalUserId}/block) rather than deleted. A delete that meets a top-up or a charge of the same customer under way waits for it, and then refuses; a top-up that meets a delete under way waits, and then creates the customer again. An external_user_id the project has never seen is 404 customer_not_found. The external_user_id is the caller's opaque identifier — not an email address or a name: erasing one with history is not offered. Audited.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Responses
The customer and its accounts are gone
No body
Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 409 | State conflict: idempotency clash, stale draft, illegal transition; the last owner of an organization ( |
Headers
X-Request-Id
The body of every error is the error envelope.
Set the settings of one credit account
PUT /v1/customers/{externalUserId}/accounts/{creditType}
- Scope:
customers:write - Available in the SDK for PHP and Node.js
The settings of the customer's account of this credit_type: whether it may go below zero (allow_negative) and the level under which a movement sends customer.balance_low (low_balance_threshold, 0 switches the warning off). Both fields are required — the body is the whole of the settings. Creates the customer and the account when they do not exist yet (201); otherwise changes the settings and answers 200. Not a movement of credits, so no Idempotency-Key: the same settings put twice are the same settings. allow_negative: false on an account already below zero is allowed — the guard holds from the next movement on. A blocked customer's settings can be changed.
This is the one place the settings change: a top-up or an adjustment that names either field is 422.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
creditType requiredpath | The credit type of the account — tenant-defined text,
|
Request body
{
"allow_negative": true,
"low_balance_threshold": "12.5000"
}Responses
The settings were changed
{
"data": {
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}The account was created with these settings
{
"data": {
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Block an end customer
POST /v1/customers/{externalUserId}/block
- Scope:
customers:write - Available in the SDK for PHP and Node.js
A blocked customer's new runs are refused 403 customer_blocked before anything is created (POST /v1/operations/execute with its context.user_id), and a run of theirs already under way is refused at its next hold of credits. The balance can still be moved — top-up, charge, adjustment and the account settings stay open, so the customer can be settled with — and the statement still reads. blocked_at is set the first time; blocking again answers 200 without moving it, and replaces block_reason only when a reason is given. An external_user_id the project has never seen is 404 customer_not_found. Audited.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Request body optional
{
"reason": "string"
}Responses
The customer, blocked
{
"data": {
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Lift the block of an end customer
POST /v1/customers/{externalUserId}/unblock
- Scope:
customers:write - Available in the SDK for PHP and Node.js
The customer is active again: blocked_at and block_reason are cleared and new runs are accepted. Unblocking a customer that is not blocked answers 200 with the customer as it is. The body is empty; any field is 422. An external_user_id the project has never seen is 404 customer_not_found. Audited.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Request body optional
{}- Maximum keys:
0
Responses
The customer, active
{
"data": {
"customer_id": "74438200973594624",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": "2026-10-02T09:30:00Z",
"block_reason": "string",
"accounts": [
{
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
],
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
One end customer's credit statement
GET /v1/customers/{externalUserId}/entries
- Scope:
customers:read - Available in the SDK for PHP and Node.js
Every movement of the customer's credits, newest first, each carrying the balance it left behind — so a reader never has to replay the feed to know where the customer stood — and the note its caller gave. Cursor-paged, no date window: a statement is read from the top. An external_user_id the project has never seen is 404 customer_not_found; a customer without an account (or without one of the credit_type asked for) has an empty statement.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
cursor optionalquery | The |
limit optionalquery | Page size (default 30).
|
credit_type optionalquery | Narrows a customer who holds several pots down to one.
|
entry_type optionalquery | Only the movements of this type — the top-ups, say, or the charges.
|
Responses
One cursor page of credit movements
{
"data": [
{
"entry_id": "74438200973594624",
"customer_account_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"entry_type": "hold",
"credits": "12.5000",
"balance_after": "12.5000",
"available_after": "12.5000",
"reservation_id": "74438200973594624",
"note": "string",
"created_at": "2026-10-02T09:30:00Z"
}
],
"meta": {
"limit": 1,
"next_cursor": "string"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Add credits to an end customer
POST /v1/customers/{externalUserId}/topup
- Scope:
customers:write - Idempotency-Key required
- Available in the SDK for PHP and Node.js
Creates the account when it does not exist yet. Moves money, so it is behind the idempotency middleware.
The Idempotency-Key is also written on the journal entry, where it is unique within the project for as long as the entry exists. A key that already names this top-up — same customer, same credit_type, same credits — answers 200 with the account as it stands now and credits nothing, however long ago the first call was and even while it is still running. A key that names any other top-up, adjustment or charge of the project answers 409 idempotency_conflict and moves nothing. The note does not take part in that comparison; while the stored answer of the first call lasts, any other body under its key — another note included — is 409 idempotency_conflict as for every financial write.
An account the top-up creates starts with allow_negative: false and no low-balance warning. The settings are PUT /v1/customers/{externalUserId}/accounts/{creditType}'s: a body that names allow_negative or low_balance_threshold is 422, as is any field this body does not describe.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Idempotency-Key requiredheader | Replay guard for a financial write. The same key returns the original result with the original status and never charges twice; a different body under a used key answers
|
Request body
{
"credits": "12.5000",
"credit_type": "default",
"note": "string"
}Responses
The account after the top-up
{
"data": {
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 400 | Malformed request — e.g. the | |
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 409 | State conflict: idempotency clash, stale draft, illegal transition; the last owner of an organization ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Correct an end customer's balance
POST /v1/customers/{externalUserId}/adjust
- Scope:
customers:write - Idempotency-Key required
- Available in the SDK for PHP and Node.js
Signed: an adjustment may take credits away. Zero is refused — it would be an audit row saying nothing.
The Idempotency-Key is unique within the project on the journal entry, exactly as for a top-up: repeating the same adjustment under its key answers 200 with the account as it stands now and moves nothing — also when the balance could no longer cover it a second time — and a key that names any other top-up, adjustment or charge answers 409 idempotency_conflict. The note takes no part in that comparison, exactly as for a top-up.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Idempotency-Key requiredheader | Replay guard for a financial write. The same key returns the original result with the original status and never charges twice; a different body under a used key answers
|
Request body
{
"credits": "12.5000",
"credit_type": "default",
"note": "string"
}Responses
The account after the adjustment
{
"data": {
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 400 | Malformed request — e.g. the | |
| 401 | No credential, or one that is not valid | |
| 402 | Refused because of money: | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 409 | State conflict: idempotency clash, stale draft, illegal transition; the last owner of an organization ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Take credits from an end customer for a service of the application
POST /v1/customers/{externalUserId}/charge
- Scope:
customers:write - Idempotency-Key required
- Available in the SDK for PHP and Node.js
The application's own charge — a PDF export, a seat, anything it sells outside an operation. credits is the positive amount to take; the journal entry is a charge with the amount negative and the note the caller gave. Guarded like every debit: an account with allow_negative off cannot go below what is held on it (402 insufficient_credits). A charge is not spend: the margin report sets credits against provider dollars, and no provider stands behind it. Crossing the account's low_balance_threshold sends customer.balance_low like any movement.
An external_user_id the project has never seen is 404 customer_not_found — nothing is created; a known customer without an account of the credit_type holds no such credits: 402 insufficient_credits. A blocked customer can be charged. Moves money, so it is behind the idempotency middleware, and the Idempotency-Key is unique within the project on the journal entry exactly as for a top-up: the same charge repeated under its key answers 200 with the account as it stands now and takes nothing — also when the balance could no longer cover it a second time — and a key that names any other top-up, adjustment or charge answers 409 idempotency_conflict.
Parameters
| Parameter | Description |
|---|---|
externalUserId requiredpath | The end customer's identifier in the caller's own system.
|
Idempotency-Key requiredheader | Replay guard for a financial write. The same key returns the original result with the original status and never charges twice; a different body under a used key answers
|
Request body
{
"credits": "12.5000",
"credit_type": "default",
"note": "string"
}Responses
The account after the charge
{
"data": {
"customer_account_id": "74438200973594624",
"customer_id": "74438200973594624",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "12.5000",
"reserved": "12.5000",
"available": "12.5000",
"allow_negative": true,
"low_balance_threshold": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 400 | Malformed request — e.g. the | |
| 401 | No credential, or one that is not valid | |
| 402 | Refused because of money: | |
| 403 | Authenticated, but not allowed: scope ( | |
| 404 | No such resource for this tenant. Somebody else's resource — and a resource of a project the caller does not reach — answers the same way on purpose: the two must not be distinguishable. | |
| 409 | State conflict: idempotency clash, stale draft, illegal transition; the last owner of an organization ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
What an operation costs an end customer in credits
GET /v1/credit-prices
- Scope:
customers:read - Available in the SDK for PHP and Node.js
The project's price list: how many credits of which type one run of an operation holds from the customer named by context.user_id. An operation without a price does not touch credits at all. A handful of rows per project, so no cursor; the filters are equality.
Parameters
| Parameter | Description |
|---|---|
operation_code optionalquery | Only the prices of this operation code (equality).
|
credit_type optionalquery | Only the prices in this credit type (equality).
|
Responses
Credit prices of the project, by operation code and credit type
{
"data": [
{
"credit_price_id": "74438200973594624",
"project_id": "74438200973594624",
"operation_code": "generate_email",
"credit_type": "default",
"credits": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
]
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Set the credit price of one operation
PUT /v1/credit-prices
- Scope:
management:write
Upsert by (operation_code, credit_type). Zero is allowed — it marks an operation free without deleting the row. A new price takes effect on the next hold; holds already taken keep theirs.
Request body
{
"operation_code": "generate_email",
"credits": "12.5000",
"credit_type": "default"
}Responses
The stored price
{
"data": {
"credit_price_id": "74438200973594624",
"project_id": "74438200973594624",
"operation_code": "generate_email",
"credit_type": "default",
"credits": "12.5000",
"created_at": "2026-10-02T09:30:00Z",
"updated_at": "2026-10-02T09:30:00Z"
}
}Errors
| Status | Codes | When |
|---|---|---|
| 401 | No credential, or one that is not valid | |
| 403 | Authenticated, but not allowed: scope ( | |
| 422 | Validation failure or a semantically impossible request. Field-level validation from the framework answers |
Headers
X-Request-Id
The body of every error is the error envelope.
Examples
Find the customers who run low
low_balance keeps the customers with an account that has fewer than 20 credits available. This customer has 18 on the balance, and 4 of them are held.
curl "https://api.slen.ai/api/v1/customers?low_balance=20" \
-H "Authorization: Bearer $SLEN_API_KEY"{
"data": [
{
"customer_id": "74439840149372928",
"external_user_id": "user_3107",
"status": "active",
"blocked_at": null,
"block_reason": null,
"accounts": [
{
"customer_account_id": "74439840153567232",
"customer_id": "74439840149372928",
"project_id": "74438200973594624",
"external_user_id": "user_3107",
"credit_type": "default",
"balance": "18.0000",
"reserved": "4.0000",
"available": "14.0000",
"allow_negative": false,
"low_balance_threshold": "0.0000",
"created_at": "2026-09-28T11:02:17+00:00",
"updated_at": "2026-10-02T08:39:51+00:00"
}
],
"created_at": "2026-09-28T11:02:17+00:00",
"updated_at": "2026-10-02T08:39:51+00:00"
}
],
"meta": {
"limit": 50,
"next_cursor": null
}
}Read a customer
The customer has one account, of the credit type default. A movement that takes its available below 10 sends customer.balance_low.
curl https://api.slen.ai/api/v1/customers/user_8421 \
-H "Authorization: Bearer $SLEN_API_KEY"{
"data": {
"customer_id": "74439117522108416",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": null,
"block_reason": null,
"accounts": [
{
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "82.5000",
"reserved": "0.0000",
"available": "82.5000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T09:20:05+00:00"
}
],
"created_at": "2026-10-02T08:40:00+00:00",
"updated_at": "2026-10-02T08:40:00+00:00"
}
}Add a customer
The body is empty. The customer is new, so the answer is 201, and accounts is empty until a top-up or the settings create one.
curl -X PUT https://api.slen.ai/api/v1/customers/user_8421 \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"data": {
"customer_id": "74439117522108416",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": null,
"block_reason": null,
"accounts": [],
"created_at": "2026-10-02T08:40:00+00:00",
"updated_at": "2026-10-02T08:40:00+00:00"
}
}Delete a customer
This customer was added and never topped up, so the customer goes. The answer has no body.
curl -X DELETE https://api.slen.ai/api/v1/customers/user_9050 \
-H "Authorization: Bearer $SLEN_API_KEY"204 No Content
Set up an account
The settings create the account of the credit type default with a warning below 10 credits. The account cannot go below zero.
curl -X PUT https://api.slen.ai/api/v1/customers/user_8421/accounts/default \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"allow_negative": false,
"low_balance_threshold": "10"
}'{
"data": {
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "0.0000",
"reserved": "0.0000",
"available": "0.0000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T08:40:30+00:00"
}
}Block a customer
The reason is shown as block_reason. The accounts keep their credits.
curl https://api.slen.ai/api/v1/customers/user_8421/block \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Chargeback on invoice 1042"
}'{
"data": {
"customer_id": "74439117522108416",
"external_user_id": "user_8421",
"status": "blocked",
"blocked_at": "2026-10-02T09:31:10+00:00",
"block_reason": "Chargeback on invoice 1042",
"accounts": [
{
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "82.5000",
"reserved": "0.0000",
"available": "82.5000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T09:20:05+00:00"
}
],
"created_at": "2026-10-02T08:40:00+00:00",
"updated_at": "2026-10-02T09:31:10+00:00"
}
}Unblock a customer
The request has no body. The customer is active again, and blocked_at and block_reason are cleared.
curl -X POST https://api.slen.ai/api/v1/customers/user_8421/unblock \
-H "Authorization: Bearer $SLEN_API_KEY"{
"data": {
"customer_id": "74439117522108416",
"external_user_id": "user_8421",
"status": "active",
"blocked_at": null,
"block_reason": null,
"accounts": [
{
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "82.5000",
"reserved": "0.0000",
"available": "82.5000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T09:20:05+00:00"
}
],
"created_at": "2026-10-02T08:40:00+00:00",
"updated_at": "2026-10-02T10:02:00+00:00"
}
}Read a customer's statement
The answer holds three movements, newest first: a charge for an export, an adjustment that took 12.5 credits, and the top-up before them.
curl https://api.slen.ai/api/v1/customers/user_8421/entries \
-H "Authorization: Bearer $SLEN_API_KEY"{
"data": [
{
"entry_id": "74439673397166080",
"customer_account_id": "74439117526302720",
"external_user_id": "user_8421",
"credit_type": "default",
"entry_type": "charge",
"credits": "-5.0000",
"balance_after": "82.5000",
"available_after": "82.5000",
"reservation_id": null,
"note": "PDF export 311",
"created_at": "2026-10-02T09:20:05+00:00"
},
{
"entry_id": "74439652312547328",
"customer_account_id": "74439117526302720",
"external_user_id": "user_8421",
"credit_type": "default",
"entry_type": "adjust",
"credits": "-12.5000",
"balance_after": "87.5000",
"available_after": "87.5000",
"reservation_id": null,
"note": null,
"created_at": "2026-10-02T09:15:42+00:00"
},
{
"entry_id": "74439117530497024",
"customer_account_id": "74439117526302720",
"external_user_id": "user_8421",
"credit_type": "default",
"entry_type": "topup",
"credits": "100.0000",
"balance_after": "100.0000",
"available_after": "100.0000",
"reservation_id": null,
"note": "Invoice 1042",
"created_at": "2026-10-02T08:41:00+00:00"
}
],
"meta": {
"limit": 30,
"next_cursor": null
}
}Top up a customer
The key names this top-up, and a repeat under the same key adds nothing. The note names the invoice it was paid by.
curl https://api.slen.ai/api/v1/customers/user_8421/topup \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Idempotency-Key: topup-user_8421-invoice-1042" \
-H "Content-Type: application/json" \
-d '{
"credits": "100",
"note": "Invoice 1042"
}'{
"data": {
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "100.0000",
"reserved": "0.0000",
"available": "100.0000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T08:41:00+00:00"
}
}Adjust a balance
A negative amount takes credits away.
curl https://api.slen.ai/api/v1/customers/user_8421/adjust \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Idempotency-Key: adjust-user_8421-refund-77" \
-H "Content-Type: application/json" \
-d '{
"credits": "-12.5"
}'{
"data": {
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "87.5000",
"reserved": "0.0000",
"available": "87.5000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T09:15:42+00:00"
}
}Charge a customer
Your application takes 5 credits for an export it made. The key names the export, so a repeat takes nothing more.
curl https://api.slen.ai/api/v1/customers/user_8421/charge \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Idempotency-Key: charge-user_8421-export-311" \
-H "Content-Type: application/json" \
-d '{
"credits": "5",
"note": "PDF export 311"
}'{
"data": {
"customer_account_id": "74439117526302720",
"customer_id": "74439117522108416",
"project_id": "74438200973594624",
"external_user_id": "user_8421",
"credit_type": "default",
"balance": "82.5000",
"reserved": "0.0000",
"available": "82.5000",
"allow_negative": false,
"low_balance_threshold": "10.0000",
"created_at": "2026-10-02T08:40:30+00:00",
"updated_at": "2026-10-02T09:20:05+00:00"
}
}List the credit prices
The project has one price.
curl https://api.slen.ai/api/v1/credit-prices \
-H "Authorization: Bearer $SLEN_API_KEY"{
"data": [
{
"credit_price_id": "74440031913676800",
"project_id": "74438200973594624",
"operation_code": "generate_email",
"credit_type": "default",
"credits": "2.0000",
"created_at": "2026-10-02T08:30:00+00:00",
"updated_at": "2026-10-02T08:30:00+00:00"
}
]
}Set a credit price
From the next hold on, one model call of the operation costs a customer two credits.
curl -X PUT https://api.slen.ai/api/v1/credit-prices \
-H "Authorization: Bearer $SLEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"operation_code": "generate_email",
"credits": "2"
}'{
"data": {
"credit_price_id": "74440031913676800",
"project_id": "74438200973594624",
"operation_code": "generate_email",
"credit_type": "default",
"credits": "2.0000",
"created_at": "2026-10-02T08:30:00+00:00",
"updated_at": "2026-10-02T08:30:00+00:00"
}
}Where to go next
Customer credits — the same customers in the console.
Customers and credits — the same operations from the SDK.
Analytics — the credits your customers were charged, against what their calls cost.
Authentication — the scopes a key needs to read and to change customers.
Overview — how credits are written in a request and in an answer.
Error codes — every code of the API, what it means and what to do about it.
Webhooks — how
customer.balance_lowarrives.
Built from openapi b070e1f, sdk contract 6a2e0e0, php 4089db3, node 0fa87be