---
title: REST API
description: Subscribe addresses from your site's server, and import CSV files and past issues, with scoped API keys.
sidebar:
  icon: code
---

The REST API is for servers: your website's signup form, or a script. It lives under `/api/v1` on your hostname, and a machine-readable [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) description is at `/api/v1/openapi.json`.

## Authentication

Send an API key as a bearer token:

```http
Authorization: Bearer sendoff_…
```

Ask your agent to create one ("create an API key for my website's signup form"). Keys are shown once and stored hashed, so save it straight away. Each key has one or more scopes:

| Scope | Allows |
| --- | --- |
| `subscribers:write` | `POST /subscribers`. The default, for signup forms. |
| `subscribers:import` | `POST /subscribers/import`. It adds people without confirmation emails, so create it for one import and revoke it afterwards. |
| `issues:import` | `POST /issues/import`, for bringing past issues over. Create it for the import and revoke it afterwards. |

Keep keys in server-side secrets. The API sends no CORS headers, so browsers can't call it.

## Subscribe an address

```http
POST /api/v1/subscribers
```

Starts double opt-in: a new address gets a confirmation email and becomes active once it's confirmed.

- **`email`** · body · `string` · required

  The address to subscribe.

- **`name`** · body · `string`

  An optional display name, up to 100 characters. An empty one is ignored, so a
  form can send its name field blank.

```bash
curl https://news.example.com/api/v1/subscribers \
  -H "Authorization: Bearer $SENDOFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com"}'
```

It returns `202 {"status": "accepted"}` whether the address is new, pending or already subscribed, so responses can't reveal who's on your list. Addresses that bounced or complained are accepted but never emailed.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | The body isn't JSON, or the email is invalid |
| `401` | `invalid_api_key` | The key is missing, wrong or revoked |
| `403` | `insufficient_scope` | The key doesn't have `subscribers:write` |
| `502` | `confirmation_failed` | SES couldn't send the confirmation. Safe to retry. |
| `503` | `not_configured` | Sendoff's settings aren't set up yet |

## Import subscribers

```http
POST /api/v1/subscribers/import
```

Imports a CSV export. Send the file as the request body, or as a multipart field named `file`. Up to 10 MB and 25,000 rows per request. See [Importing your list](/docs/guides/importing) for how columns are detected.

- **`dryRun`** · query · `string`

  `true` reports what would happen without writing anything.

- **`status`** · query · `string`

  `unsubscribed`, `bounced` or `complained`: marks every row with this status,
  for a file that only lists suppressed addresses.

```bash
curl --data-binary @subscribers.csv \
  -H "Authorization: Bearer $SENDOFF_IMPORT_KEY" \
  -H "Content-Type: text/csv" \
  "https://news.example.com/api/v1/subscribers/import?dryRun=true"
```

Use `--data-binary`, not `-d`, which strips the line breaks. The response is a report:

```json
{
  "columns": {
    "email": "Email Address",
    "name": ["First Name", "Last Name"],
    "status": ["UNSUB_TIME"]
  },
  "counts": {
    "active": 5812,
    "unsubscribed": 176,
    "bounced": 0,
    "complained": 0
  },
  "created": 5988,
  "dryRun": true,
  "invalid": [],
  "invalidCount": 0,
  "skipped": {
    "noEmail": 0,
    "unconfirmed": 12,
    "unrecognized": 0,
    "unrecognizedValues": []
  },
  "suppressed": 0,
  "total": 6000,
  "unchanged": 0
}
```

- **`columns`** · `object`

  The headers the email, name and status were read from.

- **`counts`** · `object`

  Rows by the status they're imported with.

- **`created`** · `integer`

  New subscribers.

- **`suppressed`** · `integer`

  Existing subscribers newly unsubscribed, bounced or complained by this file.

- **`unchanged`** · `integer`

  Addresses already on the list. Imports never reactivate anyone.

- **`skipped`** · `object`

  Rows left out: `noEmail`, `unconfirmed` (pending or inactive), and
  `unrecognized`, with the most common unrecognized status values in
  `unrecognizedValues`.

- **`invalid`** · `string[]`

  Up to 20 invalid addresses; `invalidCount` has the total.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | No email column, too many rows, or a bad query value |
| `401` | `invalid_api_key` | The key is missing, wrong or revoked |
| `403` | `insufficient_scope` | The key doesn't have `subscribers:import` |
| `413` | `too_large` | The file is over 10 MB |

## Import past issues

```http
POST /api/v1/issues/import
```

Keeps issues you sent with another provider, exactly as they went out. Each becomes a sent issue marked imported: it's never sent again and has no stats. Up to 50 issues and 10 MB per request. See [Importing past issues](/docs/guides/past-issues).

- **`issues`** · body · `object[]` · required

  The issues, each with:

  - `subject` (required)
  - `html` (required): the HTML as it was sent, kept exactly as given, up to 1 MB
  - `sentAt` (required): when it was sent, in ISO 8601, like `2025-03-01T09:00:00Z`. Without a time zone, it's read as UTC.
  - `text`: the plain-text version. Without it, one is made from the HTML.
  - `preheader`: the preview text
  - `slug`: defaults to one made from the subject

Importing the same subject and send date again replaces the earlier copy. The response says what happened to each issue, in order:

```json
{
  "issues": [
    {
      "id": "0195512a-6e80-7c3a-9a41-2f0e1d4c8b77",
      "mergeTags": ["{{{contact.first_name|there}}}"],
      "outcome": "created",
      "sentAt": "2025-03-01T09:00:00.000Z",
      "slug": "spring-update",
      "subject": "Spring update"
    },
    {
      "error": "The slug \"summer\" is taken by another issue.",
      "mergeTags": [],
      "outcome": "failed",
      "sentAt": "2025-06-01T09:00:00.000Z",
      "subject": "Summer"
    }
  ]
}
```

- **`outcome`** · `string`

  `created`, `updated` (it replaced an earlier import with the same subject and
  send date), or `failed`, with the reason in `error`. A failed issue doesn't
  stop the others.

- **`mergeTags`** · `string[]`

  The old provider's placeholders left in the issue, like
  `{{{contact.first_name|there}}}` or `*|FNAME|*`, which show as they are. Up to
  10.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | The body isn't valid JSON, has no issues or more than 50, or an issue is missing its subject, HTML or a past send date |
| `401` | `invalid_api_key` | The key is missing, wrong or revoked |
| `403` | `insufficient_scope` | The key doesn't have `issues:import` |
| `413` | `too_large` | The body is over 10 MB |

## Errors

Every error has the same shape:

```json
{ "error": { "code": "invalid_request", "message": "…" } }
```

The `message` is written for people; branch on `code`.
