Cocomail API

The Cocomail REST API is available on every plan. Use it to manage contacts, trigger sends and receive webhooks for list and delivery events. The base URL is https://cocomail.cc/v1 and every request and response body is JSON.

Authentication

Create an API key in Settings → API keys (the key is shown once — store it somewhere safe) and pass it as a bearer token:

curl https://cocomail.cc/v1/contacts \
  -H "Authorization: Bearer ck_your_api_key"

Keys can be revoked at any time from the same screen. Requests without a valid key return 401.

Rate limits

Each key may make 60 requests per minute. Every response carries the current window state:

HeaderMeaning
X-RateLimit-LimitRequests allowed per window (60)
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix time (seconds) when the window resets

Once the limit is hit, requests return 429 Too Many Requests with a Retry-After header (seconds).

Errors

Errors use one envelope:

{ "error": { "code": "exists", "message": "This contact already exists." } }
StatusCodeMeaning
400invalid_requestMalformed body or parameters
401unauthorizedMissing, invalid or revoked key
403account_inactive, planNo active subscription, or a plan-gated feature
404not_foundResource missing or owned by another account
409existsA contact with this email already exists
422suppressed, quota, identity, address, audience, …Valid request refused by list or sending rules
429rate_limitedRate limit exceeded

Contacts

A contact has id, email, firstName, lastName, name (the composed display name, read-only), tags, status (subscribed, pending, unsubscribed, bounced, complained), source, createdAt and updatedAt.

List contacts

GET /v1/contacts — paginated, newest cursor wins. Query parameters: limit (1–100, default 50), cursor (from the previous response), status, tag, search.

curl "https://cocomail.cc/v1/contacts?status=subscribed&limit=2" \
  -H "Authorization: Bearer ck_your_api_key"
{
  "data": [
    {
      "id": "k97…",
      "email": "[email protected]",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "name": "Ada Lovelace",
      "tags": ["vip"],
      "status": "subscribed",
      "source": "form",
      "createdAt": 1751500000000,
      "updatedAt": 1751500000000
    }
  ],
  "nextCursor": "…",
  "hasMore": true
}

Pass nextCursor back as cursor to fetch the next page; nextCursor is null on the last page.

Create a contact

POST /v1/contacts with email (required), firstName, lastName, tags (max 20). The contact is created as subscribed with source api. A legacy single name is still accepted and split on the first space.

curl -X POST https://cocomail.cc/v1/contacts \
  -H "Authorization: Bearer ck_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "firstName": "Grace", "lastName": "Hopper", "tags": ["vip"]}'

Returns 201 with the contact. Two rules always apply, exactly as in the dashboard:

  • Dedupe — emails are unique per account (case-insensitive). A duplicate returns 409 exists.
  • Suppression — an address that previously unsubscribed, bounced or complained returns 422 suppressed. It can only rejoin by subscribing again itself (e.g. via your subscribe page), which restores consent.

Fetch, update, delete

GET /v1/contacts/{id} returns one contact. PATCH /v1/contacts/{id} accepts firstName, lastName (or a legacy name, split on the first space), tags, and/or status: "unsubscribed" — unsubscribing via the API writes the same permanent suppression as the dashboard action. DELETE /v1/contacts/{id} removes the row; suppressions for the address stay in force.

curl -X PATCH https://cocomail.cc/v1/contacts/k97… \
  -H "Authorization: Bearer ck_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"status": "unsubscribed"}'

Tags

GET /v1/tags returns your distinct tags with usage counts, most-used first:

{ "data": [{ "tag": "vip", "count": 120 }, { "tag": "beta", "count": 14 }] }

Sends

Queue a send

POST /v1/sends queues a newsletter. Two forms:

  • {"draftId": "…"} — send a draft composed in the editor.
  • {"subject": "…", "html": "<p>…</p>", "audience": {"type": "all"}} — send raw HTML (Pro plan). audience is optional (defaults to all subscribers) or {"type": "tags", "tags": ["vip"]}.
curl -X POST https://cocomail.cc/v1/sends \
  -H "Authorization: Bearer ck_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Issue #42", "html": "<p>Hello!</p>", "audience": {"type": "tags", "tags": ["vip"]}}'

Returns 202 with { "id": "…", "status": "queued", "totalRecipients": 1234 }.

API sends run the same checks as the dashboard send button: a verified sending identity, your physical mailing address on file, a non-empty audience, your monthly email quota (checked before anything is queued), account standing, and the first-send review rules. A failed check returns 422 (or 403 for plan gates) with the same message the dashboard would show, and nothing is queued.

Send status

GET /v1/sends/{id} returns the subject, status (queued, sending, sent, failed, …), audience, per-send stats (delivered, opened, clicked, bounced, complained, unsubscribed) and live progress while sending.

Webhooks

Configure endpoints in Settings → Webhooks: an HTTPS URL plus the events you want. Cocomail POSTs a JSON payload for each event:

{
  "type": "subscriber.added",
  "createdAt": 1751500000000,
  "data": {
    "contact": {
      "id": "k97…",
      "email": "[email protected]",
      "firstName": "Grace",
      "lastName": "Hopper",
      "name": "Grace Hopper",
      "tags": ["vip"],
      "status": "subscribed",
      "source": "api",
      "createdAt": 1751500000000,
      "updatedAt": 1751500000000
    }
  }
}
EventFired whendata
subscriber.addedA contact joins the list (form signup, confirmed opt-in, or API)contact
subscriber.unsubscribedA contact unsubscribes (one-click link, dashboard, or API)contact
send.completedA send finishes processingsend (id, subject, status, counts)
email.bouncedA recipient hard-bounces and is suppressedemail, contactId, sendId, bounceType
email.complainedA recipient marks a message as spamemail, contactId, sendId

CSV imports intentionally do not emit subscriber.added — a large import would flood your endpoint.

Verifying signatures

Every delivery is signed with your endpoint's secret (shown in Settings → Webhooks). The X-Cocomail-Signature header looks like:

t=1751500000,v1=5257a869e7ecebeda32affa27cdc0bb653437d5df249e69a83e35f5b09e56a1a

t is the unix time (seconds) of the delivery attempt and v1 is the hex HMAC-SHA256 of t + "." + rawBody — the timestamp, a dot, then the raw request body — keyed with your signing secret. To verify:

  • Split the header on ,, then each part on =, to get t and v1.
  • Reject if t is more than 5 minutes from your current time (blocks replays).
  • Compute HMAC_SHA256(secret, t + "." + rawBody) and compare it to v1 with a constant-time comparison.
const crypto = require("node:crypto");

function verifyCocomailSignature(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  return (
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  );
}

Respond with any 2xx status within 10 seconds. Anything else (including a timeout) is retried up to 5 times with exponential backoff — 30 seconds, 2 minutes, 10 minutes, 1 hour, then 4 hours. Every attempt is visible in the delivery log in Settings → Webhooks. Deliveries also carry X-Cocomail-Event (the event type) and X-Cocomail-Delivery (a unique delivery id, useful for deduplication).