Docs Go to app →

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 login mints 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

ActionRequired role
Read + write project resources (contacts, sequences, broadcasts, templates, segments)member
Create / delete projects, manage members and invitations, domains, keys, sending settingsadmin
Everythingowner

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: 200 reads/updates, 201 creates, 202 async accepted (CSV import), 204 deletes and other no-content actions.
  • JSON in, JSON out. Send Content-Type: application/json on bodies. Multipart is used only for file uploads (CSV imports, template logos).
  • Field names are snake_case in 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." } }
CodeHTTPWhen
bad_request400Malformed JSON or failed validation
unauthorized401Missing or invalid token
forbidden403Token’s role is too low for the action
not_found404Unknown project, resource, or route
conflict409State conflict (e.g. stale sequence revision)
payload_too_large413Upload exceeds the limit
rate_limited429Per-token limit exceeded (Retry-After set)
internal_error500Unexpected — 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 designTemplate.

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.

multipart/form-data with a file field. Returns { "url": "https://..." }.

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