Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Create a new key before revoking the old one.
  2. Update all services to use the new key.
  3. Monitor for 401 errors to confirm nothing still uses the old key.
  4. Revoke the old key once traffic has fully migrated.

Authentication errors

StatusErrorMessage
401unauthorizedMissing Authorization header
401unauthorizedInvalid Authorization header format. Expected: Bearer <api_key>
401unauthorizedInvalid API key
401unauthorizedAPI 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

FieldTypeRequiredDescription
customer_idstringYesUnique identifier for the customer. Max 255 chars. Allowed: a-zA-Z0-9, -, _, ., @, +, :
traitsobjectNoKey-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_id and 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

FieldTypeRequiredDescription
customer_idstringConditionalRequired if anonymous_id is not provided. Max 255 chars.
anonymous_idstringConditionalRequired if customer_id is not provided. Max 255 chars.
eventstringYesEvent name. Max 100 chars. Allowed: a-zA-Z0-9, _, -, space.
propertiesobjectNoEvent-specific data. Max 32 KB, 100 keys, nesting depth 5.
contextobjectNoContextual metadata (user agent, IP, etc.). Max 32 KB, 100 keys, nesting depth 5.
timestampISO 8601NoWhen the event occurred. Defaults to server time.
message_idstringNoClient-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_id when the user is identified (logged in, signed up).
  • Use anonymous_id when 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_id is 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:

EventSuggested properties
order_completedorder_id, total, currency, items
signup_startedsource, referral_code
feature_usedfeature_name, duration_ms
subscription_changedold_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

FieldTypeRequiredDescription
customer_idstringConditionalRequired if anonymous_id is not provided. Max 255 chars.
anonymous_idstringConditionalRequired if customer_id is not provided. Max 255 chars.
urlstringYesFull URL of the page. Max 2048 chars.
titlestringNoPage title. Max 500 chars.
referrerstringNoReferrer URL. Max 2048 chars.
propertiesobjectNoAdditional page metadata. Max 32 KB, 100 keys, nesting depth 5.
contextobjectNoContextual metadata. Max 32 KB, 100 keys, nesting depth 5.
timestampISO 8601NoWhen the page was viewed. Defaults to server time.
message_idstringNoClient-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

  1. Generate a unique message_id on the client for each event (e.g. a UUID or a deterministic hash of the event data).
  2. Include the message_id in your track or page request.
  3. If the server receives a second event with the same message_id within 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_id can exist in different projects without conflict.
  • Only track and page events support message_id. The identify, merge, and delete operations 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

FieldMax lengthCharacter restrictions
customer_id255 charsa-zA-Z0-9, -, _, ., @, +, :
anonymous_id255 charsNo character restrictions
event100 charsa-zA-Z0-9, _, -, space
email (in traits)254 charsRFC 5322 format (local part max 64 chars, domain must contain .)
url2048 charsNo character restrictions
title500 charsNo character restrictions
referrer2048 charsNo character restrictions
message_id255 charsNo character restrictions

JSON payload limits

The traits, properties, and context fields are each validated independently:

ConstraintLimit
Serialized size32 KB (32,768 bytes)
Total key count (including nested keys)100
Maximum nesting depth5 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

EndpointRequestsWindowEffective rate
/v1/identify, /v1/track, /v1/page, /v1/merge, DELETE /v1/customers/{id}1,0001 hour~16 req/min
/v1/batch1001 minuteUp to 10,000 events/min (100 batches x 100 items)

Rate limit headers

Every rate-limited response includes these headers:

HeaderDescription
x-ratelimit-limitMaximum requests allowed in the window
x-ratelimit-remainingRequests remaining (included when limit is hit)
retry-afterSeconds 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:

  1. Read the retry-after header value (in seconds).
  2. Wait at least that long before retrying.
  3. If subsequent retries also return 429, double the wait time each attempt.
  4. 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 Authorization header is missing or malformed. Ensure the format is Authorization: Bearer <api_key> (note the space after Bearer).
  • 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_id contains disallowed characters. Only a-zA-Z0-9, -, _, ., @, +, : are allowed.
  • event name 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_id nor anonymous_id is 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:

  1. Check the retry-after header for how long to wait (in seconds).
  2. Implement exponential backoff (see Retry strategy).
  3. For high-volume ingestion, switch to the batch endpoint to send up to 100 events per request.
  4. 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_id before, the duplicate is silently ignored. Try with a different message_id or omit it.
  • The customer_id does not match the customer you are looking at. Verify the ID is correct and consistently formatted.