ClocknextHelp
DocumentationAPI ReferenceMCP Tools
Dashboard

Get started

OverviewQuickstart

Good to know

Response formatHandling errorsPaginating resultsSending data reliablyYour own customer idsWebhooks

API reference

Record credit POSTRecord outcome POSTComplete outcome POSTRecord wallet POSTRecord unit POSTComplete composite POST
List customers GETCreate a customer POSTGet a customer GETUpdate a customer PATCHDelete a customer DELETEGet a customer's balances GETGet a customer's wallet ledger GETSet a customer's low-balance alert PUTGet a customer's current plan GETChange a recurring grant PUTList Customer Usage Log GETLook Up Usage Logs by ID GET
Manage a customer's credit balance POSTManage a customer's outcome balance POSTManage a customer's unit balance POSTManage a customer's composite balance POSTManage a customer's wallet balance POST
List a customer's members GETAdd a member POSTUpdate a member PATCHRemove a member DELETE
PlansCreditsOutcomesUnitsComposites
List taxes GETCreate a tax POSTGet a tax GETUpdate a tax PATCH
List purchases GETCreate a purchase POSTGet a purchase GETUpdate a purchase's auto-payment setting PATCHCancel a purchase POST
List invoices GETGet an invoice GETMint a hosted pay link GET
List payments GETGet a payment GET
wallet.balance.update POSTwallet.balance.drop POST
Mint a customer-portal access token POST

API Reference

Handling errors

Every status code the API returns, what it means, and how to handle it.

When a request fails, the HTTP status and statusCode agree, statusDetail.status is ERROR, result is an empty object, and the reason is in statusDetail.message:

{
  "statusCode": 400,
  "statusDetail": { "status": "ERROR", "message": "email must be a valid email" },
  "result": {}
}

Status codes

StatusMeaningWhat to do
400 Bad RequestThe request was malformed — invalid JSON, a bad query parameter, a field that failed validation (wrong type, out-of-range, or missing required), or a rejected business rule (e.g. an inactive plan, a past billing date).Read statusDetail.message for the offending field or rule and fix the request.
401 UnauthorizedThe API key is missing, unknown, or expired.Send a valid Authorization: Bearer cnk_… header. See Settings → API Keys.
403 ForbiddenAn explicit organizationId on /portal/token doesn't match the key's organisation.Send the correct organizationId, or omit it.
404 Not FoundThe record doesn't exist — or belongs to another organisation (the two are masked as one).Check the id. A foreign id looks identical to a missing one by design.
409 ConflictThe request clashes with current state — e.g. a duplicate member id, a usage signal against an archived customer, or a pay link for an invoice that isn't OPEN.Reconcile with the current state, then retry.
422 Unprocessable EntityThe body is well-formed but can't be applied to the current state — raised by the usage/units flows when the customer has no active plan, the credit/outcome/unit agent key isn't in your workspace, the model isn't enabled, or the customer is out of allowance. (Field-level validation is 400, not 422.)Read statusDetail.message; fix the referenced entity or top up allowance. Not retryable unchanged.
500 Internal Server ErrorSomething went wrong on our side.Retry with backoff; if it persists, contact support.

Why foreign ids are 404, not 403

A key is scoped to one organisation, and referencing an id from another organisation returns 404 everywhere — the same as an id that doesn't exist. That's deliberate: it stops a key probing for valid foreign ids by telling "forbidden" apart from "not found". The only endpoint that distinguishes them is /portal/token.

Handling errors

Treat any non-2xx status as a failure and read statusDetail.message for the reason. 4xx codes mean your request needs to change and won't succeed on a blind retry; 500 is transient and safe to retry.

const res = await fetch(url, init);
const body = await res.json();

if (body.statusDetail.status !== "SUCCESS") {
  // body.statusCode        → 400 | 401 | 403 | 404 | 409 | 422 | 500
  // body.statusDetail.message → human-readable reason
  throw new Error(`${body.statusCode}: ${body.statusDetail.message}`);
}

Typed errors in the SDK

The official @clocknext/sdk client throws a typed error for each status, all extending ClocknextError (with .status and .retryable), so you can branch on the failure mode instead of parsing the message:

import { AuthError, NotFoundError, AllowanceError } from "@clocknext/sdk";

try {
  await cnk.customers.get("cus_missing");
} catch (err) {
  if (err instanceof NotFoundError) {/* 404 */}
  else if (err instanceof AuthError) {/* 401 */}
  else if (err instanceof AllowanceError) {/* 422 — out of allowance */}
}

Retries

Only retry codes that can succeed unchanged:

  • Retry 500 (and network/timeout failures) with exponential backoff.
  • Do not retry 4xx — the request itself is the problem; fix it first.

Be careful retrying non-idempotent writes (a POST that creates a record). If a POST /customers times out you can't tell whether it landed, so a blind retry risks a duplicate. Prefer to look the record up before retrying, or use the @clocknext/sdk client, which only auto-retries replay-safe calls.

Response format

Every endpoint — success or error — returns the same predictable envelope. Here's the contract.

Paginating results

Page through list endpoints with limit and cursor.

On this page

Status codesHandling errorsRetries