API Integration Guide
This guide covers the Timonier CDP (Customer Data Platform) API in depth. It explains authentication, each endpoint, validation rules, rate limits, and common integration patterns. For a quick-start walkthrough, see the Getting Started guide.
The full API contract is defined in the OpenAPI specification.
All examples assume the CDP API is running at http://localhost:3030.
1. Authentication
Every request to the CDP API must include an API key in the Authorization
header using the Bearer scheme:
Authorization: Bearer <api_key>
Obtaining an API key
API keys are created in the platform UI under API Keys in the project sidebar. Each key is scoped to a single project and environment. When you create a key, the secret is displayed once — copy it immediately and store it in a secrets manager or environment variable.
export TIMONIER_API_KEY="your-api-key"
Key characteristics
- Project-scoped — a key only grants access to the project it was created in. Data sent with one key is isolated from other projects.
- Environment-scoped — keys belong to a specific environment (e.g. production, staging). Use separate keys per environment to keep data isolated.
- Revocable — keys can be revoked from the platform UI at any time. A
revoked key immediately returns
401 Unauthorized.
Key rotation best practices
- Create a new key before revoking the old one.
- Update all services to use the new key.
- Monitor for
401errors to confirm nothing still uses the old key. - Revoke the old key once traffic has fully migrated.
Authentication errors
| Status | Error | Message |
|---|---|---|
| 401 | unauthorized | Missing Authorization header |
| 401 | unauthorized | Invalid Authorization header format. Expected: Bearer <api_key> |
| 401 | unauthorized | Invalid API key |
| 401 | unauthorized | API key has been revoked |
2. Identifying Customers
POST /v1/identify creates or updates a customer profile. If a customer with
the given customer_id already exists, their traits are merged with the new
values (upsert semantics).
Request
curl -X POST http://localhost:3030/v1/identify \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"traits": {
"email": "jane@example.com",
"name": "Jane Doe",
"plan": "pro",
"signed_up_at": "2025-03-15T10:00:00Z"
}
}'
Response (202 Accepted)
{
"success": true,
"command_id": "550e8400-e29b-41d4-a716-446655440000"
}
The command_id is a unique reference for the queued operation. A 202 status
means the server accepted the request for asynchronous processing.
Fields
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Yes | Unique identifier for the customer. Max 255 chars. Allowed: a-zA-Z0-9, -, _, ., @, +, : |
traits | object | No | Key-value attributes to set on the customer profile. Defaults to {}. Max 32 KB, 100 keys, nesting depth 5. |
When to call identify
- Signup — as soon as the user creates an account, call identify with their
customer_idand initial traits (email, name, plan). - Login — if traits may have changed since the last identify call (e.g. the user updated their profile externally), re-identify them.
- Profile update — whenever the user changes their name, email, plan, or any other attribute you track.
Trait naming conventions
Use snake_case for trait names: first_name, signed_up_at, company_name.
This keeps traits consistent and predictable when building segments and
workflows.
Upsert behavior
Identify performs a shallow merge of traits. Sending {"plan": "enterprise"}
for an existing customer updates only the plan trait while preserving all
other traits. To remove a trait, set it to null.
3. Tracking Events
POST /v1/track records a custom event. Events represent actions a customer
takes in your application.
Request
curl -X POST http://localhost:3030/v1/track \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"event": "order_completed",
"properties": {
"order_id": "ord_456",
"total": 99.99,
"currency": "USD",
"items": 3
},
"context": {
"user_agent": "Mozilla/5.0...",
"ip": "203.0.113.42"
},
"timestamp": "2025-06-01T14:30:00Z",
"message_id": "msg_order_456"
}'
Response (202 Accepted)
{
"success": true,
"command_id": "661f9511-f33a-42e5-b817-557766551111"
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Conditional | Required if anonymous_id is not provided. Max 255 chars. |
anonymous_id | string | Conditional | Required if customer_id is not provided. Max 255 chars. |
event | string | Yes | Event name. Max 100 chars. Allowed: a-zA-Z0-9, _, -, space. |
properties | object | No | Event-specific data. Max 32 KB, 100 keys, nesting depth 5. |
context | object | No | Contextual metadata (user agent, IP, etc.). Max 32 KB, 100 keys, nesting depth 5. |
timestamp | ISO 8601 | No | When the event occurred. Defaults to server time. |
message_id | string | No | Client-generated ID for deduplication. Max 255 chars. |
Event naming conventions
Use lowercase snake_case: order_completed, page_viewed,
signup_started, button_clicked. This keeps events consistent and easy to
reference in segments and workflows.
customer_id vs anonymous_id
- Use
customer_idwhen the user is identified (logged in, signed up). - Use
anonymous_idwhen the user is not yet identified (browsing before signup). Generate a stable anonymous ID on the client (e.g. a UUID stored in a cookie or local storage). - You may provide both fields. When only
anonymous_idis used, call merge after the user identifies themselves to link their anonymous history to their profile.
Properties: what to include
Include data that is specific to this event and useful for analysis or automation:
| Event | Suggested properties |
|---|---|
order_completed | order_id, total, currency, items |
signup_started | source, referral_code |
feature_used | feature_name, duration_ms |
subscription_changed | old_plan, new_plan, mrr |
4. Tracking Page Views
POST /v1/page records a page view. Use this for web page navigation tracking
instead of track to benefit from dedicated fields for URL, title, and
referrer.
Request
curl -X POST http://localhost:3030/v1/page \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"url": "https://app.example.com/dashboard",
"title": "Dashboard - My App",
"referrer": "https://app.example.com/login",
"properties": {
"section": "analytics"
},
"message_id": "msg_page_abc"
}'
Response (202 Accepted)
{
"success": true,
"command_id": "772a0622-a44b-52f6-c928-668877662222"
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | string | Conditional | Required if anonymous_id is not provided. Max 255 chars. |
anonymous_id | string | Conditional | Required if customer_id is not provided. Max 255 chars. |
url | string | Yes | Full URL of the page. Max 2048 chars. |
title | string | No | Page title. Max 500 chars. |
referrer | string | No | Referrer URL. Max 2048 chars. |
properties | object | No | Additional page metadata. Max 32 KB, 100 keys, nesting depth 5. |
context | object | No | Contextual metadata. Max 32 KB, 100 keys, nesting depth 5. |
timestamp | ISO 8601 | No | When the page was viewed. Defaults to server time. |
message_id | string | No | Client-generated ID for deduplication. Max 255 chars. |
When to use page vs track
Use page for navigation events where the URL is the primary identifier. Use
track for discrete actions (button clicks, form submissions, purchases). If
you want to track a page view and an action on that page, send both a page
call and a track call.
5. Batch Operations
POST /v1/batch sends multiple operations in a single HTTP request. Each item
in the batch is an independent identify, track, page, merge, or delete
operation.
Request
curl -X POST http://localhost:3030/v1/batch \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"batch": [
{
"type": "identify",
"customer_id": "user_123",
"traits": { "plan": "enterprise" }
},
{
"type": "track",
"customer_id": "user_123",
"event": "plan_upgraded",
"properties": { "old_plan": "pro", "new_plan": "enterprise" },
"message_id": "msg_upgrade_123"
},
{
"type": "page",
"customer_id": "user_456",
"url": "https://example.com/pricing",
"title": "Pricing"
}
]
}'
Response (202 Accepted)
{
"success": true,
"accepted": 3,
"rejected": 0,
"command_ids": [
"883b1733-b55c-63g7-da39-779988773333",
"994c2844-c66d-74h8-eb40-880099884444",
"aa5d3955-d77e-85i9-fc51-991100995555"
]
}
If some items fail validation, they are rejected while valid items are still processed:
{
"success": true,
"accepted": 2,
"rejected": 1,
"command_ids": [
"883b1733-b55c-63g7-da39-779988773333",
"994c2844-c66d-74h8-eb40-880099884444"
]
}
Batch limits
- Maximum 100 items per batch request.
- Each item is validated individually according to its type.
- The total request body must not exceed 64 KB.
When to batch
Use the batch endpoint for high-volume server-side ingestion — for example, processing a queue of events from a background worker or importing historical data. Batching reduces HTTP overhead and is more efficient than sending individual requests.
Mixing operation types
A single batch can contain any combination of identify, track, page,
merge, and delete operations. Each item must include a type field and the
fields required by that operation type.
6. Deduplication
Track and page events support client-side deduplication via the message_id
field. This enables exactly-once processing semantics for retried requests.
How it works
- Generate a unique
message_idon the client for each event (e.g. a UUID or a deterministic hash of the event data). - Include the
message_idin your track or page request. - If the server receives a second event with the same
message_idwithin the same project, it silently ignores the duplicate.
Example
# First request — accepted
curl -X POST http://localhost:3030/v1/track \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"event": "order_completed",
"properties": { "order_id": "ord_789" },
"message_id": "evt_ord_789"
}'
# Retry with same message_id — silently deduplicated
curl -X POST http://localhost:3030/v1/track \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"event": "order_completed",
"properties": { "order_id": "ord_789" },
"message_id": "evt_ord_789"
}'
Both requests return 202 Accepted. The second event is not stored.
Retry safety
With message_id, retries are safe: if a network error prevents you from
reading the response, you can resend the same request and the event will not be
duplicated. Without message_id, every request creates a new event regardless
of content.
Scope
- Deduplication is scoped to a project — the same
message_idcan exist in different projects without conflict. - Only
trackandpageevents supportmessage_id. Theidentify,merge, anddeleteoperations are inherently idempotent (identify upserts, merge re-links, delete soft-deletes).
7. Validation Rules
All inputs are validated before processing. Invalid requests return
400 Bad Request with a field-specific error message.
Field limits
| Field | Max length | Character restrictions |
|---|---|---|
customer_id | 255 chars | a-zA-Z0-9, -, _, ., @, +, : |
anonymous_id | 255 chars | No character restrictions |
event | 100 chars | a-zA-Z0-9, _, -, space |
email (in traits) | 254 chars | RFC 5322 format (local part max 64 chars, domain must contain .) |
url | 2048 chars | No character restrictions |
title | 500 chars | No character restrictions |
referrer | 2048 chars | No character restrictions |
message_id | 255 chars | No character restrictions |
JSON payload limits
The traits, properties, and context fields are each validated
independently:
| Constraint | Limit |
|---|---|
| Serialized size | 32 KB (32,768 bytes) |
| Total key count (including nested keys) | 100 |
| Maximum nesting depth | 5 levels |
Request body limit
The total HTTP request body is limited to 64 KB (65,536 bytes) by default. Requests exceeding this limit are rejected before parsing.
Batch size limit
The batch array accepts between 1 and 100 items. An empty batch returns a
validation error.
Error response format
Validation errors return 400 Bad Request with a JSON body:
{
"error": "bad_request",
"message": "customer_id: exceeds maximum length of 255 characters"
}
The message field includes the field name and a description of the constraint
that was violated.
8. Rate Limits
Rate limits are applied per API key to prevent abuse. Different limits apply to regular API endpoints and the batch endpoint.
Default limits
| Endpoint | Requests | Window | Effective rate |
|---|---|---|---|
/v1/identify, /v1/track, /v1/page, /v1/merge, DELETE /v1/customers/{id} | 1,000 | 1 hour | ~16 req/min |
/v1/batch | 100 | 1 minute | Up to 10,000 events/min (100 batches x 100 items) |
Rate limit headers
Every rate-limited response includes these headers:
| Header | Description |
|---|---|
x-ratelimit-limit | Maximum requests allowed in the window |
x-ratelimit-remaining | Requests remaining (included when limit is hit) |
retry-after | Seconds until the rate limit window resets |
Rate limit exceeded response (429)
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please try again in 3600 seconds.",
"retry_after_secs": 3600
}
Retry strategy
When you receive a 429, implement exponential backoff:
- Read the
retry-afterheader value (in seconds). - Wait at least that long before retrying.
- If subsequent retries also return
429, double the wait time each attempt. - Cap the maximum wait at a reasonable limit (e.g. 5 minutes).
import time
import requests
def send_with_retry(url, headers, payload, max_retries=5):
for attempt in range(max_retries):
response = requests.post(url, json=payload, headers=headers)
if response.status_code != 429:
return response
retry_after = int(response.headers.get("retry-after", 60))
wait = min(retry_after * (2 ** attempt), 300)
time.sleep(wait)
return response
9. Common Integration Patterns
Merging anonymous events
When a user browses your site anonymously and then signs up or logs in, call
POST /v1/merge to link their anonymous event history to their identified
profile:
curl -X POST http://localhost:3030/v1/merge \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"anonymous_id": "anon_abc"
}'
After merging, all events previously associated with anon_abc are attributed
to user_123.
Signup flow
A typical signup flow sends an identify call followed by a track call:
# Identify the new user
curl -X POST http://localhost:3030/v1/identify \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_789",
"traits": {
"email": "alex@example.com",
"name": "Alex Chen",
"plan": "free"
}
}'
# Track the signup event
curl -X POST http://localhost:3030/v1/track \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_789",
"event": "signed_up",
"properties": {
"source": "organic",
"referral_code": "FRIEND50"
},
"message_id": "msg_signup_user_789"
}'
# Merge anonymous browsing history
curl -X POST http://localhost:3030/v1/merge \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_789",
"anonymous_id": "anon_session_xyz"
}'
Purchase flow
curl -X POST http://localhost:3030/v1/track \
-H "Authorization: Bearer $TIMONIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_789",
"event": "order_completed",
"properties": {
"order_id": "ord_12345",
"total": 149.99,
"currency": "USD",
"items": [
{ "sku": "SKU-001", "name": "Widget", "price": 49.99, "quantity": 3 }
]
},
"message_id": "msg_ord_12345"
}'
Server-side integration (Python)
import requests
API_URL = "http://localhost:3030"
API_KEY = "your-api-key"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
def identify(customer_id: str, traits: dict) -> dict:
response = requests.post(
f"{API_URL}/v1/identify",
json={"customer_id": customer_id, "traits": traits},
headers=HEADERS,
)
response.raise_for_status()
return response.json()
def track(customer_id: str, event: str, properties: dict = None) -> dict:
payload = {"customer_id": customer_id, "event": event}
if properties:
payload["properties"] = properties
response = requests.post(
f"{API_URL}/v1/track",
json=payload,
headers=HEADERS,
)
response.raise_for_status()
return response.json()
# Usage
identify("user_001", {"email": "jane@example.com", "plan": "pro"})
track("user_001", "feature_used", {"feature_name": "export", "duration_ms": 1200})
Server-side integration (TypeScript)
const API_URL = "http://localhost:3030";
const API_KEY = "your-api-key";
const headers = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
async function identify(
customerId: string,
traits: Record<string, unknown>,
): Promise<{ success: boolean; command_id: string }> {
const res = await fetch(`${API_URL}/v1/identify`, {
method: "POST",
headers,
body: JSON.stringify({ customer_id: customerId, traits }),
});
if (!res.ok) throw new Error(`identify failed: ${res.status}`);
return res.json();
}
async function track(
customerId: string,
event: string,
properties?: Record<string, unknown>,
): Promise<{ success: boolean; command_id: string }> {
const res = await fetch(`${API_URL}/v1/track`, {
method: "POST",
headers,
body: JSON.stringify({ customer_id: customerId, event, properties }),
});
if (!res.ok) throw new Error(`track failed: ${res.status}`);
return res.json();
}
// Usage
await identify("user_001", { email: "jane@example.com", plan: "pro" });
await track("user_001", "feature_used", {
feature_name: "export",
duration_ms: 1200,
});
Error handling and retry logic
Robust integrations should handle transient failures gracefully:
import time
import requests
HEADERS = {
"Authorization": "Bearer your-api-key",
"Content-Type": "application/json",
}
def send_event(payload: dict, max_retries: int = 3) -> dict:
url = "http://localhost:3030/v1/track"
for attempt in range(max_retries):
try:
response = requests.post(url, json=payload, headers=HEADERS, timeout=10)
if response.status_code == 202:
return response.json()
if response.status_code == 429:
retry_after = int(response.headers.get("retry-after", 60))
time.sleep(retry_after)
continue
if response.status_code == 400:
raise ValueError(f"Validation error: {response.json()['message']}")
if response.status_code == 401:
raise PermissionError("Invalid API key")
response.raise_for_status()
except requests.exceptions.ConnectionError:
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
continue
raise
raise RuntimeError("Max retries exceeded")
Troubleshooting
401 Unauthorized
Symptom: Every request returns 401.
Possible causes:
- The
Authorizationheader is missing or malformed. Ensure the format isAuthorization: Bearer <api_key>(note the space afterBearer). - The API key is invalid. Verify it matches the key shown when it was created.
- The API key has been revoked. Check the API Keys page in the platform UI.
400 Bad Request
Symptom: Requests are rejected with a validation error.
Possible causes:
customer_idcontains disallowed characters. Onlya-zA-Z0-9,-,_,.,@,+,:are allowed.eventname is too long (max 100 chars) or contains special characters.- A JSON payload (
traits,properties,context) exceeds 32 KB, has more than 100 keys, or nests deeper than 5 levels. - Neither
customer_idnoranonymous_idis provided on a track or page call. - The batch array is empty or exceeds 100 items.
Read the message field in the error response — it includes the field name and
the specific constraint that was violated.
429 Too Many Requests
Symptom: Requests return 429 with a retry-after header.
Resolution:
- Check the
retry-afterheader for how long to wait (in seconds). - Implement exponential backoff (see Retry strategy).
- For high-volume ingestion, switch to the batch endpoint to send up to 100 events per request.
- If you consistently hit limits, contact your administrator to adjust the rate limit configuration.
413 Payload Too Large
Symptom: Large requests are rejected before the API processes them.
Resolution: The total request body is limited to 64 KB. Reduce the size of your JSON payloads or split large batches into multiple requests.
Events not appearing in the timeline
Symptom: The API returns 202 but events do not show up in the customer
timeline.
Possible causes:
- Events are processed asynchronously. There may be a short delay between acceptance and visibility.
- The event was deduplicated. If you sent the same
message_idbefore, the duplicate is silently ignored. Try with a differentmessage_idor omit it. - The
customer_iddoes not match the customer you are looking at. Verify the ID is correct and consistently formatted.