Integration examples
Complete, runnable clients in cURL, TypeScript and Python - including pagination, error handling and retries.
Last revised: 2026-08-23
cURL
The whole lifecycle of one record, using an API token from the environment. Every example on this page uses the same variable so they can be pasted in sequence.
export CLIENTO_HOST="https://your-host"
export CLIENTO_TOKEN="ct_live_xxxxxxxxxxxxxxxxxxxxxxxx"# List (page 1, 25 per page)
curl -sS "$CLIENTO_HOST/api/v1/crm/contacts?page=1&page_size=25" \
-H "Authorization: Bearer $CLIENTO_TOKEN"
# Create -> 201, body is the new contact
curl -sS -X POST "$CLIENTO_HOST/api/v1/crm/contacts" \
-H "Authorization: Bearer $CLIENTO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Sara",
"last_name": "Ahmadi",
"email": "sara@example.com",
"phone": "+989121234567",
"company": "Horizon Real Estate"
}'
# Read one
curl -sS "$CLIENTO_HOST/api/v1/crm/contacts/4182" \
-H "Authorization: Bearer $CLIENTO_TOKEN"
# Update - PATCH is partial, send only what changed
curl -sS -X PATCH "$CLIENTO_HOST/api/v1/crm/contacts/4182" \
-H "Authorization: Bearer $CLIENTO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title": "Managing partner" }'
# Delete -> 204, empty body
curl -sS -o /dev/null -w "%{http_code}\n" \
-X DELETE "$CLIENTO_HOST/api/v1/crm/contacts/4182" \
-H "Authorization: Bearer $CLIENTO_TOKEN"# -sS keeps curl quiet but still prints transport errors;
# -i shows the status line, which the error body does not repeat.
curl -sS -i -X POST "$CLIENTO_HOST/api/v1/crm/contacts" \
-H "Authorization: Bearer $CLIENTO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "not-an-email" }'
# HTTP/1.1 400 Bad Request
# {"error":"validation_error","message":"Invalid payload",
# "details":{"email":["Not a valid email address."]}}TypeScript / JavaScript
A small client with the three things every integration needs and most first drafts omit: typed errors, automatic pagination, and a retry that respects Retry-After. Runs on Node 20+ and in any runtime with fetch.
const HOST = process.env.CLIENTO_HOST!;
const TOKEN = process.env.CLIENTO_TOKEN!; // never hard-code this
export class ClientoError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details: unknown,
) {
super(message);
this.name = "ClientoError";
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function request<T>(
path: string,
init: RequestInit & { json?: unknown } = {},
): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${TOKEN}`);
headers.set("Accept", "application/json");
let body = init.body;
if (init.json !== undefined) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(init.json);
}
// Up to three attempts, but ONLY for answers a retry can fix: a throttle
// (the server tells us how long to wait) and a server fault. A 400 or a 403
// is a decision, and repeating it just makes the same request twice.
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${HOST}/api/v1${path}`, { ...init, body, headers });
if (res.status === 429 && attempt < 3) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
await sleep(wait * 1000);
continue;
}
if (res.status >= 500 && attempt < 3) {
await sleep(2 ** attempt * 1000 + Math.random() * 500); // backoff + jitter
continue;
}
if (res.status === 204) return undefined as T;
const payload = await res.json().catch(() => null);
if (!res.ok) {
const e = payload as { error?: string; message?: string; details?: unknown } | null;
throw new ClientoError(
res.status,
e?.error ?? "unknown_error",
e?.message ?? res.statusText,
e?.details ?? null,
);
}
return payload as T;
}
}
/** Every page of a list endpoint, as one async iterator. */
export async function* paginate<T>(
path: string,
params: Record<string, string | number> = {},
): AsyncGenerator<T> {
const pageSize = Number(params.page_size ?? 100);
for (let page = 1; ; page++) {
const query = new URLSearchParams({
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
page: String(page),
page_size: String(pageSize),
});
const res = await request<{ items: T[]; total: number }>(`${path}?${query}`);
for (const item of res.items) yield item;
// Stop on a short page rather than on `page * size >= total`: rows can be
// added while you walk, and the count is a moving target.
if (res.items.length < pageSize) return;
}
}import { ClientoError, paginate, request } from "./client";
type Contact = {
id: number;
display_name: string;
email: string | null;
phone: string | null;
};
// Create
const created = await request<Contact>("/crm/contacts", {
method: "POST",
json: { first_name: "Sara", last_name: "Ahmadi", email: "sara@example.com" },
});
// Update - partial
await request<Contact>(`/crm/contacts/${created.id}`, {
method: "PATCH",
json: { title: "Managing partner" },
});
// Walk every contact without holding them all in memory
for await (const contact of paginate<Contact>("/crm/contacts", { q: "ahmadi" })) {
console.log(contact.id, contact.display_name);
}
// Handle refusals by code, never by message text
try {
await request("/crm/contacts", { method: "POST", json: { email: "nope" } });
} catch (err) {
if (err instanceof ClientoError && err.code === "validation_error") {
console.error("Rejected fields:", err.details);
} else if (err instanceof ClientoError && err.status === 403) {
console.error("Token is missing:", (err.details as { required?: string[] })?.required);
} else {
throw err;
}
}Python
The same client in Python, using requests. A single Session reuses the TCP connection and carries the auth header, which matters when you are paging through thousands of records.
import os
import random
import time
from typing import Any, Iterator
import requests
HOST = os.environ["CLIENTO_HOST"]
TOKEN = os.environ["CLIENTO_TOKEN"] # never hard-code this
class ClientoError(Exception):
def __init__(self, status: int, code: str, message: str, details: Any):
super().__init__(f"{status} {code}: {message}")
self.status = status
self.code = code
self.details = details
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {TOKEN}",
"Accept": "application/json",
})
def request(method: str, path: str, *, json: Any = None,
params: dict | None = None, attempts: int = 3) -> Any:
"""One API call, with the retries a retry can actually fix.
429 and 5xx are retried; 4xx is a decision and is raised immediately.
"""
url = f"{HOST}/api/v1{path}"
for attempt in range(attempts):
response = session.request(method, url, json=json, params=params, timeout=30)
if response.status_code == 429 and attempt < attempts - 1:
time.sleep(int(response.headers.get("Retry-After", 5)))
continue
if response.status_code >= 500 and attempt < attempts - 1:
time.sleep(2 ** attempt + random.random()) # backoff + jitter
continue
if response.status_code == 204:
return None
try:
payload = response.json()
except ValueError:
payload = None
if not response.ok:
payload = payload or {}
raise ClientoError(
response.status_code,
payload.get("error", "unknown_error"),
payload.get("message", response.reason),
payload.get("details"),
)
return payload
raise RuntimeError("unreachable")
def paginate(path: str, *, page_size: int = 100, **filters) -> Iterator[dict]:
"""Every row of a list endpoint, one page at a time."""
page = 1
while True:
body = request("GET", path,
params={**filters, "page": page, "page_size": page_size})
items = body["items"]
yield from items
# A short page means the end. Comparing against `total` would loop
# forever if rows were added while paging.
if len(items) < page_size:
return
page += 1from cliento import ClientoError, paginate, request
# Create
contact = request("POST", "/crm/contacts", json={
"first_name": "Sara",
"last_name": "Ahmadi",
"email": "sara@example.com",
})
# Update - partial
request("PATCH", f"/crm/contacts/{contact['id']}", json={"title": "Managing partner"})
# Walk every contact
for row in paginate("/crm/contacts", q="ahmadi"):
print(row["id"], row["display_name"])
# Delete
request("DELETE", f"/crm/contacts/{contact['id']}")
# Handle refusals by code
try:
request("POST", "/crm/contacts", json={"email": "nope"})
except ClientoError as err:
if err.code == "validation_error":
print("Rejected fields:", err.details)
elif err.status == 403:
print("Token is missing:", (err.details or {}).get("required"))
else:
raiseBefore you ship
- The token comes from the environment or a secret manager - not from the repository.
- Unknown response fields are ignored, so an additive API change cannot break you.
- Errors branch on
errorandstatus, never onmessage. - 429 waits for
Retry-After; 5xx backs off with jitter; 4xx does not retry. - Pagination stops on a short page, not on an arithmetic comparison with
total. - Timestamps are parsed as UTC.
- A
POSTthat times out is reconciled by reading back, not by resending.