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

Timonier Documentation

Welcome to the Timonier documentation. Timonier is a customer data platform that lets you collect events, build customer segments, and automate workflows.

Available Guides

  • Getting Started — sign up, create a project, send your first event, build a segment, and set up an automated workflow.
  • API Integration Guide — detailed reference for every CDP endpoint including authentication, batching, and deduplication.
  • TypeScript SDK — official TypeScript/JavaScript client with automatic retries, batching, and typed errors.

Getting Started

This guide walks you through signing up, creating a project, sending your first event, and verifying it in the customer timeline. By the end, you will have a working segment and an automated workflow.

Prerequisites — a running Timonier instance at http://localhost:3010 (platform UI) and http://localhost:3030 (CDP API), plus an authenticator app such as Google Authenticator or Authy on your phone.


1. Sign Up and Create Your First Project

Create your account

  1. Open http://localhost:3010/signup in your browser.
  2. Fill in your email, first name, and last name, then click Sign Up.
  3. Check your inbox for a verification email and click the link it contains. The link brings you to a page that displays a QR code.
  4. Scan the QR code with your authenticator app to register Timonier as a TOTP source. The page also shows the base32 secret in case you prefer to enter it manually.
  5. Enter the 6-digit code displayed by your authenticator app and click Verify. You are now logged in and redirected to the project selector.

Create an organization

  1. On the project selector page (/), click Create Organization.
  2. Enter an Organization Name (for example Acme Corp) and a Slug (lowercase letters, numbers, and hyphens — for example acme-corp).
  3. Click Create Organization.

Create a project

  1. Back on the project selector, find your new organization and click New Project inside it.
  2. Enter a Project Name (for example My App) and a Slug (for example my-app).
  3. Click Create Project. You are redirected to the project dashboard.

2. Get Your API Key

  1. In the sidebar, click API Keys.
  2. Click New API Key (top-right corner of the page).
  3. Enter a descriptive Key Name (for example Backend Server) and click Create API Key.
  4. The next page displays your key once. Copy it now and store it somewhere safe (for example in an environment variable or a secrets manager). You will not be able to see it again.

Export the key so the cURL commands below can reference it:

export TIMONIER_API_KEY="<paste-your-key-here>"

3. Send Your First Event

The CDP API runs on port 3030 and accepts JSON payloads authenticated with a Bearer token.

Identify a customer

Use POST /v1/identify to create or update a customer profile. Traits are merged with any existing data.

curl -X POST http://localhost:3030/v1/identify \
  -H "Authorization: Bearer $TIMONIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "user_001",
    "traits": {
      "email": "jane@example.com",
      "name": "Jane Doe",
      "plan": "pro"
    }
  }'

A successful response returns 202 Accepted with a command ID:

{
  "success": true,
  "command_id": "550e8400-e29b-41d4-a716-446655440000"
}

The command_id is a unique identifier for the queued operation. A 202 status means the request has been accepted for asynchronous processing.

Track an event

Use POST /v1/track to record a custom event for the customer you just identified.

curl -X POST http://localhost:3030/v1/track \
  -H "Authorization: Bearer $TIMONIER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "user_001",
    "event": "purchase",
    "properties": {
      "item": "T-Shirt",
      "amount": 29.99,
      "currency": "USD"
    }
  }'

The response format is the same as for identify:

{
  "success": true,
  "command_id": "661f9511-f33a-42e5-b817-557766551111"
}

Field reference

FieldTypeRequiredNotes
customer_idstringYes (or anonymous_id)Max 255 chars. Allowed: a-zA-Z0-9 - _ . @ + :
anonymous_idstringYes (or customer_id)Use when the user is not yet identified
eventstringYes (track only)Max 100 chars. Allowed: a-zA-Z0-9 _ - and spaces
traitsobjectNo (identify)Max 32 KB, 100 keys, nesting depth 5
propertiesobjectNo (track)Max 32 KB, 100 keys, nesting depth 5
contextobjectNoExtra context such as user_agent or ip
timestampISO 8601NoClient-side timestamp; defaults to server time
message_idstringNoClient-provided ID for deduplication

Error responses

Errors follow a consistent shape:

{
  "error": "bad_request",
  "message": "customer_id: exceeds maximum length of 255 characters"
}

Common HTTP status codes:

StatusMeaning
202Accepted — command queued
400Validation error
401Missing, invalid, or revoked API key
429Rate limit exceeded (retry after the period indicated in the response)

4. Verify It Worked

  1. Go back to the platform UI at http://localhost:3010.
  2. In the sidebar, click Customers.
  3. The customer list shows Jane Doe with the external ID user_001.
  4. Click the customer row to open the detail page.
  5. The Attributes section displays the traits you sent (email, name, plan).
  6. Scroll down to the Events timeline. The purchase event appears with its properties (item, amount, currency).

5. Create Your First Segment

Segments group customers that match a condition. Membership is evaluated automatically and kept up to date as traits and events change.

  1. In the sidebar, click Segments, then click New Segment.
  2. Fill in the form:
    • Name: Pro Users
    • Description: Customers on the pro plan
    • Condition (JSON):
      {
        "type": "trait",
        "name": "plan",
        "op": "equals",
        "value": "pro"
      }
      
  3. Click Create Segment.
  4. Open the segment detail page. Jane Doe appears as a member because her plan trait equals pro.

Condition operators

Trait conditions support these operators:

OperatorDescription
equals / not_equalsExact match
greater_than / less_thanNumeric comparison
greater_than_or_equals / less_than_or_equalsNumeric range
contains / not_containsSubstring or array membership
starts_with / ends_withString prefix / suffix
in / not_inMulti-value membership
is_set / is_not_setAttribute exists and is not null

Conditions can be composed with and, or, and not wrappers:

{
  "type": "and",
  "conditions": [
    { "type": "trait", "name": "plan", "op": "equals", "value": "pro" },
    { "type": "event", "event_name": "purchase" }
  ]
}

6. Create Your First Workflow

Workflows automate actions in response to triggers. In this section you create a workflow that sends a welcome email when the purchase event is tracked.

Create an email template

Before creating the workflow you need an email template.

  1. In the sidebar, click Email Templates, then click New Email Template.
  2. Enter a Name (for example Purchase Thank You) and click Create.
  3. On the next page, click Open Designer to open the visual email editor.
  4. Design your email using the drag-and-drop editor. At a minimum add a text block with a short thank-you message.
  5. Click Save in the designer, then go back to the template detail page and click Publish. Only published templates can be used in workflows.

Create the workflow

  1. In the sidebar, click Workflows, then click New Workflow.
  2. Fill in the form:
    • Name: Purchase Follow-Up
    • Description: Send a thank-you email after a purchase
    • Trigger Type: select Event Occurred
    • Event Name: purchase
    • Frequency: select Once per customer
  3. Click Create Workflow.

Add an email step

  1. On the workflow detail page, click Builder to open the visual canvas.
  2. Click Add Step and choose Email.
  3. In the step configuration panel, select the Purchase Thank You template you published earlier.
  4. Click Save on the step configuration.

Activate the workflow

  1. Go back to the workflow detail page.
  2. Click Activate. The workflow is now live.

From this point, any customer who triggers a purchase event for the first time will receive the thank-you email.


7. Next Steps

You now have a working project with customer data flowing in, a segment that groups customers, and an automated workflow.

Here are a few directions to explore:

  • API Integration Guide — deeper coverage of all CDP endpoints including page, merge, batch, and customer queries. See API Integration Guide.
  • OpenAPI specification — the full API contract lives in domains/cdp/openapi.yaml in the repository.
  • Segments — combine trait and event conditions with and / or / not logic, add time windows, and use count-based event conditions.
  • Workflows — add time delays, branching (true/false or multi-split), random cohorts for A/B testing, attribute updates, webhooks, and wait-until conditions.
  • Email Templates — use the visual designer with MJML to build responsive emails, configure UTM tracking, and manage open/click tracking.

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.

TypeScript SDK

The official TypeScript/JavaScript SDK for the Timonier CDP. It wraps the REST API with a type-safe client that handles retries, rate limits, batching, and deduplication automatically.

Package: timonier-sdk on npm

Features

  • TypeScript-first with full type definitions
  • Automatic retry with exponential backoff on 5xx and network errors
  • Rate limit handling with retry-after header support
  • Configurable request timeout (default 10s)
  • Auto-generated message_id (UUID v4) for event deduplication
  • Batch helper with auto-flush (size threshold + interval)
  • Zero external dependencies (uses native fetch)
  • ESM and CJS dual output

Requirements

Node.js 18+ (or any runtime with global fetch and crypto.randomUUID).

Installation

npm install timonier-sdk

Quick Start

import { Timonier } from "timonier-sdk";

const client = new Timonier({
  apiKey: "your-api-key",
});

await client.identify({
  customerId: "user-123",
  traits: { email: "jane@example.com", name: "Jane Doe" },
});

await client.track({
  customerId: "user-123",
  event: "Button Clicked",
  properties: { button: "signup", page: "/pricing" },
});

await client.page({
  customerId: "user-123",
  url: "https://example.com/pricing",
  title: "Pricing",
  referrer: "https://google.com",
});

Configuration

const client = new Timonier({
  apiKey: "your-api-key",       // Required
  host: "https://cdp.timonier.eu", // API base URL
  timeout: 10_000,              // Request timeout in ms
  maxRetries: 3,                // Max retries on 5xx/network errors
  flushAt: 100,                 // Auto-flush batch queue at N items
  flushInterval: 5_000,         // Auto-flush interval in ms
  fetchFn: fetch,               // Custom fetch implementation
  onError: (err) => {           // Called on background flush failures
    console.error("Flush failed:", err);
  },
});
OptionTypeDefaultDescription
apiKeystringrequiredAPI key for authentication (sent as Bearer token).
hoststringhttps://cdp.timonier.euBase URL of the Timonier CDP instance.
timeoutnumber10000Request timeout in milliseconds.
maxRetriesnumber3Maximum retry attempts on 5xx or network errors.
flushAtnumber100Number of queued items that triggers an automatic batch flush.
flushIntervalnumber5000Interval in milliseconds between automatic batch flushes.
fetchFntypeof fetchfetchCustom fetch implementation for testing or non-standard runtimes.
onError(err: unknown) => voidCalled when a background batch flush fails silently.

API Reference

client.identify(params)

Create or update a customer profile. Calls POST /v1/identify.

await client.identify({
  customerId: "user-123",
  traits: {
    email: "jane@example.com",
    name: "Jane Doe",
    plan: "pro",
  },
});
ParameterTypeRequiredDescription
customerIdstringYesUnique customer ID. Max 255 chars.
traitsRecord<string, unknown>NoCustomer attributes. Defaults to {}.

client.track(params)

Track a custom event. Calls POST /v1/track.

await client.track({
  customerId: "user-123",
  event: "Purchase Completed",
  properties: { amount: 49.99, currency: "USD" },
  timestamp: new Date("2026-01-15T10:30:00Z"),
});
ParameterTypeRequiredDescription
customerIdstringConditionalRequired if anonymousId is not provided.
anonymousIdstringConditionalRequired if customerId is not provided.
eventstringYesEvent name. Max 100 chars.
propertiesRecord<string, unknown>NoEvent-specific data.
contextRecord<string, unknown>NoContextual metadata (user agent, IP, etc.).
timestampDateNoWhen the event occurred. Defaults to server time.
messageIdstringNoDeduplication ID. Auto-generated (UUID v4) if omitted.

client.page(params)

Track a page view. Calls POST /v1/page.

await client.page({
  anonymousId: "anon-456",
  url: "https://example.com/blog/post-1",
  title: "Blog Post 1",
  referrer: "https://google.com",
});
ParameterTypeRequiredDescription
customerIdstringConditionalRequired if anonymousId is not provided.
anonymousIdstringConditionalRequired if customerId is not provided.
urlstringYesPage URL. Max 2048 chars.
titlestringNoPage title. Max 500 chars.
referrerstringNoReferrer URL. Max 2048 chars.
propertiesRecord<string, unknown>NoAdditional page metadata.
contextRecord<string, unknown>NoContextual metadata.
timestampDateNoWhen the page was viewed. Defaults to server time.
messageIdstringNoDeduplication ID. Auto-generated if omitted.

client.batch(items)

Send multiple operations in a single request. Calls POST /v1/batch.

const result = await client.batch([
  { type: "identify", customer_id: "user-1", traits: { name: "Jane" } },
  { type: "track", customer_id: "user-1", event: "Signup" },
  { type: "page", customer_id: "user-1", url: "https://example.com" },
]);

console.log(result.accepted); // 3
console.log(result.rejected); // 0

The batch accepts between 1 and 100 items. Each item must include a type field ("identify", "track", or "page") and the fields required by that operation. Field names use snake_case to match the REST API (e.g. customer_id, anonymous_id, message_id).


Auto-Batching

The SDK includes a built-in queue that buffers items and flushes them automatically via the batch endpoint. This reduces HTTP overhead for high-volume server-side ingestion.

client.enqueue({ type: "track", customer_id: "user-1", event: "Click" });
client.enqueue({ type: "track", customer_id: "user-2", event: "Click" });

Items are flushed when the queue reaches flushAt (default 100) or every flushInterval milliseconds (default 5 seconds), whichever comes first.

Manual flush

await client.flush();

Returns null if the queue is empty.

Graceful shutdown

Call shutdown() before your process exits to flush remaining items and stop the automatic flush timer:

await client.shutdown();

During shutdown, the SDK retries failed flushes up to 3 times. If retries are exhausted, the onError callback is called with the error and the affected items are dropped.


Error Handling

The SDK throws typed errors that all extend a common TimonierError base class. Client-side validation errors are thrown before any network call is made.

import {
  TimonierError,
  InputValidationError,
  ValidationError,
  AuthenticationError,
  RateLimitError,
  ServerError,
} from "timonier-sdk";

try {
  await client.track({ customerId: "user-1", event: "test" });
} catch (err) {
  if (err instanceof InputValidationError) {
    // Client-side validation failed (before network call)
    console.error(err.field, err.message);
  } else if (err instanceof ValidationError) {
    // Server returned 400 Bad Request
    console.error(err.body);
  } else if (err instanceof AuthenticationError) {
    // Server returned 401 Unauthorized
    console.error(err.body);
  } else if (err instanceof RateLimitError) {
    // Server returned 429 Too Many Requests
    console.error("Retry after", err.retryAfter, "seconds");
  } else if (err instanceof ServerError) {
    // Server returned 5xx (after all retries exhausted)
    console.error(err.status, err.body);
  }
}

Error hierarchy

ErrorConditionNotable fields
TimonierErrorBase class for all SDK errorsmessage
InputValidationErrorInvalid input caught before the network callfield, message
ValidationErrorServer returned 400status, body
AuthenticationErrorServer returned 401status, body
RateLimitErrorServer returned 429status, body, retryAfter
ServerErrorServer returned 5xx (after retries)status, body

All errors are instances of TimonierError, so you can use a single catch (err) { if (err instanceof TimonierError) { ... } } to handle every SDK error uniformly.

Retry behavior

The SDK automatically retries on 5xx errors and transient network failures (e.g. TypeError, timeout) with exponential backoff and jitter, up to maxRetries attempts (default 3). On 429 Too Many Requests, the SDK respects the retry-after header before retrying.

Non-retryable errors (400, 401) are thrown immediately.


Examples

Express middleware

import express from "express";
import { Timonier } from "timonier-sdk";

const app = express();
const client = new Timonier({ apiKey: process.env.TIMONIER_API_KEY! });

app.use(express.json());

app.post("/api/checkout", async (req, res) => {
  await client.track({
    customerId: req.user.id,
    event: "Checkout Completed",
    properties: { total: req.body.total },
  });
  res.json({ ok: true });
});

process.on("SIGTERM", async () => {
  await client.shutdown();
  process.exit(0);
});

app.listen(3000);

Next.js server action

"use server";

import { Timonier } from "timonier-sdk";

const client = new Timonier({ apiKey: process.env.TIMONIER_API_KEY! });

export async function trackSignup(userId: string) {
  await client.identify({
    customerId: userId,
    traits: { signedUpAt: new Date().toISOString() },
  });

  await client.track({
    customerId: userId,
    event: "Signup Completed",
  });
}

High-volume ingestion with auto-batching

import { Timonier } from "timonier-sdk";

const client = new Timonier({
  apiKey: process.env.TIMONIER_API_KEY!,
  flushAt: 50,
  flushInterval: 2_000,
  onError: (err) => console.error("Batch flush failed:", err),
});

for (const event of incomingEvents) {
  client.enqueue({
    type: "track",
    customer_id: event.userId,
    event: event.name,
    properties: event.data,
    message_id: event.id,
  });
}

// Flush remaining events before exiting
await client.shutdown();

Migrating from raw HTTP calls

If you are currently calling the REST API directly with fetch, switching to the SDK is straightforward. The SDK handles authentication headers, JSON serialization, retries, and deduplication for you.

Before (raw fetch):

const res = await fetch("https://cdp.timonier.eu/v1/track", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customer_id: "user-123",
    event: "order_completed",
    properties: { total: 99.99 },
    message_id: crypto.randomUUID(),
  }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);

After (SDK):

await client.track({
  customerId: "user-123",
  event: "order_completed",
  properties: { total: 99.99 },
});

The SDK generates message_id automatically, retries on transient failures, and throws typed errors instead of requiring manual status code checks.