> Lettrove docs 1.x · https://docs.lettrove.com/docs/api/endpoints

# REST API

Base address `https://api.lettrove.com`. Every request carries your secret key, from your server only:

```http
Authorization: Bearer lt_sk_live_…
Content-Type: application/json
```

Conventions (errors, ids, times, limits) are on the [API overview](/docs/api). Path parameters are URL-encoded.
Request bodies are strict: a field the table does not list is refused, naming it. Answers may gain fields within v1: ignore ones you do not know.

## The OpenAPI document

This page is generated from the API's OpenAPI 3.1 document, which is generated from the server's own schemas and tested against every real answer. Use it to generate a client in any language, or import it into Postman, Insomnia or Bruno:

- JSON: [https://docs.lettrove.com/openapi/v1.json](https://docs.lettrove.com/openapi/v1.json)
- YAML: [https://docs.lettrove.com/openapi/v1.yaml](https://docs.lettrove.com/openapi/v1.yaml)

It covers these endpoints and the [webhook events](/docs/api/webhook-events). It has no "try it" console on purpose: the API takes your secret key and answers no browser.

## Create a token

```http
POST /embed/v1/tokens
```

Exchange your secret key for a short-lived editor token for one person on one site. Your page asks your server for it (`getToken`), and the editor opens with it. Never cached.

Limit: 600 requests a minute per key. Operation id: `createToken`.

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `user` | object | Yes | The person the editor opens for. |
| `user.id` | string | Yes | Your own id for the person: whatever you chose, up to 256 characters. Never a name or an email. |
| `origin` | string | Yes | The site the editor opens on, exactly as a browser states it: scheme, host and port, no path. It must be one of the project's allowed sites; a test key also opens on localhost. Length 1 to 2048. |
| `ttl` | integer |  | Seconds the token lasts, 60 to 900. Default 900. |

```json
{
  "user": {
    "id": "u_123"
  },
  "origin": "https://app.acme.com",
  "ttl": 900
}
```

**`200`** The token.

| Field | Type | Description |
|---|---|---|
| `token` | string | Hand it to the page, which passes it to the editor. Never store it. |
| `expiresAt` | string (date-time) | When it stops working. The page asks your server for a new one before then. |

```json
{
  "token": "eyJhbGciOiJFZERTQSIs…",
  "expiresAt": "2026-10-07T16:15:00.000Z"
}
```

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `400` | `request_invalid`: The body does not match; the message names the field. |
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project.<br/>`origin_not_allowed`: The origin is not one of the project's allowed sites for this key's environment. |
| `409` | `user_erased`: This person is being erased. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |

## List a person's designs

```http
GET /embed/v1/users/{userId}/designs
```

One person's designs, newest first, a page at a time. A person Lettrove has never seen has none.

Limit: 600 requests a minute per key. Operation id: `listDesigns`.

| Parameter | In | Type | Description |
|---|---|---|---|
| `userId` | path | string | Your own id for the person: whatever you chose, up to 256 characters. Never a name or an email. |
| `limit` | query | integer | Designs per page, 1 to 200. Default 50. |
| `cursor` | query | string | The `nextCursor` of the previous page. |

**`200`** A page of designs.

| Field | Type | Description |
|---|---|---|
| `designs` | object[] | Newest first. Empty for a person Lettrove has never seen. |
| `designs[].id` | string | The id Lettrove gave the design: 26 letters and digits. |
| `designs[].name` | string | The name the person gave it. |
| `designs[].mode` | `email` \| `page` \| `popup` \| `document` | What the design is: `email`, `page`, `popup` or `document`. A project makes one mode. |
| `designs[].revision` | integer | The saved revision; only ever goes up. |
| `designs[].createdAt` | string (date-time) | ISO 8601, UTC. |
| `designs[].updatedAt` | string (date-time) | ISO 8601, UTC. |
| `nextCursor` | string or null | Pass as `cursor` for the next page; `null` on the last. |

```json
{
  "designs": [
    {
      "id": "01J9ZQ3W8D2K7M5T1V4XG6HB0R",
      "name": "October newsletter",
      "mode": "email",
      "revision": 12,
      "createdAt": "2026-10-07T16:03:10.000Z",
      "updatedAt": "2026-10-07T16:03:10.000Z"
    }
  ],
  "nextCursor": null
}
```

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `400` | `request_invalid`: The user id or the cursor is not valid. |
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project. |
| `409` | `user_erased`: This person is being erased. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |

## Get a design

```http
GET /embed/v1/users/{userId}/designs/{designId}
```

One design as last saved, with its document: your own copy.

Limit: 600 requests a minute per key. Operation id: `getDesign`.

| Parameter | In | Type | Description |
|---|---|---|---|
| `userId` | path | string | Your own id for the person: whatever you chose, up to 256 characters. Never a name or an email. |
| `designId` | path | string | The id Lettrove gave the design: 26 letters and digits. |

**`200`** The design.

| Field | Type | Description |
|---|---|---|
| `id` | string | The id Lettrove gave the design: 26 letters and digits. |
| `name` | string | The name the person gave it. |
| `mode` | `email` \| `page` \| `popup` \| `document` | What the design is: `email`, `page`, `popup` or `document`. A project makes one mode. |
| `revision` | integer | The saved revision; only ever goes up. |
| `createdAt` | string (date-time) | ISO 8601, UTC. |
| `updatedAt` | string (date-time) | ISO 8601, UTC. |
| `doc` | object | The design document. Store it as it is; what you may rely on inside it is on docs.lettrove.com/docs/1/api/design-document. |

```json
{
  "id": "01J9ZQ3W8D2K7M5T1V4XG6HB0R",
  "name": "October newsletter",
  "mode": "email",
  "revision": 12,
  "createdAt": "2026-10-07T16:03:10.000Z",
  "updatedAt": "2026-10-07T16:03:10.000Z",
  "doc": {}
}
```

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project. |
| `404` | `design_not_found`: No design with that id is this person's. Another person's design is not found either, never refused. |
| `409` | `user_erased`: This person is being erased. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |

## Export a design

```http
POST /embed/v1/designs/{id}/export
```

One person's design as last saved: as HTML (the default), or as a PDF, PNG or ZIP file kept for a URL. Recorded, and sent as an `export.created` webhook.

Limit: 120 requests a minute per key. Operation id: `exportDesign`.

| Parameter | In | Type | Description |
|---|---|---|---|
| `id` | path | string | The id Lettrove gave the design: 26 letters and digits. |

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `user` | object | Yes | Whose design it is. Another person's design is not found. |
| `user.id` | string | Yes | Your own id for the person: whatever you chose, up to 256 characters. Never a name or an email. |
| `merge` | object |  | Merge tags to fill. A tag with no value stays as written and is listed in `mergeTags`. |
| `merge.contact` | object |  | The values for `{{contact.<field>}}` tags, by field. |
| `merge.unsubscribeUrl` | string |  | Your unsubscribe link, for `{{unsubscribe_url}}`. Left out, the tag stays for your sending provider to fill. |
| `merge.preferencesUrl` | string |  | Your preferences link, for `{{preferences_url}}`. Left out, the tag stays. |
| `text` | object |  | How the plain-text version is written. |
| `text.links` | boolean |  | Each link's address after its text. |
| `text.images` | boolean |  | Images' alt text. |
| `text.preheader` | boolean |  | The preheader first. |
| `format` | `html` \| `zip` \| `pdf` \| `png` |  | `html` (the default) answers with the HTML; `pdf`, `png` or `zip` with a file kept for a URL. |
| `fullPage` | boolean |  | With `png`: the whole design (`true`, the default) or the first screen only. |

```json
{
  "user": {
    "id": "u_123"
  },
  "merge": {
    "contact": {
      "first_name": "Ada"
    },
    "unsubscribeUrl": "https://acme.com/u/123"
  },
  "format": "html"
}
```

**`200`** The HTML, or the file (`format` is set on a file).

*As HTML*

| Field | Type | Description |
|---|---|---|
| `exportId` | string | The export's record. |
| `designId` | string | The id Lettrove gave the design: 26 letters and digits. |
| `revision` | integer | The saved revision; only ever goes up. |
| `mode` | `email` \| `page` \| `popup` \| `document` | What the design is: `email`, `page`, `popup` or `document`. A project makes one mode. |
| `html` | string | The whole document, ready to send or serve. |
| `chunks` | object | The HTML in pieces, to place it inside a page of your own. |
| `chunks.body` | string | What goes inside `<body>`, its scripts taken out. |
| `chunks.css` | string | Every stylesheet the design carries, in order. |
| `chunks.js` | string | Every script it runs, in order; empty for an email. |
| `chunks.fonts` | string[] | The web-font stylesheets it links to, by URL. |
| `text` | string | The plain-text version (the `text/plain` part), merged the same way. |
| `subject` | string | The subject line, merged. |
| `preheader` | string | The preview text after the subject, merged. |
| `mergeTags` | string[] | Tags still in the HTML, text, subject or preheader, exactly as written: yours to fill before it goes to anyone. |
| `rendererVersion` | integer | Which renderer made it. Goes up when the output changes. |
| `bytes` | integer | Size of `html` in bytes. |
| `warnings` | string[] | Things worth knowing, in words: an email past Gmail's clipping size, an inline image left inline. |
| `design` | object | The design document exported. Store it as it is. |

```json
{
  "exportId": "01J9ZR4C2N8B6V0X3M7K5T9QWE",
  "designId": "01J9ZQ3W8D2K7M5T1V4XG6HB0R",
  "revision": 12,
  "mode": "email",
  "html": "<!doctype html>…",
  "chunks": {
    "body": "…",
    "css": "…",
    "js": "",
    "fonts": []
  },
  "text": "Hi Ada, your order has shipped…",
  "subject": "Your order has shipped",
  "preheader": "It arrives Thursday.",
  "mergeTags": [],
  "rendererVersion": 7,
  "bytes": 38211,
  "warnings": [],
  "design": {}
}
```

*As a file*

| Field | Type | Description |
|---|---|---|
| `exportId` | string | The export's record. |
| `designId` | string | The id Lettrove gave the design: 26 letters and digits. |
| `revision` | integer | The saved revision; only ever goes up. |
| `mode` | `email` \| `page` \| `popup` \| `document` | What the design is: `email`, `page`, `popup` or `document`. A project makes one mode. |
| `format` | `zip` \| `pdf` \| `png` | The file it is. |
| `url` | string | Where to fetch the file. |
| `expiresAt` | string (date-time) or null | When the URL stops working; `null` while it is kept. |
| `filename` | string | A safe file name from the design's name. |
| `bytes` | integer | Size of the file in bytes. |
| `warnings` | string[] | Things worth knowing, in words: an email past Gmail's clipping size, an inline image left inline. |
| `design` | object | The design document. Store it as it is; what you may rely on inside it is on docs.lettrove.com/docs/1/api/design-document. |

```json
{
  "exportId": "01J9ZR4C2N8B6V0X3M7K5T9QWE",
  "designId": "01J9ZQ3W8D2K7M5T1V4XG6HB0R",
  "revision": 12,
  "mode": "email",
  "format": "pdf",
  "url": "https://files.lettrove.com/embed/…/q3-proposal.pdf",
  "expiresAt": null,
  "filename": "q3-proposal.pdf",
  "bytes": 48213,
  "warnings": [],
  "design": {}
}
```

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `400` | `request_invalid`: The body does not match; the message names the field. |
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project. |
| `404` | `design_not_found`: No design with that id is this person's. |
| `409` | `mode_unavailable`: A format this mode does not make, such as a popup's PDF.<br/>`user_erased`: This person is being erased. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |

## Render a design you hold

```http
POST /embed/v1/render
```

A design you keep yourself, as the file you send: the same export as everywhere else, by the project's mode. Nothing is stored with Lettrove: an inline image stays inline and is named in `warnings`.

Limit: 120 requests a minute per key. Operation id: `renderDesign`.

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `doc` | object | Yes | The design document, as you hold it. |
| `designId` | string |  | The id you know this design by. A popup's handle and a form's event carry it. Left out, the document's own id. |
| `merge` | object |  | Merge tags to fill. A tag with no value stays as written and is listed in `mergeTags`. |
| `merge.contact` | object |  | The values for `{{contact.<field>}}` tags, by field. |
| `merge.unsubscribeUrl` | string |  | Your unsubscribe link, for `{{unsubscribe_url}}`. Left out, the tag stays for your sending provider to fill. |
| `merge.preferencesUrl` | string |  | Your preferences link, for `{{preferences_url}}`. Left out, the tag stays. |
| `text` | object |  | How the plain-text version is written. |
| `text.links` | boolean |  | Each link's address after its text. |
| `text.images` | boolean |  | Images' alt text. |
| `text.preheader` | boolean |  | The preheader first. |

```json
{
  "doc": {},
  "designId": "01J9ZQ3W8D2K7M5T1V4XG6HB0R",
  "merge": {
    "contact": {
      "first_name": "Ada"
    },
    "unsubscribeUrl": "https://acme.com/u/123"
  }
}
```

**`200`** The rendered design.

| Field | Type | Description |
|---|---|---|
| `mode` | `email` \| `page` \| `popup` \| `document` | What the design is: `email`, `page`, `popup` or `document`. A project makes one mode. |
| `html` | string | The whole document, ready to send or serve. |
| `chunks` | object | The HTML in pieces, to place it inside a page of your own. |
| `chunks.body` | string | What goes inside `<body>`, its scripts taken out. |
| `chunks.css` | string | Every stylesheet the design carries, in order. |
| `chunks.js` | string | Every script it runs, in order; empty for an email. |
| `chunks.fonts` | string[] | The web-font stylesheets it links to, by URL. |
| `text` | string | The plain-text version (the `text/plain` part), merged the same way. |
| `subject` | string | The subject line, merged. |
| `preheader` | string | The preview text after the subject, merged. |
| `mergeTags` | string[] | Tags still in the HTML, text, subject or preheader, exactly as written: yours to fill before it goes to anyone. |
| `rendererVersion` | integer | Which renderer made it. Goes up when the output changes. |
| `bytes` | integer | Size of `html` in bytes. |
| `warnings` | string[] | Things worth knowing, in words: an email past Gmail's clipping size, an inline image left inline. |
| `design` | object | The design document exported. Store it as it is. |

```json
{
  "mode": "email",
  "html": "<!doctype html>…",
  "chunks": {
    "body": "…",
    "css": "…",
    "js": "",
    "fonts": []
  },
  "text": "Hi Ada, your order has shipped…",
  "subject": "Your order has shipped",
  "preheader": "It arrives Thursday.",
  "mergeTags": [],
  "rendererVersion": 7,
  "bytes": 38211,
  "warnings": [],
  "design": {}
}
```

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `400` | `request_invalid`: The body does not match; the message names the field.<br/>`design_invalid`: `doc` is not a design. |
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project. |
| `409` | `mode_unavailable`: The design is of another mode than the project's. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |

## Erase a person

```http
DELETE /embed/v1/users/{userId}
```

Erase one person and everything they made: designs, restore points and images, at once, with no grace period. Asking again while it runs is the same request. A `user.erased` webhook follows when it is done.

Limit: 60 requests a minute per key. Operation id: `eraseUser`.

| Parameter | In | Type | Description |
|---|---|---|---|
| `userId` | path | string | Your own id for the person: whatever you chose, up to 256 characters. Never a name or an email. |

**`202`** Erasure has started.

| Field | Type | Description |
|---|---|---|
| `status` | `"erasing"` |  |

```json
{
  "status": "erasing"
}
```

**`204`** Lettrove never saw this id: there is nothing to erase.

**Refusals** (body: `{ "error", "code", "requestId" }`)

| Status | Code and when |
|---|---|
| `400` | `request_invalid`: The user id is not valid. |
| `401` | `key_invalid`: The secret key is missing, revoked or not one Lettrove issued. |
| `403` | `project_suspended`: The project is suspended.<br/>`workspace_suspended`: Lettrove has suspended the embed for the workspace that owns the project. |
| `429` | `rate_limited`: Too many requests for this key; `Retry-After` says how many seconds to wait. |
| `503` | `service_unavailable`: Lettrove cannot do this right now (the embed is not available, or a file format or the queue is briefly down). Nothing was changed; try again. |
