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.
emailbodystringrequiredThe address to subscribe.
namebodystringAn 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.
dryRunquerystringtrue reports what would happen without writing anything.
statusquerystringunsubscribed, 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
}
columnsobjectThe headers the email, name and status were read from.
countsobjectRows by the status they’re imported with.
createdintegerNew subscribers.
suppressedintegerExisting subscribers newly unsubscribed, bounced or complained by this file.
unchangedintegerAddresses already on the list. Imports never reactivate anyone.
skippedobjectRows 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[]requiredThe issues, each with:
subject(required)html(required): the HTML as it was sent, kept exactly as given, up to 1 MBsentAt(required): when it was sent, in ISO 8601, like2025-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 textslug: 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"
}
]
}
outcomestringcreated, 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.