Docs Go to app →

Ingestion API

The ingestion API is how your SaaS app reports who its users are and what they do, so letter.app can enroll them in drip sequences. It’s modeled on Segment’s spec (identify / group / track / batch) so adopting it later as a Segment destination is a thin auth shim, not a redesign.

  • Base URL: https://api.letter.app
  • All endpoints are POST, JSON in, JSON out, return 202 Accepted on success.

Using Node.js? The Node SDK wraps this API with batching, retries, and idempotency — most users should reach for it before calling the HTTP API directly.

Authentication

Send your project’s API key as a Bearer token:

Authorization: Bearer lt_live_<48 hex characters>

Keys are created in Dashboard → Settings → Project tokens and shown once on creation. Letter stores only a SHA-256 hash; if you lose a key, revoke it and create a new one.

A request without a valid, unrevoked key returns 401 unauthorized.

Endpoints

POST /v1/identify

Create or update a contact. Email is required (top-level or in traits).

{
  "userId": "user_alice",          // required — your app's id for the user
  "email": "alice@example.com",    // required (here or in traits.email)
  "traits": {                      // optional — merged into existing traits
    "name": "Alice Ada",
    "plan": "free"
  },
  "timezone": "Europe/Paris",      // optional, IANA name
  "timestamp": "2026-05-16T09:00:00Z", // optional, defaults to now
  "messageId": "msg_..."           // optional, idempotency key
}

Response 202

{ "ok": true, "contactId": "<uuid>" }

Semantics

  • Upserts on (projectId, userId) — calling identify twice with the same userId updates the existing contact.
  • traits are deep-merged at the top level server-side (existing || incoming in JSONB). To remove a trait, set it to null and prune client-side later.
  • Email must be unique per project; sending a different email for the same userId updates the contact’s email.

POST /v1/group

Create or update an account, and link the calling contact to it.

{
  "userId": "user_alice",          // required — the contact to link
  "accountId": "acct_acme",        // required — your app's id for the account
  "name": "Acme Inc.",             // optional
  "traits": { "plan": "team", "mrr": 199 },
  "timestamp": "...",              // optional
  "messageId": "msg_..."           // optional
}

Response 202

{ "ok": true, "accountId": "<uuid>" }

Semantics

  • Upserts the account on (projectId, accountId).
  • Sets contacts.accountId on the matching contact (no-op if the contact doesn’t exist yet — call identify first or use /v1/batch so they arrive in order).
  • traits merged the same way as identify.

POST /v1/track

Record an event. This is what sequences listen to.

{
  "userId": "user_alice",          // required
  "event": "Workspace Created",    // required, max 128 chars
  "properties": { "workspaceId": "ws_1" },
  "timestamp": "...",              // optional, defaults to now (occurredAt)
  "messageId": "msg_..."           // optional, idempotency key
}

Response 202

{ "ok": true, "deduplicated": false }

deduplicated: true means the event was a no-op because another event with the same (projectId, messageId) already exists.

Semantics

  • The contact is looked up by userId; if it doesn’t exist, the event is still recorded (with contactId = null) so you can identify later without losing events. The externalUserId column always holds your id.
  • occurredAt is what the client reports (or now), receivedAt is when the server stored it. Triggers match on occurredAt; “wait-for-event” matches on receivedAt so old events don’t retroactively resolve waits.

POST /v1/batch

Send up to 100 mixed items in one request.

{
  "batch": [
    { "type": "identify", "userId": "user_alice", "email": "...", "traits": {} },
    { "type": "group",    "userId": "user_alice", "accountId": "acct_acme" },
    { "type": "track",    "userId": "user_alice", "event": "Signed Up" }
  ]
}

Response 202

{
  "ok": true,
  "results": [
    { "type": "identify", "ok": true, "contactId": "..." },
    { "type": "group",    "ok": true, "accountId": "..." },
    { "type": "track",    "ok": true, "deduplicated": false }
  ]
}

Items are processed sequentially in order, so it’s safe to put identify before group/track in the same batch. Per-item errors don’t abort the batch — each entry’s ok field indicates success.

This is the primary path used by the SDK and by backend imports.

Idempotency

Every endpoint accepts an optional messageId (8–64 chars). For track, a unique index on (project_id, message_id) ensures the event is inserted at most once per project, so SDK retries after a network blip don’t double-count.

For identify / group, the upserts are idempotent on the external id; the messageId is informational and reserved for future analytics.

If you don’t supply a messageId, the SDK generates a UUID for you.

Rate limiting

Per-API-key fixed-window in Redis: 10 000 requests / minute / key. Over the limit returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "error": { "code": "rate_limited", "message": "...", "retryAfter": 37 } }

The SDK respects Retry-After and retries automatically.

Body size

Hard cap 512 KB per request. Over that returns 413 payload_too_large. Practical guidance: keep individual events well under 32 KB; trim properties to fewer than ~50 keys.

Error format

All errors share one shape:

{
  "error": {
    "code": "bad_request",
    "message": "Validation failed.",
    "issues": [
      { "path": "userId", "message": "String must contain at least 1 character(s)" }
    ]
  }
}
CodeHTTPWhen
bad_request400Malformed JSON, missing required field, Zod failure
unauthorized401Missing or invalid Bearer token
payload_too_large413Body > 512 KB
rate_limited429Per-key minute limit exceeded (Retry-After set)
internal_error500Unexpected — logged server-side

Clients

There’s an official Node.js client — see Node SDK. Other languages can hit the HTTP API directly; auth, idempotency, and rate limits behave identically.

Bootstrapping an existing user base

If you’re connecting an existing SaaS for the first time and need to backfill your current users and accounts, see Initial import for a checkpointed, re-runnable script template built on top of /v1/batch.