Download OpenAPI specification:Download
SendFox's REST API lets you manage contacts, campaigns, lists, automations, and forms programmatically. It uses OAuth 2.0 for authentication.
Compatible with AI agents (Claude, ChatGPT, etc.) via the OpenAPI spec. Available at GET /openapi.yaml.
Create a personal access token at https://sendfox.com/account/oauth. Once created, use it in the Authorization header:
Authorization: Bearer {TOKEN}
For integrations that require user authentication, create an OAuth 2.0 client at https://sendfox.com/account/oauth
API requests are limited to 60 requests per minute per authenticated user. Rate limit status is returned in response headers:
X-RateLimit-Limit: Maximum requests per minuteX-RateLimit-Remaining: Remaining requests in current windowRetry-After: Seconds until rate limit resets (only on 429 responses)All error responses use standard HTTP status codes and Laravel's default error format:
message: Human-readable error descriptionerrors: Field-level validation errors (on 422 responses)API access requires a Lifetime or Empire plan. Free users cannot use the API.
Accounts restricted by SendFox cannot use authenticated API endpoints. These requests return 403 Forbidden with the error code account_restricted and a link to the account status page.
Beta. Send one email per request from your app: login codes, receipts, notifications. Unlike the rest of the API, transactional routes are available on every plan, including free. Create a key from the Transactional page in your SendFox account. On a paid plan that key (and any key from Settings > API tokens) works on the whole API; on the free plan it is a transactional key that only works on these routes. Transactional keys are rejected by the marketing API, so a key that lives in your app server can never read your contacts.
Every non-2xx response has the same shape: status, name,
message, fix, link, request_id.
| Status | name | When |
|---|---|---|
| 401 | missing_api_key |
No Authorization header |
| 401 | invalid_api_key |
The key is wrong or revoked |
| 403 | insufficient_scope |
A free-plan key without the needed transactional scope |
| 403 | feature_not_available |
Transactional email is not enabled on the account |
| 403 | domain_not_verified |
The from domain is not verified for this account |
| 403 | sandbox_recipient_not_allowed |
Sandbox sender to an address that is not yours |
| 403 | recipient_suppressed |
A recipient is on your suppression list (suppressed lists them) |
| 403 | account_paused |
Transactional sending is paused for review |
| 403 | account_not_approved |
Live sending not yet approved; the sandbox still works |
| 403 | account_restricted |
The SendFox account is restricted |
| 404 | not_found |
No message with that id on this account |
| 409 | invalid_idempotent_request |
Same Idempotency-Key, different body, inside 24 hours |
| 422 | validation_error |
Field errors in errors |
| 429 | rate_limit_exceeded |
Over 5/s or 300/min; honor Retry-After |
| 429 | daily_quota_exceeded |
Daily limit used; resets at midnight UTC |
| 429 | monthly_quota_exceeded |
Monthly limit used; upgrade or turn on overage under Transactional > Usage |
Add one https URL under Transactional > Webhooks and SendFox POSTs a
JSON event to it for each of sent, delivered, delayed, bounced,
complained and failed that you pick. Sandbox test addresses send
events too. The URL must resolve to a public address; redirects are not
followed, and SendFox waits up to 10 seconds for an answer.
{
"id": "whd_5f0c2a9e1b7d4c3a8e6f1d2b",
"event": "delivered",
"occurred_at": "2026-10-10T14:03:22+00:00",
"data": {
"message": { "id": "tm_…", "status": "delivered", "to": ["[email protected]"], "subject": "Your code", "tag": "login_code", "…": "same fields as GET /transactional/emails/{id}, without events" },
"recipient": "[email protected]",
"status": null,
"reason": null
}
}
recipient is the address the event is about (a message with cc
gets one event per recipient). status and reason carry the
receiving server's answer on bounced, delayed and failed.
Any 2xx answer counts as delivered. Anything else, or no answer, is
retried after 1 minute, 15 minutes, 2 hours and 18 hours, then given
up. Retries send the same body with the same id, so dedupe on it.
Verify every request. The X-SendFox-Signature header is
t={unix seconds},v1={hex}, where hex is HMAC-SHA256 of
{t}.{raw body} keyed with your signing secret (the whole whsec_…
string). Compute it over the raw body before parsing the JSON, compare
in constant time, and reject a t more than five minutes old. Requests
also carry X-SendFox-Event and X-SendFox-Delivery (the body's id).
Queues one email. The response is immediate; delivery happens in the
background and is reported through GET /transactional/emails/{id}
and your webhook.
Idempotency. Send an Idempotency-Key header (1 to 256 characters)
to make retries safe. Within 24 hours, the same key with the same body
returns the original message (with an idempotent-replayed: true
header); the same key with a different body is a 409 invalid_idempotent_request.
Sandbox. Send from [email protected] with no DNS setup.
It delivers only to your own account email and to the four test
addresses. Test addresses never reach a mailbox and never count toward
your limits; each one synthesizes an outcome so you can exercise your
integration: [email protected], [email protected],
[email protected], [email protected].
Limits. Every response carries x-sendfox-daily-quota-limit,
x-sendfox-daily-quota-remaining, x-sendfox-monthly-quota-limit and
x-sendfox-monthly-quota-remaining. Requests are limited to 5 per
second and 300 per minute per key.
| Idempotency-Key | string [ 1 .. 256 ] characters |
| from required | string <= 320 characters Sender. Either an address or a display name followed by one in angle brackets. The domain must be verified under Account > Domains (a subdomain of a verified domain also works), or be the sandbox sender |
| to required | Array of strings <email> [ 1 .. 5 ] items |
| cc | Array of strings <email> <= 5 items |
| bcc | Array of strings <email> <= 5 items cc and bcc together may hold at most 5 addresses. |
| reply_to | string or null <email> |
| subject required | string <= 998 characters |
| html | string HTML body, at most 500 KB (UTF-8 bytes). Send html, text, or both. |
| text | string Plain-text body, at most 100 KB (UTF-8 bytes). Send html, text, or both. |
| tag | string or null <= 64 characters ^[a-z0-9][a-z0-9_.-]*$ A label for grouping in the dashboard, for per-tag open/click tracking switches, and for per-tag alerts. Lowercase letters, digits, dots, dashes and underscores. Messages without a tag are grouped by subject. Tags that look like authentication mail ( |
{- "subject": "Your login code",
- "html": "<p>Your code is <strong>483 920</strong>.</p>",
- "text": "string",
- "tag": "login_code"
}{- "id": "tm_4f0c9b2e7d1a5c3e8b6f2a9d",
- "status": "queued"
}Newest first. Standard SendFox paginator; bodies are never included.
| status | string Enum: "queued" "sent" "delivered" "bounced" "complained" "failed" "suppressed" |
| tag | string |
| to | string <email> Exact match on the first |
| mode | string Enum: "live" "test" |
| page | integer |
| per_page | integer <= 100 Default: 25 |
{- "data": [
- {
- "id": "tm_4f0c9b2e7d1a5c3e8b6f2a9d",
- "status": "queued",
- "mode": "live",
- "from": "string",
- "to": [
- "string"
], - "cc": [
- "string"
], - "bcc": [
- "string"
], - "reply_to": "string",
- "subject": "string",
- "tag": "string",
- "idempotency_key": "string",
- "failure": {
- "name": "string",
- "message": "string"
}, - "accepted_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "failed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "events": [
- {
- "event": "sent",
- "occurred_at": "2019-08-24T14:15:22Z",
- "reason": "string",
- "status": "string"
}
]
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}The message without its bodies, plus its event timeline.
| id required | string Example: tm_4f0c9b2e7d1a5c3e8b6f2a9d |
{- "id": "tm_4f0c9b2e7d1a5c3e8b6f2a9d",
- "status": "queued",
- "mode": "live",
- "from": "string",
- "to": [
- "string"
], - "cc": [
- "string"
], - "bcc": [
- "string"
], - "reply_to": "string",
- "subject": "string",
- "tag": "string",
- "idempotency_key": "string",
- "failure": {
- "name": "string",
- "message": "string"
}, - "accepted_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z",
- "failed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "events": [
- {
- "event": "sent",
- "occurred_at": "2019-08-24T14:15:22Z",
- "reason": "string",
- "status": "string"
}
]
}Returns a paginated list of contacts (100 per page by default, up to 1000 via per_page).
Supports engagement filtering through filter[...] query parameters, so you can answer
questions like "who last opened over a year ago" without paging the whole account. All
filter conditions are AND-ed. Pass count_only=true to get just the number of matches —
the cheapest way to size an audience before acting on it.
| query | string Search query for filtering contacts |
| unsubscribed | boolean Filter unsubscribed contacts |
string Filter by specific email | |
| per_page | integer [ 1 .. 1000 ] Default: 100 Contacts per page |
| count_only | boolean Return only the number of matching contacts, with no contact records |
| filter[status] | string Enum: "active" "engaged" "inactive" "new" "unconfirmed" "unsubscribed" "bounced" "invalid" Engagement status |
| filter[last_opened_after] | string <date-time> Contacts whose most recent open is on or after this date |
| filter[last_opened_before] | string <date-time> Contacts whose most recent open is before this date. Excludes contacts who never opened — use filter[never_opened] for those. |
| filter[last_clicked_after] | string <date-time> Contacts whose most recent click is on or after this date |
| filter[last_clicked_before] | string <date-time> Contacts whose most recent click is before this date |
| filter[last_sent_after] | string <date-time> Contacts last sent to on or after this date |
| filter[last_sent_before] | string <date-time> Contacts last sent to before this date |
| filter[created_after] | string <date-time> Contacts created on or after this date |
| filter[created_before] | string <date-time> Contacts created before this date |
| filter[never_opened] | boolean Only contacts who have never opened an email |
| filter[never_clicked] | boolean Only contacts who have never clicked a link |
| filter[never_sent] | boolean Only contacts who have never been sent an email |
| filter[in_list_ids] | Array of integers Only contacts still subscribed to any of these lists. Members who unsubscribed from the list are not matched, so results may be smaller than before per-list unsubscribe shipped. |
| filter[not_in_list_ids] | Array of integers Exclude contacts who are members of any of these lists, whether or not they unsubscribed from it. |
| filter[tag_ids] | Array of integers Only contacts still subscribed to any of these tags. Somebody who opted out of the tag keeps it but is not matched. Same rule as filter[in_list_ids]; see the tag_ids note in the bulk-action filter schema. |
| filter[not_tag_ids] | Array of integers Exclude contacts carrying any of these tags |
{- "count": 0,
- "filter": "string"
}| email required | string <email> |
| first_name | string |
| last_name | string |
| ip_address | string |
| lists | Array of integers Array of list IDs to add the contact to |
Array of objects |
{- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "lists": [
- 0
], - "contact_fields": [
- {
- "name": "string",
- "value": "string"
}
]
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Update contact details including name, list memberships, and custom fields
| id required | integer |
| first_name | string |
| last_name | string |
| lists | Array of integers Array of list IDs (replaces all current list memberships) |
Array of objects |
{- "first_name": "string",
- "last_name": "string",
- "lists": [
- 0
], - "contact_fields": [
- {
- "name": "string",
- "value": "string"
}
]
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns paginated email deliverables and contact-level engagement summary
| id required | integer |
{- "contact": {
- "last_sent_at": "2019-08-24T14:15:22Z",
- "last_opened_at": "2019-08-24T14:15:22Z",
- "last_clicked_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "bounced_at": "2019-08-24T14:15:22Z"
}, - "deliverables": {
- "data": [
- {
- "campaign_title": "string",
- "campaign_id": 0,
- "sent_at": "2019-08-24T14:15:22Z",
- "opened_at": "2019-08-24T14:15:22Z",
- "clicked_at": "2019-08-24T14:15:22Z",
- "bounced_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "spam_at": "2019-08-24T14:15:22Z"
}
]
}
}Import up to 1,000 contacts in a single request. Creates new contacts or updates existing ones.
required | Array of objects <= 1000 items |
{- "contacts": [
]
}{- "created": 0,
- "updated": 0
}Queues one action against every contact the filter matches, applied in chunks in the background. Returns immediately with an id to poll.
Run with dry_run: true first. A dry run reports how many contacts match and changes
nothing — it is the only way to catch a filter that matches more than intended, and a bulk
write is not undoable. An empty filter (which would match the whole account) is rejected
unless it is a dry run.
Per-campaign conditions (opened_campaign_id, not_opened_campaign_id,
clicked_campaign_id) are available here but not on GET /contacts, because they are
resolved in the background rather than inside a request.
Bulk delete and bulk unsubscribe are deliberately not offered.
Because in_list_ids matches only subscribed members, remove_from_list with
filter.in_list_ids set to that same list leaves the memberships of people who
unsubscribed from it in place. They already receive nothing from that list.
tag_ids behaves the same way, so remove_tag with filter.tag_ids set to that
same tag leaves the tag on people who unsubscribed from it. They already receive
nothing sent to that tag. To reach every carrier regardless of opt-out status,
select them by some other filter, or read them from the tag endpoints.
| action required | string Enum: "apply_tag" "remove_tag" "add_to_list" "remove_from_list" |
| target_id required | integer The tag id (apply_tag/remove_tag) or list id (add_to_list/remove_from_list) |
| dry_run | boolean Default: false When true, only count the matches — nothing is modified |
object (ContactFilter) An engagement filter over the account's contacts. Every condition is AND-ed. Omitting the object entirely means "all contacts". |
{- "action": "apply_tag",
- "target_id": 0,
- "dry_run": false,
- "filter": {
- "status": "active",
- "last_opened_after": "2019-08-24T14:15:22Z",
- "last_opened_before": "2019-08-24T14:15:22Z",
- "last_clicked_after": "2019-08-24T14:15:22Z",
- "last_clicked_before": "2019-08-24T14:15:22Z",
- "last_sent_after": "2019-08-24T14:15:22Z",
- "last_sent_before": "2019-08-24T14:15:22Z",
- "created_after": "2019-08-24T14:15:22Z",
- "created_before": "2019-08-24T14:15:22Z",
- "never_opened": true,
- "never_clicked": true,
- "never_sent": true,
- "in_list_ids": [
- 0
], - "not_in_list_ids": [
- 0
], - "tag_ids": [
- 0
], - "not_tag_ids": [
- 0
], - "opened_campaign_id": 0,
- "not_opened_campaign_id": 0,
- "clicked_campaign_id": 0
}
}{- "id": 0,
- "status": "pending",
- "action": "apply_tag",
- "target_id": 0,
- "dry_run": true,
- "filter": "string",
- "matched_count": 0,
- "processed_count": 0,
- "error": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}For a dry run, matched_count is the answer and nothing was modified. A run whose match set exceeds the per-request ceiling fails without applying anything.
| id required | integer |
{- "id": 0,
- "status": "pending",
- "action": "apply_tag",
- "target_id": 0,
- "dry_run": true,
- "filter": "string",
- "matched_count": 0,
- "processed_count": 0,
- "error": "string",
- "started_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}| query | string Search query for filtering contacts |
{- "data": [
- {
- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}| email required | string <email> |
{- "email": "[email protected]"
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}{- "data": [
- {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Creates a campaign as a draft. To send it, use the send endpoint or provide scheduled_at. Subject lines cannot start with "RE:" or "FWD:". At least one list is required if scheduled_at is provided.
| title required | string <= 191 characters |
| subject required | string <= 191 characters |
| preview_text | string or null <= 191 characters Inbox preview snippet shown beneath the subject line. Optional. |
| html required | string <= 1000000 characters Email body HTML content |
| from_name required | string <= 191 characters |
| from_email required | string <email> <= 191 characters |
| scheduled_at | string <date-time> Schedule send time (omit for draft). Must include at least one list. |
| lists | Array of integers Array of list IDs to send to |
| excluded_lists | Array of integers List IDs whose contacts should be excluded from the send. Exclusion wins over inclusion. |
| to_contact_tags | Array of integers Tag IDs whose contacts should receive the campaign. Can be combined with lists. |
| excluded_contact_tags | Array of integers Tag IDs whose contacts should be excluded from the send. Exclusion wins over inclusion. |
| web_publish | boolean Publish the email to the web on your primary Smart Page once it sends. Requires a Smart Page. Omit it to list the email on every Smart Page whose Newsletters tab is on; send false to keep it off the web. |
| web_gate | boolean Default: false On a published email, ask new readers for their email address to read past the opening. The account-wide signup requirement in Settings overrides this choice when enabled, including when false is sent or this field is omitted. |
{- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "lists": [
- 0
], - "excluded_lists": [
- 0
], - "to_contact_tags": [
- 0
], - "excluded_contact_tags": [
- 0
], - "web_publish": true,
- "web_gate": false
}{- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns stored counts and rates for one to 100 distinct owned campaigns in request order. The entire request fails with the same validation error for missing, deleted or foreign IDs. Does not return link stats or query individual delivery events.
| campaign_ids[] required | Array of integers [ 1 .. 100 ] items unique [ items >= 1 ] Repeat campaign_ids[] for each campaign ID. |
{- "data": [
- {
- "link_stats": [
- {
- "url": "string",
- "clicks": 0
}
], - "sent_count": 0,
- "unique_open_count": 0,
- "unique_click_count": 0,
- "unsubscribe_count": 0,
- "bounce_count": 0,
- "spam_count": 0,
- "open_rate": 0,
- "click_rate": 0,
- "unsubscribe_rate": 0,
- "bounce_rate": 0,
- "spam_rate": 0,
- "id": 0
}
]
}{- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Only draft campaigns (not yet sent) can be updated. All fields are optional.
| id required | integer |
| title | string <= 191 characters |
| subject | string <= 191 characters |
| preview_text | string or null <= 191 characters Inbox preview snippet. Pass null to clear. |
| html | string <= 1000000 characters |
| from_name | string <= 191 characters |
| from_email | string <email> <= 191 characters |
| scheduled_at | string or null <date-time> Set to null to unschedule, or a datetime to schedule |
| lists | Array of integers Replaces all list assignments |
| excluded_lists | Array of integers Replaces the excluded lists (contacts in these lists are removed from the send). |
| to_contact_tags | Array of integers Replaces the included tag audience (contacts carrying these tags receive the campaign). |
| excluded_contact_tags | Array of integers Replaces the excluded tag audience (contacts carrying these tags are removed from the send). |
{- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "lists": [
- 0
], - "excluded_lists": [
- 0
], - "to_contact_tags": [
- 0
], - "excluded_contact_tags": [
- 0
]
}{- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Schedules a draft campaign for immediate sending. The campaign must:
All existing abuse prevention applies automatically: content approval workflow, sending throttles, spam detection, and bounce rate monitoring.
| id required | integer |
{- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Delivers the rendered campaign to the addresses given so it can be checked in a real inbox. This is a real send to those addresses only: the campaign's audience is untouched and the campaign stays a draft. Recipients are added to the account's "My Test Contacts" list.
Limits: up to 30 addresses per call (1 on the free plan) and 30 test sends per
account per day, shared with Send Test in the SendFox app. A test from a newly
created account goes to the account's own address only, unless the campaign sends
from a domain the account has verified; notice in the response says so when
addresses were dropped.
| id required | integer |
| emails required | Array of strings <email> [ 1 .. 30 ] items Addresses to deliver the test to. Trimmed and de-duplicated. |
{
}{- "sent": true,
- "campaign_id": 0,
- "notice": "string"
}Returns sent count and open/click/bounce/unsubscribe/spam counts and rates, all read from stored counters.
Pass include_link_stats=true to also get link_stats — the campaign's links ranked by click count. That part is opt-in because it counts rows in email_link_clicks once per link rather than reading a counter, so leaving it off keeps this endpoint as cheap as it has always been.
| id required | integer |
| include_link_stats | boolean Default: false When true, include the link_stats breakdown in the response |
| link_limit | integer [ 1 .. 100 ] Default: 50 How many links to return in link_stats, most-clicked first. Ignored unless include_link_stats is true. |
{- "link_stats": [
- {
- "url": "string",
- "clicks": 0
}
], - "sent_count": 0,
- "unique_open_count": 0,
- "unique_click_count": 0,
- "unsubscribe_count": 0,
- "bounce_count": 0,
- "spam_count": 0,
- "open_rate": 0,
- "click_rate": 0,
- "unsubscribe_rate": 0,
- "bounce_rate": 0,
- "spam_rate": 0
}Returns the contacts in one of a sent campaign's engagement groups. non_openers covers contacts the campaign actually sent to who did not open it — queued and cancelled deliverables are excluded, since they never had the chance.
| id required | integer |
| type required | string Enum: "openers" "clickers" "non_openers" "bounced" "unsubscribed" Which engagement group to list |
| per_page | integer [ 1 .. 1000 ] Default: 100 Records per page |
{- "data": [
- {
- "id": 0,
- "contact_id": 0,
- "email": "string",
- "first_name": "string",
- "last_name": "string",
- "sent_at": "2019-08-24T14:15:22Z",
- "opened_at": "2019-08-24T14:15:22Z",
- "clicked_at": "2019-08-24T14:15:22Z",
- "bounced_at": "2019-08-24T14:15:22Z",
- "unsubscribed_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Creates a new draft with the original's content, sender, and exclusions, aimed at the chosen slice of the original audience. Omit scheduled_at to leave it as a draft. The source campaign's lists are deliberately not carried over — the engagement segment is the audience.
| id required | integer |
| audience required | string Enum: "non_openers" "openers" "clickers" |
| subject | string <= 191 characters Optional new subject line; defaults to the original's |
| title | string <= 191 characters Optional internal name; defaults to "Resend: |
| scheduled_at | string <date-time> Optional send time; omit to leave the resend as a draft |
{- "audience": "non_openers",
- "subject": "string",
- "title": "string",
- "scheduled_at": "2019-08-24T14:15:22Z"
}{- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| query | string Search query for filtering lists |
{- "data": [
- {
- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}| name required | string |
{- "name": "string"
}{- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns list details including average open and click rates
| id required | integer |
{- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| id required | integer |
| name required | string <= 191 characters |
{- "name": "string"
}{- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Members who no longer receive this list, and when they stopped. Each row is a Contact with two extra fields.
The two timestamps are never merged. list_unsubscribed_at is this list only. unsubscribed_at keeps the meaning it has everywhere else in this API: the account-wide opt-out. Treating a per-list timestamp as an account-wide one would suppress that person across your whole integration because they left a single list, so read unsubscribed_scope if you need one field to branch on.
| list_id required | integer |
| query | string Search query for filtering contacts |
{- "data": [
- {
- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "list_unsubscribed_at": "2019-08-24T14:15:22Z",
- "unsubscribed_scope": "list"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Stops this one list for this one contact. Their other lists and their account-wide status are untouched, which is what distinguishes this from PATCH /unsubscribe — that endpoint is account-wide and is unchanged.
Idempotent. Repeating the request returns the original timestamp rather than moving it, so a retry never loses the date the person actually asked.
The opt-out is recorded durably, so it survives the contact being removed from the list and added back: re-adding them does not start sending again.
There is no matching resubscribe endpoint. Taking consent away on someone's behalf is safe for an integration to do; handing it back is not.
| list_id required | integer |
| email required | string <email> Email address of a contact already on this list |
{- "email": "[email protected]"
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "list_unsubscribed_at": "2019-08-24T14:15:22Z",
- "unsubscribed_scope": "list"
}Returns every contact on the list. This is membership, not subscription: someone who unsubscribed from the list is still a member and is still returned, so that an integration mirroring this endpoint can tell "removed from the list" apart from "asked to stop receiving it". Pass status to narrow it.
| list_id required | integer |
| query | string Search query for filtering contacts |
| status | string Enum: "subscribed" "unsubscribed" Optional. Omit for every member, which is the long-standing behaviour of this endpoint. |
{- "data": [
- {
- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Adds an existing contact to a list. If the contact is already in the list, no duplicate is created.
| list_id required | integer |
| contact_id required | integer ID of the contact to add |
{- "contact_id": 0
}{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| list_id required | integer |
| contact_id required | integer |
{- "id": 0,
- "first_name": "string",
- "last_name": "string",
- "ip_address": "string",
- "unsubscribed_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| query | string Search query for filtering forms |
{- "data": [
- {
- "id": 0,
- "title": "string",
- "landing_page_id": 0,
- "redirect_url": "string",
- "gdpr_required": true,
- "url": "string",
- "lists": [
- {
- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Creates a subscription form linked to one or more lists. Free users are limited to 1 form.
| title required | string <= 191 characters |
| lists required | Array of integers Array of list IDs to attach |
| redirect_url | string or null <uri> URL to redirect to after subscription |
| gdpr_required | boolean Whether GDPR consent checkbox is required |
{- "title": "string",
- "lists": [
- 0
], - "gdpr_required": true
}{- "id": 0,
- "title": "string",
- "landing_page_id": 0,
- "redirect_url": "string",
- "gdpr_required": true,
- "url": "string",
- "lists": [
- {
- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}{- "id": 0,
- "title": "string",
- "landing_page_id": 0,
- "redirect_url": "string",
- "gdpr_required": true,
- "url": "string",
- "lists": [
- {
- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| id required | integer |
| title | string <= 191 characters |
| lists | Array of integers Replaces all list assignments |
| redirect_url | string or null <uri> |
| gdpr_required | boolean |
{- "title": "string",
- "lists": [
- 0
], - "gdpr_required": true
}{- "id": 0,
- "title": "string",
- "landing_page_id": 0,
- "redirect_url": "string",
- "gdpr_required": true,
- "url": "string",
- "lists": [
- {
- "id": 0,
- "name": "string",
- "user_id": 0,
- "subscribed_contacts_count": 0,
- "unsubscribed_contacts_count": 0,
- "public_description": "string",
- "average_email_open_percent": 0,
- "average_email_click_percent": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}{- "data": [
- {
- "id": 0,
- "user_id": 0,
- "title": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "automation_triggers": [
- {
- "id": 0,
- "automation_id": 0,
- "type": "apply_list",
- "list_id": 0,
- "campaign_id": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "automation_items": [
- {
- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}| title required | string |
| trigger_type | string Enum: "apply_list" "open_campaign" "click_campaign" |
| trigger_list_id | integer Required when trigger_type is apply_list |
| trigger_campaign_id | integer Required when trigger_type is open_campaign or click_campaign |
| active | boolean |
{- "title": "string",
- "trigger_type": "apply_list",
- "trigger_list_id": 0,
- "trigger_campaign_id": 0,
- "active": true
}{- "id": 0,
- "user_id": 0,
- "title": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "automation_triggers": [
- {
- "id": 0,
- "automation_id": 0,
- "type": "apply_list",
- "list_id": 0,
- "campaign_id": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "automation_items": [
- {
- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}Returns automation with triggers, items, and campaign stats
| id required | integer |
{- "id": 0,
- "user_id": 0,
- "title": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "automation_triggers": [
- {
- "id": 0,
- "automation_id": 0,
- "type": "apply_list",
- "list_id": 0,
- "campaign_id": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "automation_items": [
- {
- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}Update title, trigger, or active status. Activating reschedules stale deliverables.
| id required | integer |
| title | string |
| active | boolean |
| trigger_type | string Enum: "apply_list" "open_campaign" "click_campaign" |
| trigger_list_id | integer |
| trigger_campaign_id | integer |
{- "title": "string",
- "active": true,
- "trigger_type": "apply_list",
- "trigger_list_id": 0,
- "trigger_campaign_id": 0
}{- "id": 0,
- "user_id": 0,
- "title": "string",
- "active": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z",
- "automation_triggers": [
- {
- "id": 0,
- "automation_id": 0,
- "type": "apply_list",
- "list_id": 0,
- "campaign_id": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "automation_items": [
- {
- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
]
}| id required | integer |
| subject required | string |
| html required | string |
| from_name required | string |
| from_email required | string <email> |
| delay_hours | integer [ 0 .. 5000 ] Hours to wait before sending (default 24, first email defaults to 0) |
{- "subject": "string",
- "html": "string",
- "from_name": "string",
- "delay_hours": 5000
}{- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| id required | integer |
| subject | string |
| html | string |
| from_name | string |
| from_email | string <email> |
| delay_hours | integer [ 0 .. 5000 ] |
| send_order | integer >= 1 |
{- "subject": "string",
- "html": "string",
- "from_name": "string",
- "delay_hours": 5000,
- "send_order": 1
}{- "id": 0,
- "automation_id": 0,
- "campaign_id": 0,
- "delay_hours": 0,
- "send_order": 0,
- "campaign": {
- "id": 0,
- "title": "string",
- "subject": "string",
- "preview_text": "string",
- "html": "string",
- "from_name": "string",
- "scheduled_at": "2019-08-24T14:15:22Z",
- "sent_at": "2019-08-24T14:15:22Z",
- "timezone": "string",
- "is_web_gated": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}, - "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns a paginated list of the user's whitelabel/sender domains
{- "data": [
- {
- "id": 0,
- "domain": "string",
- "sendgrid_whitelabel_domain_id": 0,
- "validated_at": "2019-08-24T14:15:22Z",
- "dns": { },
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "current_page": 0,
- "total": 0,
- "per_page": 0
}Adds a new sender domain and creates the corresponding SendGrid whitelabel domain. Requires an active subscription and SendGrid subuser.
| domain required | string <= 191 characters Domain name (e.g., example.com). Do not include @ or protocol. |
{- "domain": "string"
}{- "id": 0,
- "domain": "string",
- "sendgrid_whitelabel_domain_id": 0,
- "validated_at": "2019-08-24T14:15:22Z",
- "dns": { },
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns domain details including DNS records needed for verification
| id required | integer |
{- "id": 0,
- "domain": "string",
- "sendgrid_whitelabel_domain_id": 0,
- "validated_at": "2019-08-24T14:15:22Z",
- "dns": { },
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Triggers DNS validation for the domain via SendGrid. Returns whether validation passed and any errors for specific DNS records.
| id required | integer |
{- "message": "string",
- "valid": true,
- "domain": {
- "id": 0,
- "domain": "string",
- "sendgrid_whitelabel_domain_id": 0,
- "validated_at": "2019-08-24T14:15:22Z",
- "dns": { },
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}{- "id": 0,
- "name": "string",
- "contacts_count": 0,
- "contact_limit": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Creates a custom field for contacts. The name is auto-generated from the label as a slug.
Provide type (text, number, or date) or the existing contact_field_type_id UUID.
If both are provided, they must identify the same type. UUIDs are the same for every account:
| Type | contact_field_type_id |
|---|---|
| text | 0abe5601-7738-43c8-858f-81911ecf89ee |
| number | e43c6471-b3ff-4541-80c5-964c62decd15 |
| date | fe7dd76c-5113-4159-bc7c-0dbf0ed8d404 |
The value is immutable after creation and is not returned by any endpoint - responses
carry the human-readable type (text, number, date) instead.
| label required | string <= 191 characters Human-readable field label |
| contact_field_type_id | string Enum: "0abe5601-7738-43c8-858f-81911ecf89ee" "e43c6471-b3ff-4541-80c5-964c62decd15" "fe7dd76c-5113-4159-bc7c-0dbf0ed8d404" UUID of the field's data type. Required unless type is provided; must match type if both are provided. |
| type required | string Enum: "text" "number" "date" Readable data type; required unless contact_field_type_id is provided. |
{- "label": "string",
- "contact_field_type_id": "0abe5601-7738-43c8-858f-81911ecf89ee",
- "type": "text"
}{- "id": 0,
- "label": "string",
- "name": "string",
- "type": "text"
}Updates the label and auto-regenerates the name slug.
Note: contact_field_type_id is immutable and cannot be changed after creation.
| id required | integer |
| label required | string <= 191 characters |
{- "label": "string"
}{- "id": 0,
- "label": "string",
- "name": "string",
- "type": "text"
}