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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per window (60) |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix 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." } }| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body or parameters |
| 401 | unauthorized | Missing, invalid or revoked key |
| 403 | account_inactive, plan | No active subscription, or a plan-gated feature |
| 404 | not_found | Resource missing or owned by another account |
| 409 | exists | A contact with this email already exists |
| 422 | suppressed, quota, identity, address, audience, … | Valid request refused by list or sending rules |
| 429 | rate_limited | Rate 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).audienceis 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
}
}
}| Event | Fired when | data |
|---|---|---|
subscriber.added | A contact joins the list (form signup, confirmed opt-in, or API) | contact |
subscriber.unsubscribed | A contact unsubscribes (one-click link, dashboard, or API) | contact |
send.completed | A send finishes processing | send (id, subject, status, counts) |
email.bounced | A recipient hard-bounces and is suppressed | email, contactId, sendId, bounceType |
email.complained | A recipient marks a message as spam | email, 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=5257a869e7ecebeda32affa27cdc0bb653437d5df249e69a83e35f5b09e56a1at 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 gettandv1. - Reject if
tis more than 5 minutes from your current time (blocks replays). - Compute
HMAC_SHA256(secret, t + "." + rawBody)and compare it tov1with 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).