All endpoints are prefixed with /api/v1. This is the JSON API surface — it exists
alongside (and, where equivalent, is backed by the same services as) the server-rendered
/ui/* web app, but the two are separate: the web app uses cookie sessions, the API
below uses JWT bearer tokens.
SDKs: Python (openclear-sdk) and TypeScript (@openclear/sdk), Apache-2.0. Both are generated from the contract below, with a hand-written layer for tokens, retries with idempotency keys, paging, bulk, the decision feed and webhook verification.
The full machine-readable contract is openapi/openclear-v1.json
(OpenAPI 3.1), regenerated with python scripts/export_openapi.py. /api/v1 only changes
additively: endpoints, optional parameters and response fields can be added, but nothing
is removed, renamed or made newly required. tests/test_openapi_contract.py enforces
that on every test run.
Authentication
Login
POST /api/v1/auth/login
Body (LoginRequest):
| field | type | notes |
|---|---|---|
tenant_slug |
string | which organization to log into |
email |
string | |
password |
string |
Response (LoginResponse):
{
"mfa_required": false,
"access_token": "...",
"refresh_token": "...",
"mfa_token": null,
"token_type": "bearer"
}
If the account has a registered WebAuthn credential, mfa_required is true and
access_token/refresh_token are withheld — only mfa_token is returned. mfa_token
carries no permissions and can only be used against the two /auth/webauthn/login/*
endpoints below to complete the second factor.
Refresh
POST /api/v1/auth/refresh
Body: {"refresh_token": "..."}. Returns a new TokenResponse (access_token +
refresh_token). The new tokens always reflect the membership's current security
group and active status — not whatever was true when the refresh token was issued — so a
permission change or deactivation takes effect the next time a client refreshes.
A refresh token works once. Keep the refresh_token each refresh returns and use that next
time. Presenting a used one again within a minute is allowed (two requests refreshing together);
after that it's taken as a copy, the whole session is ended (the newer tokens stop working too),
and the bank's security team sees auth.session.reuse_detected. Sign in again to start a new one.
API clients (for a bank's systems)
A bank's own systems (its core, its online banking provider, its middleware) sign in with OAuth 2.0 client credentials instead of a person's password. A bank admin creates an API client under Admin → API clients, choosing a security group (what it may do) and optionally a customer (whose data it may see), and copies the secret, shown once.
POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=occ_...&client_secret=ocs_...
The id and secret can also go in HTTP Basic auth.
Signing in with a key pair instead (private_key_jwt, RFC 7523). Under Admin → API
clients, Use a key pair registers the client's public key (RSA of 2048 bits or more, or
EC P-256/P-384, as PEM); from then on its secret no longer works. The client signs a short
JWT with its private key, which never leaves its systems, and sends it instead of a secret:
POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<JWT>
The JWT is signed with RS256/384/512, PS256/384/512 or ES256/384 and carries iss and sub
(the client id), aud (the token endpoint's URL: OPENCLEAR_PUBLIC_BASE_URL +
/api/v1/oauth/token, or the address the request arrived at), iat, exp (at most five
minutes after iat) and a unique jti, which is accepted once. Anything else is
401 invalid_client. Use a secret instead goes back, with a new secret shown once.
The response is
{"access_token": "...", "token_type": "Bearer", "expires_in": 1800}
(OPENCLEAR_API_CLIENT_TOKEN_MINUTES, default 30); request a new one when it expires.
Errors follow RFC 6749: 401 {"error": "invalid_client"} for an unknown client, a wrong
secret or a revoked client, and 400 {"error": "unsupported_grant_type"}. The endpoint is
rate-limited per address (OPENCLEAR_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE, 30).
Per-account access. A customer's person (or a customer's API client) sees all of the
customer's accounts until the bank limits them to some: Users → Edit → Choose accounts,
or PUT /api/v1/users/{membership_id}/accounts with {"all_accounts": false, "account_ids":
[...]} (GET the same path to read it; GET /api/v1/users shows each membership's
all_accounts and account_ids). Limited, every endpoint returns only those accounts and
what's on them, an item on another account is 404, and their bulk uploads can add items
only to those accounts.
Customer self-service. GET/PUT /api/v1/customers/{customer_number}/self-service
with {"manages_people": true, "manages_sign_on": false, "manages_api_clients": false} lets a
customer's own administrators manage its people, sign-on and API clients (or stops them at
once); manages_api_clients left out of a PUT stays as it is. The answer also lists its
administrators. The customer upsert (PUT /api/v1/customers/{customer_number}) never
changes these, so a core's sync leaves them alone; customer lists show them as
self_manages_users, self_manages_sso and self_manages_api_clients.
A client acts through its own service identity, so the token works on every endpoint its security group allows, limited to its customer if it has one, and everything it does is in the audit log under its name. Revoking it takes effect on its next request; rotating its secret stops the old secret from getting new tokens. Nobody can sign in as a client's service identity, by password or SSO.
Acting for a user. An online banking provider's server can act for the person signed in to it, so a decision or recommendation is theirs and dual control still sees who made it. The bank turns this on per client ("may act for your users"). The client then sends:
Authorization: Bearer <the client's token>
OpenClear-Act-As: pat@acme.example.com
OpenClear-Act-As-Customer: ACME # only if that person belongs to several customers
- The person must already be an active member of the bank (and of the client's customer, if the client has one).
- The request gets only the permissions both the client and the person hold.
- The action's audit entry names the person, and every change made this way also records
an
api_client.act_asentry naming the client. - Without the bank's permission, or for anyone else, the answer is
403. OpenClear-Act-As-Subject: <id>names the person by the online banking provider's own id for them (its OIDCsub) instead of their email, for a client linked to that provider's sign-on connection. The person must have signed in, or been exchanged (below), at least once.
Token exchange (RFC 8693). An online banking provider whose people sign in with it (the bank sets up its OIDC connection with a customer claim, so each person belongs to a business customer) can trade the person's ID token for an OpenClear token that acts for them. This also creates the person on first use, with the connection's group mappings:
POST /api/v1/oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=occ_...&client_secret=...
subject_token=<the person's ID token from the provider>
subject_token_type=urn:ietf:params:oauth:token-type:id_token
The answer is {"access_token", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer", "expires_in"}.
- The token expires no later than the ID token does.
- What it allows is re-checked on every request, as with
OpenClear-Act-As. - The client must be allowed to act for users and be linked to the provider's connection (Admin → API clients).
- A refused exchange answers
400with an RFC 6749 error: invalid_grant: a bad or expired ID token, or a person with no mapped group or customer;unauthorized_client: a client that isn't set up for exchange;invalid_request: a malformed request.
| method | path | permission | notes |
|---|---|---|---|
| GET | /api/v1/api-clients |
api_client:manage, or a customer's customer_api_client:manage |
the API clients, for access reviews: name, client id, security group, customer, managed_by (customer for one a customer made for its own systems), accounts, created by, last used, rotated, revoked. Never a secret. A customer's own session sees only the clients it made |
Using the access token
Every other endpoint requires:
Authorization: Bearer <access_token>
A token encodes tenant_id, security_group_id, and (if the membership is
customer-scoped) customer_id. Permissions are not baked into the token — they're
re-resolved from the security group's current permission set on every request, so
editing a group or deactivating a user/membership takes effect immediately, without
waiting for the token to expire. See Permissions below.
WebAuthn / FIDO2 (second factor)
All under /api/v1/auth/webauthn, and (except the two /login/* endpoints) require a
normal access token:
| method | path | auth | purpose |
|---|---|---|---|
| POST | /register/options |
access token | begin registering a new credential for the current user |
| POST | /register/verify |
access token | complete registration; body: {"credential": {...}, "nickname": "..."} (raw navigator.credentials.create() response) → 201 + WebauthnCredentialRead |
| GET | /credentials |
access token | list the current user's registered credentials |
| DELETE | /credentials/{credential_id} |
access token | remove a credential → 204 |
| POST | /login/options |
mfa_token |
begin the second factor after a password check that returned mfa_required: true |
| POST | /login/verify |
mfa_token |
complete it; body: {"credential": {...}} (raw navigator.credentials.get() response) → TokenResponse with real access/refresh tokens |
WebauthnCredentialRead: id, nickname, aaguid, created_at, last_used_at.
Webhooks
OpenClear can tell your systems the moment something happens, instead of their polling.
Set endpoints up under Admin → Webhooks (api_client:manage): a URL, the events it
wants, and a signing secret (whsec_..., shown once).
| Event | When | data |
|---|---|---|
exception.created |
a presented check or ACH debit raised an exception | exception_id, network, customer_id |
exception.decided |
an exception was decided, by a person or automatically | exception_id, decision_id, sequence (its place in the decision feed), outcome |
bulk_upload.completed |
a bulk upload finished, or failed | upload_id, status, succeeded, failed |
webhook.test |
the "Send a test event" button | endpoint_id |
Each message is a JSON POST in the Standard Webhooks
format:
{"type": "exception.decided", "timestamp": "2026-10-09T14:03:11+00:00",
"data": {"exception_id": "...", "decision_id": "...", "sequence": 1042, "outcome": "return"}}
Payloads carry ids, not data: fetch the details through the API with your own credentials, so a misconfigured endpoint can't leak anything. Headers:
webhook-id: the message id, the same on every retry. Use it to ignore duplicates.webhook-timestamp: Unix seconds. Reject messages more than a few minutes old.webhook-signature:v1,<signature>, a base64 HMAC-SHA256, keyed with the secret (base64-decoded, after thewhsec_prefix), of{webhook-id}.{webhook-timestamp}.{body}.
import base64, hashlib, hmac
def verify(secret: str, headers: dict, body: bytes) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split())
Any 2xx response within 10 seconds (OPENCLEAR_WEBHOOK_TIMEOUT_SECONDS) counts as
delivered. Anything else is retried after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h,
then marked failed. The endpoint's page lists recent deliveries, and can send a failed
one again. An event is written in the same transaction as what caused it, so it's sent
only for a change that was saved, and isn't lost if a server stops before sending it.
The URL must be public https, and it's checked again when connecting, so a name that
resolves to a private address is refused and redirects aren't followed. A single-bank
install whose receivers are on its own network can turn on
OPENCLEAR_WEBHOOK_ALLOW_PRIVATE_HOSTS. Leave it off when several banks share an
installation.
Retrying writes safely: Idempotency-Key
Send Idempotency-Key: <a unique value of your own> (up to 255 characters, e.g. a UUID)
on any POST, PUT, PATCH or DELETE, and a retry after a timeout or dropped connection
can't do the work twice:
- the first request runs, and its response is kept against the key for 24 hours
(
OPENCLEAR_IDEMPOTENCY_KEY_HOURS); - a retry with the same key and the same request gets that response again, with the header
Idempotency-Replayed: true. Error responses (4xx) are replayed too; - the same key with a different method, path, query or body:
422; - a retry while the first is still running:
409, so try again shortly; - a server error (5xx) isn't kept, so a retry runs again.
Keys belong to the caller (the person, or the API client) within its bank, so two callers can't collide. Requests without the header behave as before.
Paging list endpoints
GET /exceptions, /paid-items, /issued-items, /stop-payments, /ach-transactions
and /ach-authorizations return one page of rows, oldest first:
| query parameter | default | notes |
|---|---|---|
limit |
500 | rows per page, at most 1,000 (more is a 422) |
offset |
0 | rows to skip |
The body is still a plain JSON list. Two response headers describe the rest:
X-Total-Count: how many rows match your filters in total.Link: <...>; rel="next": the URL of the next page, with your filters kept. It's absent on the last page.
To read everything, follow Link until it's gone. Changed in 1.6.0:
these endpoints used to return every row in one response. A client that expects that
still works for up to 500 rows; beyond that it must follow Link.
Permissions
Every endpoint below is gated on one permission key from this catalog, checked against the caller's current security group. There is no per-user permission override — a membership always has exactly one security group governing what it can do, plus an optional customer scope governing whose data it can see (see Multi-tenancy and customer scoping).
| permission | grants |
|---|---|
account:read / account:write |
view / create accounts |
issued_item:read / issued_item:write |
view / create & void issued items |
stop_payment:read / stop_payment:write |
view / create & cancel stop payments |
paid_item:read / paid_item:write |
view / submit paid items |
check_image:read / check_image:write |
view / upload & reprocess check images |
ach_authorization:read / ach_authorization:write |
view / create & revoke ACH authorizations |
ach_transaction:read / ach_transaction:write |
view / submit & bulk-load ACH transactions |
exception:read |
view exceptions |
exception:recommend |
submit a maker recommendation (pay/return) |
exception:decide |
finalize a pay/return decision (checker) |
admin:manage |
manage ML models and payment networks |
user:manage |
manage users (list, add, edit entitlements, deactivate) |
security_group:manage |
manage security groups |
tenant:manage |
manage organization branding/settings |
audit_log:read |
view the immutable action log |
customer:manage |
manage customers |
api_client:manage |
create, rotate and revoke API clients for the bank's systems (bank-wide only) |
billing:read |
view billing statements and download the billing exports (a customer-scoped user sees only its own statement) |
billing:manage |
manage the fee schedule, billing settings and customer billing terms, and finalize a month |
A missing permission returns 403 Forbidden with {"detail": "Missing permission: <key>"}.
An expired token returns 401 with "Token expired"; any other invalid/malformed token,
or a token whose user/membership/security group has since been deactivated or deleted,
returns 401 with "Invalid token" — these are indistinguishable from the outside, by
design, so a token can't be used to probe why access was revoked.
Multi-tenancy and customer scoping
A membership is either bank-wide (customer_id is null on the token — sees every
customer's data in the tenant) or scoped to one customer (customer_id set — every
list/get endpoint below transparently filters to that customer only, and creates are
rejected with 404 if they'd reference a different customer's account).
Customer-scoped tokens additionally have a fixed set of bank-staff permissions masked out
regardless of what their security group nominally grants: user:manage,
security_group:manage, tenant:manage, customer:manage, admin:manage,
audit_log:read. These are always bank-wide-only concerns.
Resource endpoints
Every write endpoint (POST/PATCH) records an entry in the tenant's tamper-evident
audit log (channel="api") — this is automatic and not a separate call you need to make.
Issued items — /api/v1/issued-items
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | issued_item:write |
body: IssuedItemCreate → 201 + IssuedItemRead |
| POST | /bulk |
issued_item:write |
body: JSON array of IssuedItemCreate → BulkSubmitResponse (see Bulk uploads) |
| GET | `` | issued_item:read |
query: status_filter, account_id |
| GET | /outstanding |
issued_item:read |
shortcut for status_filter=outstanding |
| GET | /{item_id} |
issued_item:read |
|
| PATCH | /{item_id}/void |
issued_item:write |
body: {"reason": "..."} |
IssuedItemCreate: account_id, check_number, amount, payee_name, issue_date.
IssuedItemRead adds: id, status (outstanding / paid / voided / stopped /
stale), void_reason, created_at.
Stop payments — /api/v1/stop-payments
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | stop_payment:write |
body: StopPaymentCreate → 201 + StopPaymentRead |
| GET | `` | stop_payment:read |
query: status_filter |
| GET | /outstanding |
stop_payment:read |
shortcut for status_filter=active |
| PATCH | /{stop_id}/cancel |
stop_payment:write |
StopPaymentCreate: account_id, check_number, amount (optional), effective_date,
expiration_date (optional), reason (optional).
StopPaymentRead adds: id, status (active / expired / cancelled), created_at.
No bulk endpoint exists for stop payments.
Paid items — /api/v1/paid-items
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | paid_item:write |
body: PaidItemCreate → 201 + PaidItemRead |
| POST | /bulk |
paid_item:write |
body: JSON array of PaidItemCreate → BulkSubmitResponse |
| GET | `` | paid_item:read |
query: account_id |
| GET | /{item_id} |
paid_item:read |
PaidItemCreate: account_id, check_number, presented_amount, presented_date.
PaidItemRead adds: id, source (api, bulk_file, manual, teller: cashed at a branch), matched_issued_item_id, match_status (pending /
matched / exception), settlement_status (pending / paid / returned),
created_at. Submitting a paid item automatically attempts to match it against an
outstanding issued item; a non-match becomes an exception item (see
Exceptions and decisions).
Teller verification — /api/v1/checks
For a teller platform's API client, in the bank's Teller platform security group
(check:verify, which no person holds by default and which does nothing else). See
docs/guides/teller.md.
| method | path | notes |
|---|---|---|
| POST | /verify |
routing_number, account_number, check_number, amount, payee_name (as written on the check; required when the customer gave a payee), teller_id, branch → verification_id, result, problems, expires_at, issued (what was issued, only to explain a decline). result is match, or the first of stopped, already_paid, voided, not_issued, stop_differs, amount_differs, payee_differs, stale, customer_review (a reverse positive pay account), payee_needed (the customer gave a payee and none was sent), not_on_positive_pay (an account not enrolled: unknown, or its customer deactivated). Pay out only on match; not_on_positive_pay is cashed by the bank's standard procedure, and anything else is declined |
| POST | /cashed |
verification_id, amount (what was paid out) → 201 with paid_item_id and exception_types (empty when it cleared as paid). The check is presented at once as a paid item with source teller, so the same check presented anywhere later is a duplicate. 409 for a declined, expired (15 minutes) or already-recorded verification, or another amount; 404 for an unknown one |
Check images — /api/v1/check-images
The one multipart/file endpoint in the API.
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | check_image:write |
multipart form: front_image (required file), back_image (optional file), paid_item_id (optional query/form param) → 201 + CheckImageRead |
| GET | /{check_image_id} |
check_image:read |
|
| POST | /{check_image_id}/reprocess |
check_image:write |
resets ocr_status to pending and re-queues OCR |
CheckImageRead: id, paid_item_id, ocr_status (pending / completed /
failed), ocr_extracted_amount, ocr_extracted_payee, ocr_confidence,
ocr_provider, created_at, processed_at. OCR runs as a background job after the
request returns — poll GET /{check_image_id} until ocr_status is no longer pending.
ACH authorizations — /api/v1/ach/authorizations
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | ach_authorization:write |
body: AchAuthorizationCreate → 201 + AchAuthorizationRead |
| GET | `` | ach_authorization:read |
query: status_filter |
| PATCH | /{rule_id}/revoke |
ach_authorization:write |
AchAuthorizationCreate: account_id, originator_id, originator_name, receiver_id
(optional — null means a blanket authorization for any receiver from this originator),
max_amount (optional), frequency_limit (optional), allowed_sec_codes (optional list
of strings), effective_date, expiration_date (optional).
AchAuthorizationRead adds: id, status (active / revoked), created_at.
No bulk endpoint exists for ACH authorizations.
ACH transactions — /api/v1/ach/transactions
| method | path | permission | notes |
|---|---|---|---|
| POST | `` | ach_transaction:write |
body: AchTransactionCreate → 201 + AchTransactionRead |
| POST | /bulk |
ach_transaction:write |
body: JSON array of AchTransactionCreate → BulkSubmitResponse |
| GET | `` | ach_transaction:read |
query: account_id |
| GET | /{transaction_id} |
ach_transaction:read |
AchTransactionCreate: account_id, originator_id, originator_name, receiver_id
(optional), amount, transaction_type (debit / credit), sec_code,
trace_number, effective_date.
AchTransactionRead adds: id, match_status (pending / matched / exception),
settlement_status (pending / paid / returned), created_at. Matched against
active ACH authorizations the same way paid items are matched against issued items — a
non-match becomes an exception item.
Exceptions and decisions — /api/v1/exceptions
| method | path | permission | notes |
|---|---|---|---|
| GET | `` | exception:read |
query: network_code, status_filter, customer_number, paging, updated_since |
| GET | /{exception_id} |
exception:read |
|
| POST | /{exception_id}/recommend |
exception:recommend |
under dual control, the maker's step — body: RecommendRequest |
| POST | /{exception_id}/decide |
exception:decide |
body: DecideRequest |
| GET | /{exception_id}/decision |
exception:read |
the decision, 404 if none yet |
| PUT | /{exception_id}/decision |
exception:decide (exception:recommend to propose under dual control) |
change it before the cut-off; 409 once final |
| DELETE | /{exception_id}/decision |
exception:decide |
take it back before the cut-off (not under dual control) |
| GET | /{exception_id}/decision/history |
exception:read |
every earlier version, oldest first |
customer_number narrows a bank-wide caller to one customer; a customer's own caller only ever
sees its own, and naming another answers 403.
ExceptionRead: id, network_code, customer_id, source_item_id, related_reference_id,
exception_types (list of strings), status (open / pending_approval / pay / return /
withdrawn), ml_score, ml_model_version, decision_deadline, decision_final,
changeable_until, created_at, updated_at. Act only on a final decision
(decision_final), or take decisions from the feed, webhooks or decision files, which carry
only final ones.
RecommendRequest and DecideRequest: outcome (pay / return), reason_code, notes,
ach_return_reason_id (required to return an ACH debit), check_return_reason_id (optional, for
a check: the bank's reason instead of free text). The ids come from the return reasons below.
DecisionRead: id, exception_item_id, outcome, reason_code, return_transaction_code,
notes, submitted_by_user_id (the maker, if any), decided_by_user_id (none for an automatic
default), decided_at, locked_at (when it became final), changeable_until, change_pending.
Errors:
| condition | status |
|---|---|
| exception not found | 404 |
| already decided (including by someone else a moment before) | 409 |
/decide before a recommendation, under dual control |
409 |
| the maker approving their own recommendation | 403 |
| no return reason, or one that's inactive | 422 |
| changing a decision after its cut-off | 409, with when it became final |
Return reasons — /api/v1/return-reasons
| method | path | permission | notes |
|---|---|---|---|
| GET | /check |
exception:read |
the bank's active check return reasons: id, reason_text, return_code |
| GET | /ach |
exception:read |
the bank's active ACH return reasons: id, reason_text, transaction_code |
Customers and accounts — /api/v1/customers, /api/v1/accounts
For a core or middleware keeping OpenClear in step, keyed by the bank's own numbers. A
PUT creates the record or updates it to match, so a nightly sync can send everything
without looking anything up first. It answers 201 when it created and 200 when it
updated. As with any PUT, fields left out are cleared. A bank whose core numbers
customers by CIF can use the CIF as the customer number.
| method | path | permission | notes |
|---|---|---|---|
| GET | /customers (?external_customer_id=) |
customer:manage |
paged |
| PUT | /customers/{customer_number} |
customer:manage |
name (required), is_active, external_customer_id, tax_id, contact and address fields, notes |
| GET | /accounts |
account:read |
paged; a customer-scoped caller sees its own |
| PUT | /accounts/{account_number} |
account:write |
name (required), customer_number (null for a house account), ach_debit_block_mode, external_account_id (the customer's own reference, matched in its files), check_review_mode (positive, or reverse positive pay: the customer reviews every check and unreturned ones are paid at the cut-off; the bank's admin:manage only, and while turning reverse on waits for a second admin the rest is saved and it answers 202 with {"status": "pending", "change_request_id"}). A customer-scoped caller can only create or change its own customer's accounts |
Every create and update is audit-logged.
Decision files — /api/v1/decision-batches
For a core that takes files rather than calling the feed. The bank turns on the outbox under
Settings → Decision files: a layout, how often, and the layout's settings. OpenClear then
writes each run's new decisions to a file and sends the decision_batch.ready webhook.
The built-in layouts:
openclear_csv;nacha_returns, a NACHA return file of the ACH debits returned, with R codes from the bank's return reasons, checked against Moov's validator;- any layout a plugin provides and the bank turned on.
These endpoints are for bank-wide callers only.
| method | path | notes |
|---|---|---|
| GET | ?unacknowledged_only=true |
the files, oldest first (from_sequence, to_sequence, decision_count, layout, filename, sha256, size, acknowledged_at); paged |
| POST | `` | write a file now ({"layout": ...} optional); null if there's nothing new |
| GET | /{id}, /{id}/file |
the batch, and its file as written (X-Content-SHA256) |
| POST | /{id}/acknowledge |
the core processed it. Moves the feed's outbox consumer forward, as far as acknowledged files are contiguous. Repeating it is harmless |
GET /api/v1/decisions/file?layout=nacha_returns uses the same saved settings.
Embedding — /api/v1/embed
| method | path | notes |
|---|---|---|
| POST | /launch |
{"parent_origin": "https://banking.example.com", "path": "/ui/exceptions"}. Answers 201 with {"url", "expires_in": 60}: a one-time link that opens path in a frame on parent_origin, as the person the call acts for. Only for an API client acting for someone (an exchanged token, OpenClear-Act-As or OpenClear-Act-As-Subject): 403 otherwise. 422 if the origin isn't on the bank's list (Settings → Embedding) or the path isn't a /ui/ page. See docs/guides/embedding.md |
Decision feed — /api/v1/decisions
Every pay or return decision, in order, with what a core needs to pay or return the item,
so it can act before the deadline without reading exceptions one by one. Each live
decision gets the bank's next sequence (1, 2, 3...) when it becomes final, at its cut-off
(within a minute of it), human or automatic.
Numbers become visible in order, so reading "everything after N" never skips one. Fraud
training examples aren't items and aren't in the feed. Needs exception:read; a
customer-scoped caller sees only its customer's decisions.
| method | path | notes |
|---|---|---|
| GET | ?after=N&limit=500 |
decisions with sequence > after, oldest first (limit up to 1,000). customer_number= limits a bank-wide caller to one customer; the sequence is still the bank's, so expect gaps. X-Latest-Sequence gives the newest; a full page has Link: rel="next". Keep the last sequence you processed and pass it as after |
| GET | /feed.csv?after=N |
the same as a CSV file (up to 10,000 rows), for a core that takes files |
| GET | /file?layout=openclear_csv&after=N |
the same in a decision file layout: openclear_csv, or one a plugin provides that the bank turned on (docs/PLUGINS.md). X-Last-Sequence is the last decision in the file |
| POST | /acknowledge |
{"consumer": "core", "through_sequence": N}: confirm a consumer processed through N. Only moves forward, never past the newest; bank-wide callers only; audit-logged |
| GET | /consumers |
each consumer's acknowledged position and how many decisions it's behind |
Each decision: sequence, decision_id, exception_id, network (check / ach),
outcome (pay / return), reason_code, return_transaction_code, source
(human, auto_default), decided_by (email, or null for automatic),
decided_at, account_number, customer_number. Plus, for a check: check_number,
amount, presented_date; for an ACH debit: trace_number, amount, effective_date,
originator_id, originator_name, sec_code.
A typical core job, every few minutes: read ?after=<last>, pay or return each item,
then acknowledge the last sequence it handled.
Admin — /api/v1/admin
All gated on admin:manage. Fraud scoring is advisory and the bank's own: a model trained only on
its own final decisions scores each exception once the bank turns scoring on (Settings → Fraud
scoring), and the score never decides an item.
| method | path | notes |
|---|---|---|
| GET | /payment-networks |
list registered network codes (list[str]) |
| POST | /ml/retrain?network_code=... |
retrain this organization's model → RetrainResponse; 409 while fraud scoring is off, if there isn't enough training data yet, or it was retrained moments ago. The new version is activated only if it scores at least as well as the active one on the same recent decisions and was trained on enough of the bank's own decisions (ml_bank_model_min_decisions, default 200); the reason is in metrics_json.evaluation.reason |
| GET | /ml/models |
this organization's model versions; optional ?network_code= filter |
| PATCH | /ml/models/{model_id}/activate |
activate one of this organization's models; 404 for any other. With segregation of duties enforced, answers 202 with {"status": "pending", "change_request_id"}: a second admin approves it in Admin → Changes |
MlModelRead: id, network_code, version, algorithm,
trained_from_decision_count, metrics_json, status (training / active /
retired / failed), activated_at, created_at.
RetrainResponse: network_code, promoted (bool), metrics (dict[str, float]),
model (MlModelRead).
Retrain and activate are written to the organization's audit log.
Model risk — /api/v1/mrm
Gated on admin:manage, for the bank's own staff (COMPLIANCE_PLAN.md C2;
docs/compliance/sr-11-7.md). Both tests are dry runs: the real
rules run, with the bank's settings and plugins, and everything is rolled back except the
report.
| method | path | notes |
|---|---|---|
| POST | /replay |
multipart file: a historical X9.37 cash letter or NACHA file (up to 50 MB). A file over long_request_sync_bytes (1 MB) is replayed in the background: 202 with the run, state queued; poll GET /runs/{id} until state is done (or failed). Otherwise 201 with the finished run: report has items, compared_with_history, match_rate (today's rules flag an item exactly when they did then), false_positive_rate (flagged, but the bank paid it), missed_return_rate (not flagged, but returned), unhandled_rate, exception_types, and variances listing the items that differ. 422 for a file that can't be read |
| POST | /anomaly-capture |
runs the synthetic anomaly-capture test now. passed only if every injected fraud vector was caught and the clean items weren't flagged; report.probes has each one |
| GET | /runs |
the latest runs; ?kind=mrm_replay or anomaly_capture |
| GET | /runs/{run_id} |
one run |
Controls — /api/v1/controls
| method | path | notes |
|---|---|---|
| GET | /status |
audit_log:read. Each control's live status, PASS / WARN / FAIL, with detail and evidence, and overall (the worst). The controls are: secrets encrypted in the database; disk and database encryption (as the operator states it); the action log's integrity; segregation of duties; admins' second factor; the anomaly-capture test; malformed files refused; decisions and imports on time; integration secret age; production settings |
Compliance inventories — /api/v1/compliance
Read-only (CONTROLS_PLAN.md CT1). See docs/compliance/controls.md and cuecs.md.
| method | path | notes |
|---|---|---|
| GET | /controls |
audit_log:read, the bank's own staff. OpenClear's control inventory: each control (OC-AC-05) with its NIST SP 800-53 controls (id, title, OSCAL id), type, nature, the CUECs it relies on, and its checks. Each check is live (with this bank's result and detail now) or planned; a control's status is the worst of its live checks, or null when none is live yet |
| GET | /cuecs?party=bank\|customer\|host |
Any signed-in caller (the inventory is published). The complementary user entity controls, each with the OpenClear controls that rely on it. A customer's own session sees only the customer's (party=bank or host is 403) |
Evidence export — /api/v1/audit
| method | path | notes |
|---|---|---|
| GET | /export?start=YYYY-MM-DD&end=YYYY-MM-DD&format=json\|pdf |
audit_log:read, the bank's own staff. A signed evidence bundle for the period (UTC days, at most 400). It covers the system version; the rules, plugins and models in force with their digests; settings and security groups; whether the action log verifies; segregation-of-duties breaches; changes and approvals; every decision with who recommended and decided it; model-risk runs; and the control status. JSON is {"bundle", "signature"}, where the signature is ECDSA P-256 over the SHA-256 of the bundle as compact, key-sorted JSON, with the public key included. The PDF shows the same. Both carry X-OpenClear-File-SHA256 and X-OpenClear-File-Signature (the file as sent). Each export is in the action log. A period longer than long_request_sync_days (32) is exported in the background: 202 with JSON status_url. See docs/compliance |
| GET | /export/runs/{run_id} |
A background export's state (queued, running, done, failed); download_url when done |
| GET | /export/runs/{run_id}/file |
The finished export, with the same X-OpenClear-* headers. 409 until it's done |
Security events and the action log — /api/v1/security-events, /api/v1/audit-log
audit_log:read, the bank's own staff (SIEM_PLAN.md S4). Both are feeds read oldest first
with a cursor, like the decision feed.
| method | path | notes |
|---|---|---|
| GET | /security-events?after=<sequence>&limit= |
This bank's security events after after (0 for the start): sign-ins and their failures, lockouts, second factors, tokens, refusals, changes to who may do what. Each has id, sequence, time, event (name, category, outcome, severity, reason), bank, actor, target, source (ip, user_agent), request (id, channel, edge_id), audit (the action-log sequence, when it's also an entry) and details. Continue with next_after. Kept for 30 days by default |
| GET | /audit-log?after_sequence=<n>&limit= |
This bank's action log after after_sequence, each entry with prev_entry_hash, entry_hash (an ECDSA P-256 / SHA-256 signature, hex DER) and signed_text (exactly what was signed); signature.public_key checks them. Continue with next_after_sequence |
| GET | /platform/security-events?after=&limit= |
Platform key with the operations scope: every bank's events, and those belonging to no bank (e.g. a failed sign-in naming a bank that doesn't exist) |
Every response carries X-Request-ID. A signed-in caller is rate-limited as themselves
(rate_limit_per_principal_per_minute); anyone else by address. A body over the size limit
gets 413 with a JSON detail under /api.
Billing — /api/v1/billing
What a bank charges its customers for positive pay. Each customer is billed either through
account analysis (charges go to the bank's analysis system, which offsets them with
earnings credits; ISO 20022 payment method BCMP) or as a hard charge (a direct debit
to the customer's billing account; FLAT), or is exempt. The bank sets a default and can
override it per customer. OpenClear counts volumes and prices them; it doesn't calculate
earnings credits.
A fee item charges a metric (GET /metrics) at a unit price, optionally with step
tiers and a monthly minimum, and can carry the bank's AFP Service Code. Metrics count per
account, per customer or bank-wide:
| metric | counts | level |
|---|---|---|
customer_monthly |
customers enrolled at month end | customer |
account_monthly / ach_account_monthly |
accounts enrolled (check / ACH positive pay) | account |
user_monthly |
users with access | customer |
ach_rule_monthly |
active ACH authorization rules | account |
issued_items / issue_files |
issued checks loaded / issue files received | account / customer |
presented_checks / ach_debits |
items evaluated | account |
check_exceptions / ach_exceptions |
exceptions raised | account |
check_returns / ach_returns / items_returned |
items returned | account |
items_paid_on_decision / auto_decisions |
exceptions decided pay / decided by the default | account |
stop_payments, check_images, files_received, data_exports |
as named | account / customer |
sms_sent / email_sent |
notifications sent | bank |
Bank-level metrics appear on the bank-wide statement (customer_id: null), which uses the
bank's own billing method.
| method | path | permission | notes |
|---|---|---|---|
| GET | /metrics |
billing:read |
the metric catalog → MetricRead (code, label, unit, level) |
| GET | /fee-items |
billing:manage |
the fee schedule → FeeItemRead list |
| POST | /fee-items |
billing:manage |
add a fee (FeeItemCreate); 422 for an unknown metric, a duplicate code, or bad tiers |
| POST | /fee-items/starter-schedule |
billing:manage |
add the common positive pay fees the bank doesn't have yet, priced at zero |
| PATCH | /fee-items/{id} |
billing:manage |
change a fee |
| GET / PUT | /settings |
billing:manage |
customer_default_billing_method, bank_billing_method, bank_billing_account_id |
| GET / PUT | /customers/{id}/terms |
billing:manage |
a customer's billing method, billing account, discount %, waive-all, notes and per-fee overrides (unit_price or waived); is_default when it has no terms of its own |
| GET | /statements |
billing:read |
statements for ?period=2026-09 (or start and end), optional customer_id; live by default, the finalized version with finalized=true |
| GET | /report.csv |
billing:read |
the billing report, one row per statement line; same filters |
| GET | /periods/{period} |
billing:manage |
whether the month is finalized, and its versions |
| POST | /periods/{period}/finalize |
billing:manage |
freeze the month's statements; finalizing again saves a replacement version |
| GET | /periods/{period}/analysis-feed.csv |
billing:read |
analysis customers' service lines for the analysis system |
| GET | /periods/{period}/hard-charge-postings.csv |
billing:read |
one debit per hard-charge customer, for the core's fee posting |
| GET | /periods/{period}/camt086.xml |
billing:read |
the month as an ISO 20022 camt.086 Bank Services Billing message (optional customer_id) |
The three exports are made from the finalized month only (409 until it's finalized) and
are for bank staff (403 for a customer-scoped user). camt.086 marks the first version
ORGN and later ones RPLC; the namespace version is OPENCLEAR_BILLING_CAMT086_VERSION
(default camt.086.001.04). Validate it against your counterparty's schema before using it
in production. Finalizing is written to the audit log.
A statement: period_start, period_end, customer_id, customer_number,
customer_name, billing_method, billing_account_number, totals (list, discount,
waived, net) and lines. Each line: fee_code, description, metric, unit,
afp_code, bank_service_code, volume, unit_price, charge_method (camt.086 UPRC,
TIR1…, MCHG), list_amount, discount_amount, net_amount, payment_method
(BCMP, FLAT, WVED, FREE), tiers, and accounts (volume per account). Amounts are
strings.
Platform — background jobs — /api/v1/platform/jobs
For the platform operator: a platform API key with the operations scope, as X-Api-Key
(python scripts/create_metering_api_key.py "Operations" --scope operations).
| method | path | purpose |
|---|---|---|
| GET | /summary |
count of jobs by status: {"queued": 3, "running": 1, "succeeded": 120, "failed": 0} |
| GET | ` (?status_filter=failed&kind=...`) |
jobs, newest first, paged (see Paging) → JobRead list |
| POST | /{job_id}/retry |
queue a failed job again with a fresh set of attempts; 409 for any other status |
JobRead: id, kind (check_image.ocr, data_export.run, dropbox.scan_bank,
ml.retrain_slot), tenant_id, status (queued / running / succeeded / failed),
attempts, max_attempts, run_after, created_at, started_at, finished_at,
last_error. A job reaches failed only after max_attempts tries; watch
/summary (or ?status_filter=failed) and retry once the cause is fixed.
Platform — billing banks — /api/v1/platform/billing
For the platform operator to bill each bank: a platform API key with the billing scope
(python scripts/create_metering_api_key.py "Billing" --scope billing). The operator keeps
its own fee schedule, using the same metrics as the banks plus bank_monthly (one per
bank). Each bank's invoice counts its whole organization (every customer and the bank's
own activity), is always a hard charge (FLAT), and leaves out the public demo.
| method | path | notes |
|---|---|---|
| GET | /metrics |
the metric catalog, with bank_monthly |
| GET / POST | /fee-items |
the operator's fee schedule (same fields as a bank's) |
| PATCH | /fee-items/{id} |
change a fee |
| GET | /invoices |
each bank's invoice for ?period=2026-09 (or start and end), optional tenant_id; the statement shape above, with bank_slug and bank_name in place of customer_number and customer_name |
| GET | /invoices.csv |
the same as a report |
| GET | /invoices/camt086.xml |
?period= (optional tenant_id) as camt.086, sent by OPENCLEAR_PLATFORM_OPERATOR_NAME / _ID |
Invoices are calculated live. There's no finalized platform invoice yet.
Users — /api/v1/users
| method | path | permission | notes |
|---|---|---|---|
| GET | `` | user:manage |
one row per membership — see below |
This is a read-only access-review / recertification endpoint: it runs the exact same
query as the /ui/users web page, so the two can never drift apart. There is currently
no API for creating, editing, or deactivating users/memberships — those actions are
web-UI-only (/ui/users/*).
TenantUserRead: user_id, email, security_group_name, customer_name (null =
bank-wide), is_active (the membership's status, not the underlying login's — the same
login can be active in one tenant and deactivated in another), membership_id,
membership_created_at, last_login_at (null if this identity has never completed a
login).
Bulk uploads
POST .../bulk exists for issued items, ACH transactions, and paid items — each
takes a plain JSON array of the same Create schema used by the single-item POST,
and returns:
{
"total": 3,
"succeeded": 2,
"failed": 1,
"results": [
{"index": 0, "success": true, "id": "...", "status": null, "error": null},
{"index": 1, "success": true, "id": "...", "status": "matched", "error": null},
{"index": 2, "success": false, "id": null, "status": null, "error": "Account not found"}
]
}
Rows are processed independently — one bad row doesn't fail the whole batch, and
results[i].index maps each result back to its position in the request array.
Large requests are imported in the background. Up to OPENCLEAR_BULK_API_SYNC_MAX_ROWS
items (1,000 by default) are imported during the call and answered as above. A larger
array is stored and imported by a background job, and the call answers 202 Accepted:
{"upload_id": "...", "status": "queued", "total": 25000, "status_url": "/api/v1/bulk-uploads/..."}
Poll the upload until it's finished, then read the row results:
| method | path | notes |
|---|---|---|
| GET | /api/v1/bulk-uploads/{id} |
status (queued, processing, completed, rejected, failed), processed_rows of total_rows, succeeded, failed, error, source (api, manual, auto_import) |
| GET | /api/v1/bulk-uploads/{id}/results |
each row's index, label, success, created id and error, in order; paged (see Paging); ?failures_only=true |
Both need the permission that gated the upload (paid_item:write, issued_item:write,
ach_transaction:write, ...); web and dropbox uploads can be read here too. Rows already
committed stay if an import failed. New exceptions from a background import send one
summary notification per reviewer rather than one each.
Uploading a file. You can also send a whole file in any import format the bank can use: the built-in ones, and any a plugin provides that a bank admin turned on (docs/PLUGINS.md). It's imported in the background like the large requests above.
| method | path | notes |
|---|---|---|
| GET | /api/v1/bulk-uploads/formats |
the kind and format pairs the caller may upload, each with built_in |
| POST | /api/v1/bulk-uploads |
multipart/form-data: kind (e.g. paid_items), format (e.g. tabular, nacha, x937, layout), file, and optionally create_missing_accounts, and layout_id with format=layout (issued items, or the bank's posted-check file with kind=paid_items). Answers 202 with status_url. A format that doesn't exist or isn't turned on gets 422. A file that can't be read comes back rejected, as does a posted-check file whose trailer doesn't match its lines |
| GET | /api/v1/bulk-uploads/layouts?kind= |
the layouts the caller may upload with: issued_items (default; a customer's own and the bank's) or paid_items (the bank's posted-check layouts, bank staff only). Each: id, kind, name, customer_id, is_default, file_type |
Needs the permission for that kind.
There is no /bulk endpoint for stop payments, ACH authorizations, or check images.
Built from API.md in the OpenClear repository. Last changed October 22, 2026.