API reference
One public endpoint does the collecting, and it needs no key. Everything that creates and reads — projects, endpoints, records, export — is one key-authenticated management API, which the dashboard itself is a client of.
Two hosts
| Host | Serves |
|---|---|
| api.mailcollectly.com | The collect endpoint, and nothing else. Cookieless, permissive CORS, callable from anywhere. |
| mailcollectly.com | Dashboard, docs, and the management API. Session or API-key authenticated. |
They are one application. The split exists so a public form endpoint never sits on the same origin as your signed-in session.
POST /c/:public_id
The collect surface. No authentication, by design — the protection is the pipeline, not a secret in your page source.
curl -i -X POST https://api.mailcollectly.com/c/lst_a495ba \
-H "Content-Type: application/json" \
-d '{"email":"rowan@havenlabs.io","name":"Rowan","source":"launch-post"}'Headers
| Header | Effect |
|---|---|
| Content-Type | application/json, application/x-www-form-urlencoded, or multipart/form-data. Anything unrecognised is parsed as URL-encoded. |
| Origin | Checked against the endpoint's allowed hosts when present. Absent is allowed. |
| Referer | Used for the host check when there's no Origin, and stored as the record's source URL. |
| Accept | text/html on a form-encoded post opts into the 303 redirect instead of a JSON body. |
Status codes
| Status | error | When |
|---|---|---|
| 200 | success | Stored, forwarded, deduplicated, or quietly discarded as spam. |
| 303 | — | A plain form post with Accept: text/html and a valid _redirect. |
| 400 | invalid_request | The body couldn't be parsed as JSON, URL-encoded, or multipart. |
| 401 | unauthorized | Management API and MCP only — a missing or invalid key. |
| 403 | domain_not_allowed · captcha_failed | Origin not on the allowed list, or the captcha refused. |
| 404 | not_found | No endpoint with that ID. |
| 409 | account_over_record_limit · endpoint_paused · endpoint_limit_reached | A limit or a state, not a malformed request. Something has to change before a retry helps. |
| 422 | invalid_email | Failed syntax validation, or a disposable-provider address. |
| 429 | rate_limited | Per-address limit or the endpoint's daily cap. Carries retry_after. |
CORS
The collect route answers OPTIONS with 204 and allows any origin, any of the three content types, and POST. Browsers can call it from anywhere; whether a submission is kept is the origin check’s business, not CORS’s.
The error contract
Every refusal — collect, management API, MCP tool result, and the dashboard’s own banner — is the same object in different costumes. Two fields are always present:
- error — a stable machine code. Branch on this.
- message — written for the person who hit it. Show this.
Everything else is code-specific: records_used and records_limit on a record-limit refusal, retry_after on a rate limit, reason and fix on a captcha failure. Codes are added over time; treat an unknown one as its status class and show the message. All of them are printed here.
Management API
Everything the dashboard does — projects, endpoints, records, export — over one key-authenticated API at https://mailcollectly.com/api/v1. The dashboard is a client of it rather than a privileged path around it, so anything you can do by clicking, you can do by calling.
Getting a key
Settings → API keys, in the dashboard. The key is shown once, at creation. What’s stored is a sha256 of it, so there is no “reveal” anywhere and nobody — including us — can read it back. Lost it? Mint another and revoke the old one; that takes about five seconds and is the only design under which the storage claim stays true.
curl https://mailcollectly.com/api/v1/usage \
-H "Authorization: Bearer mc_live_…"Revoking takes effect on the next call. A signed-in dashboard session authenticates the same handlers, which is how the app itself uses them — but cookie-authenticated writes from another origin are refused, so a key is the right answer for anything that isn’t the dashboard.
Shapes
- A single resource comes back as itself — a bare JSON object.
- A collection comes back as { data: [...] }, plus pagination where there’s paging.
- A refusal is the error contract above, and carries the same usage headers as a success.
- Endpoints are addressable by their public ID (lst_9f3a2c) or their UUID. The public one is what you already have.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1 | The index — every route, for whoever pastes the base URL. |
| GET | /api/v1/usage | Records, endpoints, projects, and where they stand. |
| GET | /api/v1/projects | Every project, with endpoint and record counts. |
| POST | /api/v1/projects | Create one. Its first list is provisioned in the same transaction and comes back under provisioned. |
| GET | /api/v1/projects/:id | One project. |
| PATCH | /api/v1/projects/:id | Rename it, or set its domain. |
| DELETE | /api/v1/projects/:id | Delete it, its endpoints, and their records. There is no undo. |
| GET | /api/v1/endpoints | Every endpoint. Filter with ?project=<uuid> and ?kind=list|form. |
| POST | /api/v1/endpoints | Create a list or a form in a project you own. |
| GET | /api/v1/endpoints/:id | One endpoint, including its collect URL. |
| PATCH | /api/v1/endpoints/:id | name, status, allowed_domains, allow_localhost, turnstile, daily_cap. |
| DELETE | /api/v1/endpoints/:id | Delete it and its records. |
| GET | /api/v1/endpoints/:id/records | Newest first. ?limit= (50, max 200) &offset= &search=. |
| GET | /api/v1/endpoints/:id/export | CSV. No cap check, no plan check, ever. |
| GET | /api/v1/endpoints/:id/snippets | The same code the Get Code screen shows. ?language=html|react|fetch|curl. |
| DELETE | /api/v1/records/:id | Delete one record. |
| DELETE | /api/v1/records | Bulk delete: {"ids":[…]}, up to 1,000. Reports how many were actually yours. |
A worked example
From nothing to a live endpoint with a record in it, in four calls. The first one returns the list that was provisioned with the project — nothing has to be created separately for the project to be collecting.
# 1 · a project, and the list that came with it
curl -X POST https://mailcollectly.com/api/v1/projects \
-H "Authorization: Bearer $MC_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Squire Editor","domain":"squireeditor.com"}'
# → { "id": "…", "provisioned": [ { "public_id": "lst_9f3a2c", "url": "…" } ] }
# 2 · anyone can post to it — no key, that's the collect surface
curl -X POST https://api.mailcollectly.com/c/lst_9f3a2c \
-H "Content-Type: application/json" \
-d '{"email":"rowan@havenlabs.io","name":"Rowan"}'
# 3 · read it back, newest first
curl "https://mailcollectly.com/api/v1/endpoints/lst_9f3a2c/records?limit=50" \
-H "Authorization: Bearer $MC_KEY"
# 4 · or take the whole thing as CSV
curl "https://mailcollectly.com/api/v1/endpoints/lst_9f3a2c/export" \
-H "Authorization: Bearer $MC_KEY" -o signups.csvPagination
Record lists carry a pagination object saying what was actually used — limit, offset, total, has_more — so a caller never has to guess whether its limit was honoured. Ask for more than 200 and you get 200, said out loud rather than silently.
{
"data": [
{
"id": "9d3f…",
"email": "rowan@havenlabs.io",
"name": "Rowan",
"data": {
"source": "launch-post"
},
"country": "US",
"created_at": "2026-08-02T18:11:04.220Z"
}
],
"pagination": {
"limit": 50,
"offset": 0,
"total": 3842,
"has_more": true
}
}Usage headers
Every authenticated response carries where you stand — successes and refusals alike, because the moment you most want to know is the moment you have just been told no.
X-Records-Used: 8412
X-Records-Limit: 10000
X-Endpoints-Used: 7
X-Endpoints-Limit: 10MCP server
Streamable HTTP at https://mailcollectly.com/api/mcp, authenticated with the same API key. Point an agent at it and it can provision an endpoint mid-build, without you leaving the editor to click anything.
claude mcp add --transport http mail-collectly https://mailcollectly.com/api/mcp \
--header "Authorization: Bearer $MC_KEY"For editors configured by file, it’s the same three facts — transport, URL, header:
{
"mcpServers": {
"mail-collectly": {
"type": "http",
"url": "https://mailcollectly.com/api/mcp",
"headers": {
"Authorization": "Bearer ${MAIL_COLLECTLY_KEY}"
}
}
}
}The tools
| Tool | What it does |
|---|---|
| list_projects | Every project, with its endpoint and record counts. |
| list_endpoints | Every list and form, or one project's — id, name, kind, status, collect URL, record count. Where an agent starts when it didn't create the endpoint itself. |
| create_project | Create a project. Its first list is provisioned with it and comes back live. |
| create_list | An email list in a project, addressed by id or by exact name. |
| create_form | A form endpoint in a project. Stored submissions. |
| get_embed_code | The paste-ready snippet, spam fields included. |
| list_records | Records for an endpoint, newest first, paginated. |
| delete_records | Delete records by id. Frees the allowance immediately. |
| export_csv | CSV for an endpoint, a project, or everything. |
| get_usage | Where the account stands against its limits. |
Every tool is a wrapper over the routes above — same store, same limits, same refusals — and every result carries the usage block, so an agent can tell you your form isn’t broken, you’ve just hit the fence. A refusal arrives with its human sentence first, which is the sentence worth repeating to you verbatim rather than paraphrasing.
Addressing things by name
The session that creates an endpoint knows its id. Every session afterwards doesn’t — so get_embed_code, list_records and export_csv also take project plus endpoint_name in place of an id, and create_list / create_form take a project by name.
Endpoint names are only unique inside a project — “Signups” is the default list name, so an account with two projects has two of them — which is why the name always needs its project alongside it. A miss answers with what does exist: No endpoint called “Waitlist” in “Nightjar”. It has “Signups” and “Tour dates”. An agent corrects itself from that in one step instead of asking you.
There is no tool that deletes a project or an endpoint, and none that mints or reads API keys. Removing a whole audience is a decision to make in the dashboard, where the typed confirmation is; a key that can create more keys is a key worth stealing.
Endpoint IDs
IDs are prefixed by kind: lst_ for lists, frm_ for forms — lst_a495ba. They are public: they ship in your page source the way any form action does, and they are safe there because the collect surface is designed to be posted to by strangers.