Taildove Logo
Taildove
API & Integrations

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

  1. Go to Settings → API Keys in your account.
  2. Enter a name for the key (e.g. Production, Zapier).
  3. Optionally set an expiry date — the key will stop working automatically after midnight on that date. Leave blank for a key that never expires.
  4. Click Generate key.
  5. Copy your key immediately — it is shown only once and cannot be retrieved later.
  6. 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.