Management API
The Management API is the control-plane behind the Letter dashboard. Everything you can do by clicking around the app, you can do over HTTP: create projects, manage contacts and segments, edit and publish sequences, schedule broadcasts, design templates, and configure sending domains.
It’s separate from the Ingestion API (identify / track / group /
batch), which is the high-throughput data-plane your backend reports users and
events to. The Ingestion API uses a project key (lt_live_*); the Management
API uses a workspace Personal Access Token (lt_pat_*).
- Base URL:
https://api.letter.app - Machine-readable spec:
/v1/openapi.json(OpenAPI 3.1)
Most people drive this API through the CLI rather than calling it by hand.
letter loginmints and stores the token for you, and every command supports--json.
Authentication
Send a Personal Access Token as a Bearer token:
Authorization: Bearer lt_pat_<48 hex characters>
A PAT is scoped to a single workspace and carries the role you have in that
workspace (member, admin, or owner). Mint one with letter login (the CLI
device flow) or POST /v1/tokens. The raw value is shown once; Letter stores
only a SHA-256 hash. Revoke a token any time with DELETE /v1/tokens/{id} or from
the dashboard.
A request without a valid, unrevoked token returns 401 unauthorized.
Roles
| Action | Required role |
|---|---|
| Read + write project resources (contacts, sequences, broadcasts, templates, segments) | member |
| Create / delete projects, manage members and invitations, domains, keys, sending settings | admin |
| Everything | owner |
Calls that exceed your role return 403 forbidden.
Resource scoping
Workspace-level resources live directly under /v1 (/v1/me, /v1/projects,
/v1/members, /v1/invitations, /v1/tokens).
Project-level resources are nested under the project slug:
/v1/projects/{slug}/.... The slug is resolved within the token’s workspace, so
you can’t reach another tenant’s data by guessing slugs.
Conventions
- Status codes:
200reads/updates,201creates,202async accepted (CSV import),204deletes and other no-content actions. - JSON in, JSON out. Send
Content-Type: application/jsonon bodies. Multipart is used only for file uploads (CSV imports, template logos). - Field names are
snake_casein both requests and responses.
Pagination
List endpoints return a cursor envelope:
{ "data": [ /* ... */ ], "next_cursor": "b3BhcXVl..." }
Pass ?limit= (1–100, default 50) and ?cursor= (the opaque next_cursor from
the previous page). When next_cursor is null, you’ve reached the end. Cursors
are keyset-based over (created_at, id), so they’re stable under inserts.
Idempotency
Create and send endpoints accept an Idempotency-Key header. The first
response for a (token, key) pair is cached for 24h and replayed on retries
(with an Idempotent-Replayed: true header), so a dropped connection never
double-creates or double-sends.
Rate limiting
Per-token fixed-window limiting. Over the limit returns 429 with a
Retry-After header:
{ "error": { "code": "rate_limited", "message": "...", "retryAfter": 12 } }
Error format
All errors share one shape:
{ "error": { "code": "not_found", "message": "No project \"acme\" in workspace." } }
| Code | HTTP | When |
|---|---|---|
bad_request | 400 | Malformed JSON or failed validation |
unauthorized | 401 | Missing or invalid token |
forbidden | 403 | Token’s role is too low for the action |
not_found | 404 | Unknown project, resource, or route |
conflict | 409 | State conflict (e.g. stale sequence revision) |
payload_too_large | 413 | Upload exceeds the limit |
rate_limited | 429 | Per-token limit exceeded (Retry-After set) |
internal_error | 500 | Unexpected — logged server-side |
Shared types
A few JSON shapes are reused across endpoints.
FilterSpec
The segment filter tree the dashboard builds. A discriminated union on kind:
// group — combine children with and/or (nestable)
{ "kind": "group", "op": "and", "children": [ /* FilterSpec[] */ ] }
// trait — match a contact trait
{ "kind": "trait", "path": "plan", "op": "neq", "value": "free" }
// event — did / didn't do an event, optionally within a window
{ "kind": "event", "eventName": "Signed In", "did": true,
"window": { "value": 7, "unit": "day" } }
// suppression — include or exclude suppressed contacts
{ "kind": "suppression", "includesSuppressed": false }
trait.op is one of eq, neq, contains, not_contains, gt, gte,
lt, lte, exists, not_exists (value is omitted for the last two).
window.unit is hour or day.
Audience
A broadcast’s audience is either a saved segment or an inline filter:
{ "kind": "segment", "segmentId": "<uuid>" }
{ "kind": "inline", "filter": { /* FilterSpec */ } }
Sequence trigger and graph
// trigger — what enrolls a contact. Both arms take an optional `filter`
// (a FilterSpec on the contact) that gates enrolment. Event triggers also take
// an optional `where`: conditions on the firing event's own properties (single
// object or array, AND-ed). Trait/event values compare as strings.
{ "type": "event", "eventName": "Signed Up", "oncePerContact": true,
"where": [{ "property": "plan", "op": "eq", "value": "free" }] }
{ "type": "contact_created", "filter": { /* FilterSpec, optional */ } }
// graph — the flow editor's nodes + edges
{
"nodes": [
{ "id": "n1", "type": "email", "position": { "x": 0, "y": 0 },
"config": { "type": "email", "subject": "Welcome", "bodyDoc": { /* TipTap doc */ } } }
],
"edges": [ { "id": "e1", "source": "n1", "target": "n2", "branch": "yes" } ]
}
Node type is trigger, email, wait, wait_event, branch, or exit,
and config.type must match it. The email body is bodyDoc; a wait nests its
duration ({ value, unit }, unit minutes/hours/days); a wait_event
pauses until its eventName fires or its timeout elapses. edge.branch is
only set on edges leaving a two-leg node: yes/no for a branch,
received/timeout for a wait_event. The draft endpoint validates these
shapes strictly. See the Sequences guide for every node
config, branch conditions, validation rules, and a full worked example.
Identity
GET /v1/me
The authenticated user, their workspace, and role.
{
"user": { "id": "<uuid>", "email": "you@acme.com", "name": "You" },
"workspace": { "id": "<uuid>", "name": "Acme" },
"role": "owner"
}
Personal access tokens
These are the lt_pat_* credentials that authenticate this API and the CLI. The
dashboard labels them API keys; the CLI manages them with the api-keys
group. A token object never includes the secret:
{
"id": "<uuid>",
"name": "laptop",
"prefix": "lt_pat_ab12cd34",
"last_used_at": "2026-05-16T09:00:00.000Z",
"expires_at": null,
"created_at": "2026-05-16T09:00:00.000Z"
}
GET /v1/tokens
List your active tokens in this workspace. Returns { "data": [Token] }.
POST /v1/tokens
Mint a token. The raw token is returned once.
// request
{ "name": "ci", "expires_at": "2027-01-01T00:00:00Z" } // expires_at optional
// response 201
{ "id": "<uuid>", "name": "ci", "prefix": "lt_pat_ab12cd34",
"token": "lt_pat_<48 hex>" }
DELETE /v1/tokens/{id}
Revoke one of your tokens. 204 No Content.
Members admin+
GET /v1/members
List workspace members. Returns { "data": [Member] }:
{ "user_id": "<uuid>", "email": "teammate@acme.com", "name": "Teammate",
"role": "member", "joined_at": "2026-05-16T09:00:00.000Z" }
POST /v1/members
Invite someone. Membership is created when they accept the emailed invitation, so this returns the pending invitation.
// request
{ "email": "new@acme.com", "role": "member" } // role: "member" (default) | "admin"
// response 201
{ "invitation_id": "<uuid>", "email": "new@acme.com",
"role": "member", "status": "pending" }
A duplicate (already a member or already invited) returns 409 conflict.
DELETE /v1/members/{userId}
Remove a member. 204 No Content.
Invitations admin+
GET /v1/invitations
List pending invitations. Returns { "data": [Invitation] }:
{ "id": "<uuid>", "email": "new@acme.com", "role": "member",
"status": "pending", "expires_at": "2026-05-23T09:00:00.000Z",
"created_at": "2026-05-16T09:00:00.000Z" }
POST /v1/invitations
Same body and semantics as POST /v1/members; returns the invitation as
{ "id", "email", "role", "status" }.
DELETE /v1/invitations/{id}
Revoke a pending invitation. 204 No Content.
Projects
A project object:
{
"slug": "acme",
"name": "Acme",
"timezone": "Europe/Paris",
"sending_mode": "letter_subdomain",
"sending_status": "active",
"assigned_subdomain": "acme",
"from_name": "Acme",
"from_email": "hello@acme.com",
"reply_to_email": null,
"daily_send_cap": 10000,
"created_at": "2026-05-16T09:00:00.000Z",
"updated_at": "2026-05-16T09:00:00.000Z"
}
GET /v1/projects
List projects in the workspace. Returns { "data": [Project] }.
POST /v1/projects admin+
Create a project. Runs onboarding (default templates, assigned subdomain).
// request
{ "name": "Acme", "timezone": "Europe/Paris" } // timezone optional, defaults UTC
// response 201 → Project
GET /v1/projects/{slug}
Get one project → Project.
PATCH /v1/projects/{slug}
Update name and/or timezone. Renaming changes the slug, so use the slug
from the response for subsequent calls.
// request — both optional
{ "name": "Acme Inc", "timezone": "America/New_York" }
// response 200 → Project (with the new slug)
DELETE /v1/projects/{slug} admin+
Delete a project. This cascades to all its contacts, sequences, broadcasts,
and templates. 204 No Content.
Contacts
A contact in list responses:
{
"id": "<uuid>",
"external_id": "user_alice",
"email": "alice@example.com",
"traits": { "name": "Alice", "plan": "free" },
"account_id": "<uuid|null>",
"unsubscribed_at": null,
"unsubscribe_reason": null,
"created_at": "2026-05-16T09:00:00.000Z",
"updated_at": "2026-05-16T09:00:00.000Z"
}
Contacts are created through the Ingestion API (
identify), not the Management API. The Management API reads, segments, suppresses, and imports them.
GET /v1/projects/{slug}/contacts
Cursor-paginated list (?limit=, ?cursor=). Returns
{ "data": [Contact], "next_cursor": "..." | null }.
GET /v1/projects/{slug}/contacts/{externalId}
A single contact by your external id, enriched with its account and recent events:
{
"id": "<uuid>", "external_id": "user_alice", "email": "alice@example.com",
"traits": { /* ... */ }, "timezone": "Europe/Paris",
"unsubscribed_at": null, "unsubscribe_reason": null,
"created_at": "...", "updated_at": "...",
"account": { "id": "<uuid>", "external_id": "acct_acme", "name": "Acme" },
"recent_events": [ { /* Event */ } ]
}
POST /v1/projects/{slug}/contacts/{externalId}/suppress
Suppress (unsubscribe) a contact. 204 No Content.
POST /v1/projects/{slug}/contacts/{externalId}/resubscribe
Clear suppression. 204 No Content.
Suppressions
GET /v1/projects/{slug}/suppressions
Cursor-paginated list of suppressed contacts. Each item:
{ "id": "<uuid>", "external_id": "user_alice", "email": "alice@example.com",
"unsubscribed_at": "...", "unsubscribe_reason": "manual", "created_at": "..." }
POST /v1/projects/{slug}/suppressions
Suppress by email. The email must belong to an existing contact.
// request
{ "email": "alice@example.com" }
// response 201 → Suppression
DELETE /v1/projects/{slug}/suppressions/{email}
Remove an email from the suppression list (resubscribe). 204 No Content.
Accounts
{ "id": "<uuid>", "external_id": "acct_acme", "name": "Acme",
"traits": { "plan": "team" }, "created_at": "...", "updated_at": "..." }
GET /v1/projects/{slug}/accounts
Cursor-paginated list of accounts.
GET /v1/projects/{slug}/accounts/{externalId}
One account, with its linked contacts appended:
{ /* Account fields */,
"contacts": [ { "id": "<uuid>", "external_id": "user_alice",
"email": "alice@example.com", "unsubscribed_at": null } ] }
Events
GET /v1/projects/{slug}/events
Cursor-paginated event stream. Filter by name with ?name=. Each item:
{ "id": "<uuid>", "name": "Signed In", "properties": { /* ... */ },
"contact_id": "<uuid|null>", "account_id": "<uuid|null>",
"external_user_id": "user_alice",
"occurred_at": "...", "received_at": "..." }
GET /v1/projects/{slug}/traits
The distinct top-level trait keys seen across the project’s contacts and
accounts, with the most common JSON type, how many records carry each, and a
sample value. Use it to discover what you can filter on without dumping
contacts (CLI: letter traits list). Remember filter/where values compare as
strings.
{ "data": [
{ "key": "plan", "scope": "contact", "type": "string", "count": 142, "sample": "free" },
{ "key": "isPro", "scope": "contact", "type": "boolean", "count": 142, "sample": "false" }
] }
CSV imports
POST /v1/projects/{slug}/contacts/imports
multipart/form-data upload. Fields: file (the CSV), mapping (a JSON
string mapping each CSV column to a contact field), optional dedupe
(update (default) or skip), optional rowCount.
// response 202
{ "id": "<uuid>", "status": "queued" }
GET /v1/projects/{slug}/contacts/imports/{jobId}
Import progress (status, processed/total counts, error tallies).
GET /v1/projects/{slug}/contacts/imports/{jobId}/error-report
A short-lived signed URL to download the row-level error report:
{ "url": "https://..." }.
Segments
{ "id": "<uuid>", "name": "Paid users", "filter": { /* FilterSpec */ },
"created_at": "...", "updated_at": "..." }
GET /v1/projects/{slug}/segments
List all segments → { "data": [Segment] }.
POST /v1/projects/{slug}/segments
// request
{ "name": "Paid users", "filter": { /* FilterSpec */ } }
// response 201 → Segment
An invalid filter returns 400 bad_request.
POST /v1/projects/{slug}/segments/preview
Count contacts matching a filter without saving it.
// request
{ "filter": { /* FilterSpec */ } }
// response 200
{ "matches": 142, "total": 3500 }
GET /v1/projects/{slug}/segments/{id}
Get one segment → Segment.
PATCH /v1/projects/{slug}/segments/{id}
Update name and/or filter (both optional) → Segment.
DELETE /v1/projects/{slug}/segments/{id}
204 No Content.
Sequences
{
"id": "<uuid>", "name": "Onboarding", "status": "active",
"trigger": { /* SequenceTrigger */ },
"draft_revision": 7, "published_version_id": "<uuid|null>",
"created_at": "...", "updated_at": "..."
}
GET /v1/projects/{slug}/sequences/{id} additionally returns draft_graph
(the editable graph).
GET /v1/projects/{slug}/sequences
List → { "data": [Sequence] }.
POST /v1/projects/{slug}/sequences
Create a draft sequence (name optional) → 201 Sequence.
GET /v1/projects/{slug}/sequences/{id}
Get one, including draft_graph.
PATCH /v1/projects/{slug}/sequences/{id}
Rename or archive.
// request — both optional
{ "name": "New name", "status": "archived" } // status: "active" | "archived"
DELETE /v1/projects/{slug}/sequences/{id}
204 No Content.
PUT /v1/projects/{slug}/sequences/{id}/draft
Save the working graph and trigger. Optimistic concurrency: send the
expected_revision you last read; a stale write returns 409 conflict with
the current revision.
// request
{ "graph": { /* Graph */ }, "trigger": { /* SequenceTrigger */ },
"expected_revision": 7 }
// response 200
{ "revision": 8, "updated_at": "..." }
POST /v1/projects/{slug}/sequences/{id}/publish
Snapshot the current draft into a new immutable version. Validation failures
return 400 with an errors array.
// response 201
{ "version_number": 3 }
GET /v1/projects/{slug}/sequences/{id}/validate
Dry-run the publish checks against the current draft without shipping. Returns
the same errors publish would, so you can confirm a graph is shippable first
(CLI: letter sequences validate <id>).
// response 200
{ "ok": false,
"errors": [{ "nodeId": "email1", "message": "Email has no body content." }] }
POST /v1/projects/{slug}/sequences/{id}/preview
Render an email node’s content to HTML/text (diagnostic — nothing is sent).
config is optional: omit it and the saved draft config for node_id is
rendered (so you can preview exactly what will publish). When you do pass
config, use camelCase (bodyDoc, templateId); snake_case is still accepted
for back-compat.
// request — explicit config
{ "node_id": "n1",
"config": { "subject": "Welcome", "preview": "...",
"bodyDoc": { /* TipTap */ }, "templateId": null },
"recipient_email": "you@acme.com" } // optional, defaults to the token user
// request — use the saved draft node config
{ "node_id": "n1" }
// response 200
{ "html": "<!doctype html>...", "text": "Welcome ..." }
POST /v1/projects/{slug}/sequences/{id}/test-email
Send a [Test] copy of an email node. Same body as preview (config optional,
falls back to the saved draft node config).
// response 200
{ "message_id": "<id>", "to": "you@acme.com" }
GET /v1/projects/{slug}/sequences/{id}/activity
Current enrollments plus 24-hour tallies for the sequence.
GET /v1/projects/{slug}/sequences/{id}/versions
Published version history → { "data": [SequenceVersion] }:
{ "id": "<uuid>", "version_number": 3, "trigger": { /* ... */ },
"graph": { /* ... */ }, "published_by": "<uuid|null>", "published_at": "..." }
Broadcasts
{
"id": "<uuid>", "name": "May newsletter", "status": "draft",
"subject": "Hello", "preview": "...", "audience": { /* Audience */ },
"template_id": "<uuid|null>", "from_email": "hello@acme.com",
"scheduled_at": null, "completed_at": null, "cancelled_at": null,
"created_at": "...", "updated_at": "..."
}
GET /v1/projects/{slug}/broadcasts/{id} additionally returns body_doc (the
composed TipTap document).
GET /v1/projects/{slug}/broadcasts
List → { "data": [Broadcast] }.
POST /v1/projects/{slug}/broadcasts
Create a draft (name optional) → 201 Broadcast.
GET /v1/projects/{slug}/broadcasts/{id}
Get one, including body_doc.
PATCH /v1/projects/{slug}/broadcasts/{id}
Edit a draft. All fields optional.
{ "name": "May newsletter", "subject": "Hello", "preview": "...",
"audience": { /* Audience */ }, "template_id": "<uuid>",
"body_doc": { /* TipTap */ } }
DELETE /v1/projects/{slug}/broadcasts/{id}
204 No Content.
POST /v1/projects/{slug}/broadcasts/{id}/preflight
Dry-run the send checks (subject/body present, audience resolves and is non-empty).
// response 200
{ "matches": 142, "total": 3500, "subject": "Hello" }
POST /v1/projects/{slug}/broadcasts/{id}/schedule
Schedule for later, or send now by passing null.
// request
{ "scheduled_at": "2026-06-01T09:00:00Z" } // null → send immediately
// response 200 → Broadcast
POST /v1/projects/{slug}/broadcasts/{id}/cancel
Cancel a scheduled or in-flight broadcast → 200 Broadcast.
GET /v1/projects/{slug}/broadcasts/{id}/live
Live status, aggregate stats, and recent per-recipient activity.
{
"status": "sending",
"scheduled_at": "...", "completed_at": null, "cancelled_at": null,
"stats": { /* sent, delivered, opened, ... */ },
"activity": [ { "id": "<uuid>", "state": "delivered", "reason": null,
"finished_at": "...", "contact_email": "alice@example.com",
"contact_id": "<uuid>" } ]
}
Templates
{ "id": "<uuid>", "name": "Default", "is_default": true,
"design": { /* design JSON */ }, "created_at": "...", "updated_at": "..." }
GET /v1/projects/{slug}/templates
List → { "data": [Template] }.
POST /v1/projects/{slug}/templates
Create (name and design optional) → 201 Template.
GET /v1/projects/{slug}/templates/{id}
Get one → Template.
PATCH /v1/projects/{slug}/templates/{id}
Update name and/or design → Template.
DELETE /v1/projects/{slug}/templates/{id}
204 No Content.
POST /v1/projects/{slug}/templates/{id}/default
Make this the project’s default template. 204 No Content.
POST /v1/projects/{slug}/templates/{id}/logo
multipart/form-data with a file field. Returns { "url": "https://..." }.
DELETE /v1/projects/{slug}/templates/{id}/logo
Remove the logo. 204 No Content.
POST /v1/projects/{slug}/templates/{id}/reset
Reset the design to a preset.
{ "preset": "branded" } // "plain" | "branded"
Sending domains admin+
{ "id": "<uuid>", "domain": "mail.acme.com", "kind": "custom",
"status": "pending", "dkim_tokens": ["...", "...", "..."],
"verified_at": null, "last_checked_at": null,
"created_at": "...", "updated_at": "..." }
GET /v1/projects/{slug}/domains
List sending domains → { "data": [Domain] }.
POST /v1/projects/{slug}/domains
Register a custom domain. The response includes dkim_tokens — add the
matching CNAME records at your DNS provider, then verify.
// request
{ "domain": "mail.acme.com" }
// response 201 → Domain
POST /v1/projects/{slug}/domains/{id}/verify
Re-check DKIM verification → 200 Domain (with updated status).
DELETE /v1/projects/{slug}/domains/{id}
Deregister a custom domain. 204 No Content.
Sender identity admin+
PUT /v1/projects/{slug}/sender-identity
Set the From address, From name, and Reply-To.
// request
{ "from_email": "hello@acme.com", "from_name": "Acme",
"reply_to_email": "support@acme.com" } // from_name / reply_to_email optional
// response 200 → Project
Sending mode admin+
PUT /v1/projects/{slug}/sending-mode
Switch between Letter’s shared subdomain and your own verified domain.
// request
{ "sending_mode": "byo_domain" } // "letter_subdomain" | "byo_domain"
// response 200 → Project
Project ingestion keys
These are the lt_live_* keys the Ingestion API and SDKs
authenticate with, managed here. The dashboard labels them Project tokens;
the CLI manages them with the project-tokens group. Don’t confuse them with the
PAT you’re using to call this API.
{ "id": "<uuid>", "name": "Production server", "prefix": "lt_live_ab12cd34",
"scope": "ingest", "last_used_at": "...", "revoked_at": null,
"created_at": "..." }
GET /v1/projects/{slug}/keys
List active ingestion keys → { "data": [ApiKey] }.
POST /v1/projects/{slug}/keys admin+
Mint a new ingestion key. The raw key is returned once.
// request
{ "name": "Production server" } // optional, defaults to "Default"
// response 201
{ "id": "<uuid>", "name": "Production server",
"prefix": "lt_live_ab12cd34", "key": "lt_live_<48 hex>" }
DELETE /v1/projects/{slug}/keys/{id} admin+
Revoke an ingestion key. 204 No Content.
Example
Create a project, then a segment, with curl:
curl -s https://api.letter.app/v1/projects \
-H "Authorization: Bearer $LETTER_PAT" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "timezone": "Europe/Paris" }'
curl -s https://api.letter.app/v1/projects/acme/segments \
-H "Authorization: Bearer $LETTER_PAT" \
-H "Content-Type: application/json" \
-d '{
"name": "Paid, active last 7d",
"filter": {
"kind": "group", "op": "and", "children": [
{ "kind": "trait", "path": "plan", "op": "neq", "value": "free" },
{ "kind": "event", "eventName": "Signed In", "did": true,
"window": { "value": 7, "unit": "day" } }
]
}
}'
Or, far more conveniently, with the CLI:
letter projects create --name Acme --timezone Europe/Paris
letter segments create --project acme --name "Paid" --filter @filter.json --json