Back to documentation

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.

bash·Setup
export CLIENTO_HOST="https://your-host"
export CLIENTO_TOKEN="ct_live_xxxxxxxxxxxxxxxxxxxxxxxx"
bash·List, create, read, update, delete
# 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"
bash·Seeing an error body
# -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.

typescript·client.ts
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;
  }
}
typescript·Using it
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.

python·cliento.py
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 += 1
python·Using it
from 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:
        raise

Before 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 error and status, never on message.
  • 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 POST that times out is reconciled by reading back, not by resending.

Need technical help?

If your question is not answered here, get in touch with our team.

Contact us
Integration examples · Cliento Pro