Taildove Logo
Taildove

API & Integrations

API Keys

Authenticate with the Taildove REST API using API keys to manage contacts, groups, and campaigns.

API Keys

The Taildove REST API lets you manage your contacts, groups, and campaigns programmatically. All requests require an API key.

If you’re using the TypeScript SDK, see TypeScript SDK and SDK Authentication for the SDK-specific setup.


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.

cURL (bash)

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":"jane@example.com","first_name":"Jane","subscribed":true,"groups":["Newsletter"]}'

# Delete a contact
curl -X DELETE "$BASE/contacts/1" \
  -H "Authorization: Bearer $KEY"

JavaScript / TypeScript (fetch)

const BASE = 'https://app.taildove.com/api/v1';
const KEY  = 'hz_<your-key>';

const headers = {
  'Authorization': `Bearer ${KEY}`,
  'Content-Type':  'application/json',
};

// List contacts
const list = await fetch(`${BASE}/contacts`, { headers });
const { data, meta } = await list.json();
console.log(`${meta.total_count} contacts, page ${meta.current_page}/${meta.total_pages}`);

// Create a contact (flat payload)
const create = await fetch(`${BASE}/contacts`, {
  method:  'POST',
  headers,
  body: JSON.stringify({
    email: 'jane@example.com', first_name: 'Jane', subscribed: true, group: 'Newsletter'
  }),
});
const { data: newContact } = await create.json();
console.log('Created:', newContact.id, 'groups:', newContact.groups);

// Delete a contact
await fetch(`${BASE}/contacts/${newContact.id}`, { method: 'DELETE', headers });
console.log('Deleted.');

Works in the browser, Node.js 18+, Deno, and Bun without any extra dependencies.


Python (requests)

import requests

BASE = "https://app.taildove.com/api/v1"
KEY  = "hz_<your-key>"
HEADERS = {"Authorization": f"Bearer {KEY}"}

# List contacts (first page)
resp = requests.get(f"{BASE}/contacts", headers=HEADERS, params={"per_page": 10})
resp.raise_for_status()
payload = resp.json()
print(f"{payload['meta']['total_count']} total contacts")

# Create a contact (flat payload)
new = requests.post(
    f"{BASE}/contacts",
    headers=HEADERS,
    json={"email": "jane@example.com", "first_name": "Jane", "subscribed": True, "groups": ["Newsletter"]},
)
new.raise_for_status()
contact_id = new.json()["data"]["id"]
print(f"Created contact {contact_id}")

# Delete the contact
requests.delete(f"{BASE}/contacts/{contact_id}", headers=HEADERS).raise_for_status()
print("Deleted.")

# Install with: pip install requests

Ruby (Net::HTTP — stdlib only)

require "net/http"
require "json"
require "uri"

BASE = "https://app.taildove.com/api/v1"
KEY  = "hz_<your-key>"

def api(method, path, body = nil)
  uri  = URI("#{BASE}#{path}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  req_class = { get: Net::HTTP::Get, post: Net::HTTP::Post,
                patch: Net::HTTP::Patch, delete: Net::HTTP::Delete }[method]
  req = req_class.new(uri)
  req["Authorization"] = "Bearer #{KEY}"
  req["Content-Type"]  = "application/json"
  req.body = body.to_json if body

  JSON.parse(http.request(req).body)
end

# List contacts
result = api(:get, "/contacts?per_page=10")
puts "#{result.dig("meta", "total_count")} total contacts"

# Create a contact (flat payload)
contact = api(:post, "/contacts",
              { email: "jane@example.com", first_name: "Jane", subscribed: true, group: "Newsletter" })
id = contact.dig("data", "id")
puts "Created contact #{id}"

# Delete the contact
api(:delete, "/contacts/#{id}")
puts "Deleted."

PHP (cURL extension)

<?php
define('BASE', 'https://app.taildove.com/api/v1');
define('KEY',  'hz_<your-key>');

function api(string $method, string $path, ?array $body = null): array {
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . KEY,
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $resp = curl_exec($ch);
    curl_close($ch);
    return json_decode($resp, true);
}

// List contacts
$result = api('GET', '/contacts?per_page=10');
echo $result['meta']['total_count'] . " total contacts\n";

// Create a contact (flat payload)
$created = api('POST', '/contacts', [
    'email' => 'jane@example.com', 'first_name' => 'Jane', 'subscribed' => true, 'group' => 'Newsletter'
]);
$id = $created['data']['id'];
echo "Created contact {$id}\n";

// Delete the contact
api('DELETE', "/contacts/{$id}");
echo "Deleted.\n";

Go (net/http — stdlib only)

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

const (
    base = "https://app.taildove.com/api/v1"
    key  = "hz_<your-key>"
)

func apiRequest(method, path string, body any) (map[string]any, error) {
    var buf io.Reader
    if body != nil {
        b, _ := json.Marshal(body)
        buf = bytes.NewBuffer(b)
    }
    req, _ := http.NewRequest(method, base+path, buf)
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var result map[string]any
    json.NewDecoder(resp.Body).Decode(&result)
    return result, nil
}

func main() {
    // List contacts
    list, _ := apiRequest("GET", "/contacts?per_page=10", nil)
    meta := list["meta"].(map[string]any)
    fmt.Printf("%.0f total contacts\n", meta["total_count"])

    // Create a contact (flat payload)
    created, _ := apiRequest("POST", "/contacts", map[string]any{
        "email": "jane@example.com", "first_name": "Jane", "subscribed": true, "groups": []string{"Newsletter"},
    })
    id := created["data"].(map[string]any)["id"]
    fmt.Printf("Created contact %v\n", id)

    // Delete the contact
    apiRequest("DELETE", fmt.Sprintf("/contacts/%v", id), nil)
    fmt.Println("Deleted.")
}

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

ParamTypeDescription
qstringFilter by email (partial match)
subscribedtrue / falseFilter by subscription status
bouncedtrue / falseFilter by bounce status
per_pageintegerResults per page (max 100, default 25)
pageintegerPage number

Response

{
  "data": [
    {
      "id": 1,
      "email": "jane@example.com",
      "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": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Smith",
  "subscribed": true,
  "source": "api",
  "groups": ["Newsletter", "VIP"],
  "tags": ["newsletter"],
  "custom_properties": { "company": "Acme" }
}
FieldTypeRequiredDescription
emailstringYesContact’s email address
first_namestringNoFirst name
last_namestringNoLast name
subscribedbooleanNoDefaults to false if omitted
sourcestringNoWhere the contact came from (e.g. "api", "call")
groupsarray of stringsNoGroup names — each found or created automatically
tagsarray of stringsNoLabels to attach
custom_propertiesobjectNoArbitrary 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:

ValueMeaning
"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

ParamTypeDescription
statusstringFilter by status: draft, scheduled, sent, failed, approved, sending
qstringSearch by name or subject
per_pageintegerResults per page (max 100, default 25)
pageintegerPage 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

StatusMeaning
401 UnauthorizedMissing, invalid, revoked, or expired API key
403 ForbiddenPlan does not include API access
404 Not FoundResource not found (or belongs to a different account)
422 Unprocessable EntityValidation 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
HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix timestamp when the window resets

Limits

ScopeLimitWindow
Per API key1 000 requests1 minute
Per API key10 000 requests1 hour
Per IP address300 requests1 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.