API Keys
The Taildove REST API lets you manage your contacts, groups, and campaigns programmatically. All requests require an API key.
Getting an API key
- Go to Settings → API Keys in your account.
- Enter a name for the key (e.g.
Production,Zapier). - Optionally set an expiry date — the key will stop working automatically after midnight on that date. Leave blank for a key that never expires.
- Click Generate key.
- Copy your key immediately — it is shown only once and cannot be retrieved later.
- Store it securely (a secrets manager, environment variable, or vault).
To revoke a key early, click Revoke next to it in the API Keys table. Revoked keys stop working immediately.
Key expiry
Keys with an expiry date automatically become inactive once the date passes — no manual action needed. Expired keys show an Expired status badge in the table and return 401 Unauthorized on API requests, the same as a revoked key.
Use short-lived keys for automated pipelines, CI/CD, or any context where you want automatic rotation without manual clean-up.
Authentication
Pass your API key as a Bearer token in every request:
Authorization: Bearer hz_<your-key>
Code examples
The examples below all perform the same three operations: list contacts, create a contact, and delete a contact. Replace hz_<your-key> with your real key.
BASE="https://app.taildove.com/api/v1"
KEY="hz_<your-key>"
# List contacts
curl "$BASE/contacts" \
-H "Authorization: Bearer $KEY"
# Create a contact (flat payload — no wrapper needed)
curl -X POST "$BASE/contacts" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","first_name":"Jane","subscribed":true,"groups":["Newsletter"]}'
# Delete a contact
curl -X DELETE "$BASE/contacts/1" \
-H "Authorization: Bearer $KEY"
Base URL
https://app.taildove.com/api/v1
All responses are JSON (Content-Type: application/json).
Contacts
List contacts
GET /api/v1/contacts
Query parameters
| Param | Type | Description |
|---|---|---|
q |
string | Filter by email (partial match) |
subscribed |
true / false |
Filter by subscription status |
bounced |
true / false |
Filter by bounce status |
per_page |
integer | Results per page (max 100, default 25) |
page |
integer | Page number |
Response
{
"data": [
{
"id": 1,
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"subscribed": true,
"bounced": false,
"source": "api",
"tags": ["vip"],
"custom_properties": { "company": "Acme" },
"groups": [{ "id": 3, "name": "Newsletter" }],
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-02-01T08:30:00Z"
}
],
"meta": {
"current_page": 1,
"total_pages": 4,
"total_count": 98,
"per_page": 25
}
}
Get a contact
GET /api/v1/contacts/:id
Response — same shape as a single item from the list, wrapped in { "data": { ... } }. The groups array lists every group the contact belongs to.
Create a contact
POST /api/v1/contacts
Request body
You can send fields at the top level (flat) or nested under a contact key — both are accepted:
{
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Smith",
"subscribed": true,
"source": "api",
"groups": ["Newsletter", "VIP"],
"tags": ["newsletter"],
"custom_properties": { "company": "Acme" }
}
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Contact's email address |
first_name |
string | No | First name |
last_name |
string | No | Last name |
subscribed |
boolean | No | Defaults to false if omitted |
source |
string | No | Where the contact came from (e.g. "api", "call") |
groups |
array of strings | No | Group names — each found or created automatically |
tags |
array of strings | No | Labels to attach |
custom_properties |
object | No | Arbitrary key-value pairs (max 50) |
Returns 201 Created with the new contact.
Groups: pass groups as an array of name strings. Each group is found or created automatically and the contact is added to all of them. The response groups array reflects all groups the contact belongs to. The legacy group string param is still accepted for backwards compatibility.
Email domain validation
Before saving, the API verifies that the email domain is legitimate:
- Format — must be a valid email address.
- Not disposable — domains from known temporary-mail providers (Mailinator, Guerrilla Mail, etc.) are rejected.
- MX record — the domain must have a resolvable MX (or A) record, confirming it can receive email.
A failed domain check returns 422 Unprocessable Entity with an extra domain_error field so you can distinguish it from other validation errors:
{
"errors": ["Email domain fake-domain.xyz does not appear to accept email (no MX record found)"],
"domain_error": "no_mx"
}
Possible domain_error values:
| Value | Meaning |
|---|---|
"disposable" |
Domain is a known disposable/temporary provider |
"no_mx" |
Domain has no MX or A record — cannot receive email |
"dns_timeout" |
DNS lookup timed out — domain could not be verified |
The same validation runs when updating a contact's email address.
Update a contact
PATCH /api/v1/contacts/:id
Send only the fields you want to change (flat or wrapped). Returns the updated contact.
If group is included the contact is added to that group (found or created by name). Existing group memberships are not removed — pass group to add, not to replace.
If email is changed the new address goes through the same domain validation as on create.
Delete a contact
DELETE /api/v1/contacts/:id
Returns 204 No Content.
Groups
List groups
GET /api/v1/groups
Response
{
"data": [
{
"id": 1,
"name": "Newsletter",
"contacts_count": 342,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-03-01T12:00:00Z"
}
]
}
Get a group
GET /api/v1/groups/:id
Create a group
POST /api/v1/groups
{ "group": { "name": "VIP customers" } }
Returns 201 Created.
Update a group
PATCH /api/v1/groups/:id
Delete a group
DELETE /api/v1/groups/:id
Returns 204 No Content.
Campaigns
Campaigns are read-only via the API.
List campaigns
GET /api/v1/campaigns
Query parameters
| Param | Type | Description |
|---|---|---|
status |
string | Filter by status: draft, scheduled, sent, failed, approved, sending |
q |
string | Search by name or subject |
per_page |
integer | Results per page (max 100, default 25) |
page |
integer | Page number |
Response
{
"data": [
{
"id": 10,
"name": "March Newsletter",
"title": "What's new in March",
"status": "sent",
"preview_text": "Here's what we shipped...",
"emails_sent": 1240,
"emails_failed": 3,
"total_recipients": 1243,
"unsubscribes_count": 7,
"send_at": null,
"sent_at": "2026-03-01T09:00:00Z",
"created_at": "2026-02-28T14:00:00Z",
"updated_at": "2026-03-01T09:05:00Z"
}
],
"meta": {
"current_page": 1,
"total_pages": 1,
"total_count": 5,
"per_page": 25
}
}
Get a campaign
GET /api/v1/campaigns/:id
Error responses
| Status | Meaning |
|---|---|
401 Unauthorized |
Missing, invalid, revoked, or expired API key |
403 Forbidden |
Plan does not include API access |
404 Not Found |
Resource not found (or belongs to a different account) |
422 Unprocessable Entity |
Validation failed — check the errors array |
Example error
{
"errors": ["Email has already been taken", "Email is not a valid email address"]
}
Rate limiting
Every API response includes headers that show your current quota:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 994
X-RateLimit-Reset: 1741600120
| Header | Description |
|---|---|
X-RateLimit-Limit |
Maximum requests allowed in the window |
X-RateLimit-Remaining |
Requests left in the current window |
X-RateLimit-Reset |
Unix timestamp when the window resets |
Limits
| Scope | Limit | Window |
|---|---|---|
| Per API key | 1 000 requests | 1 minute |
| Per API key | 10 000 requests | 1 hour |
| Per IP address | 300 requests | 1 minute |
When a limit is exceeded the API returns 429 Too Many Requests with a Retry-After: 60 header:
{
"error": "Too many requests. Please slow down and retry after 60 seconds."
}
Handling 429s
Check X-RateLimit-Remaining before it hits zero and pause proactively. If you do receive a 429, wait for the number of seconds in the Retry-After header before retrying.
const res = await fetch(`${BASE}/contacts`, { headers });
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '60', 10);
console.warn(`Rate limited. Retrying in ${retryAfter}s`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
// retry ...
}
Brute-force protection
IPs that send more than 50 invalid authentication attempts within 10 minutes are automatically blocked for that window. Rotate to a valid key or wait for the block to expire.