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 Acceptedon 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)— callingidentifytwice with the sameuserIdupdates the existing contact. traitsare deep-merged at the top level server-side (existing || incomingin JSONB). To remove a trait, set it tonulland prune client-side later.- Email must be unique per project; sending a different email for the same
userIdupdates 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.accountIdon the matching contact (no-op if the contact doesn’t exist yet — callidentifyfirst or use/v1/batchso they arrive in order). traitsmerged the same way asidentify.
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 (withcontactId = null) so you canidentifylater without losing events. TheexternalUserIdcolumn always holds your id. occurredAtis what the client reports (or now),receivedAtis when the server stored it. Triggers match onoccurredAt; “wait-for-event” matches onreceivedAtso 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)" }
]
}
}
| Code | HTTP | When |
|---|---|---|
bad_request | 400 | Malformed JSON, missing required field, Zod failure |
unauthorized | 401 | Missing or invalid Bearer token |
payload_too_large | 413 | Body > 512 KB |
rate_limited | 429 | Per-key minute limit exceeded (Retry-After set) |
internal_error | 500 | Unexpected — 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.