Skip to content
Sendoff
Esc
↑↓navigate↵open⌘Jpreview
On this page

REST API

Subscribe addresses from your site's server, and import CSV files and past issues, with scoped API keys.

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 description is at /api/v1/openapi.json.

Authentication

Send an API key as a bearer token:

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

POST /api/v1/subscribers

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

emailbodystringrequired

The address to subscribe.

namebodystring

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

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

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 for how columns are detected.

dryRunquerystring

true reports what would happen without writing anything.

statusquerystring

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

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:

{
  "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
}
columnsobject

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

countsobject

Rows by the status they’re imported with.

createdinteger

New subscribers.

suppressedinteger

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

unchangedinteger

Addresses already on the list. Imports never reactivate anyone.

skippedobject

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

invalidstring[]

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

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.

issuesbodyobject[]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:

{
  "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"
    }
  ]
}
outcomestring

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.

mergeTagsstring[]

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:

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

The message is written for people; branch on code.

Last updated on

Was this page helpful?