Newsletters
Audiences and issues, managed per project from the dashboard. Subscribe over a public endpoint.
Newsletters let a project collect an audience — readers subscribe through a
public endpoint — and send issues to it. Sending an issue uses the project's
own verified domain. Audiences, drafts, and sending are
managed from Newsletters
(/dashboard/newsletters) — that page also prints the live
subscribe URL for each audience.
Subscribe
POST /api/v1/subscribe/{public_key}
Adds an email address to one audience. {public_key} names the audience and
comes from Newsletters in the dashboard for that audience.
This is a public key, not an API key
There is no Authorization header on this endpoint. The public key is
meant to sit in page source — same model as the form
endpoint — not the Bearer key described on
Authentication. Anyone who can see
your subscribe form can see this key; that is by design, and it is not a
secret to protect.
Request
Accepts a JSON body, a classic form post
(application/x-www-form-urlencoded), or multipart/form-data — same three
shapes as the form endpoint. The one field the endpoint reads
is email (or Email); anything else in the body is ignored.
An Origin header is checked against an allowlist only when the audience
owner has configured one — with no allowlist configured, any origin (or
none, e.g. a server-side curl) is accepted. A configured allowlist requires
the header; a missing or non-matching Origin is refused.
Anti-spam and idempotency
Two hidden fields are recognized as a honeypot,
_gotcha and
_hp — any non-empty value on either
is treated as spam and answered as if the request succeeded, without
adding a subscriber. A _ts field
carrying a submit-page-rendered timestamp is checked against a minimum
fill time; too fast is treated the same way. Send an
Idempotency-Key header (or a
_idem field) to make a retried
submission a no-op rather than a second attempt against the rate limit.
Response
201 Created for a new subscriber:
{ "ok": true, "id": "subscriber-uuid", "duplicate": false }200 OK for an address already actively
subscribed to this audience (duplicate: true, same id), for a replayed
idempotency key ({ "ok": true, "duplicate": true }, no id), and for a
request the spam check silently absorbs ({ "ok": true }) — none of these
three is distinguishable from the others by status code alone; check the
body shape if the distinction matters to your integration.
Errors
| Status | Body error | Cause |
|---|---|---|
| 400 | invalid_body | Body isn't parseable JSON (for a JSON request). |
| 400 | invalid_email | email / Email missing or not address-shaped. |
| 403 | origin_not_allowed | The audience has an origin allowlist configured and the request's Origin isn't on it. |
| 404 | audience_not_found | No audience matches {public_key}, or the owner disabled it. |
| 413 | payload_too_large | Body over 65536 bytes (64 KB). |
| 429 | rate_limited | More than 10 subscribe attempts from this IP against this audience in the past hour. |
| 429 | limit_exceeded | The project's plan has a subscriber-count ceiling and this audience is at it. Body carries a message naming the limit. |
| 500 | server_error | Unexpected failure. |
HTML
<form action="https://kontacts.dev/api/v1/subscribe/{public_key}" method="POST">
<input type="email" name="email" required>
<input type="hidden" name="_gotcha">
<button type="submit">Subscribe</button>
</form>
JavaScript
fetch("https://kontacts.dev/api/v1/subscribe/{public_key}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
Unsubscribe
Every sent issue carries the two mail headers a compliant client uses to offer
a one-click unsubscribe button (RFC 8058):
List-Unsubscribe, carrying a URL unique to that one
recipient and that one list, and
List-Unsubscribe-Post: List-Unsubscribe=One-Click. There
is nothing to switch on — this is what already happens on every send.
The rest of this section describes the URL that first header carries. It is documented because it is a stable public address that mail we have already delivered points at, not because it is a step in an integration.
POST /api/v1/unsubscribe/{token}
You will not call this yourself. The {token} identifies a single subscriber
on a single list and exists only inside one delivered message, so the only
caller that can ever hold one is the mail client that received it — which
posts here automatically when the reader presses Unsubscribe.
A capability token, not an API key
There is no Authorization header on this endpoint, and the token is not a
Bearer key of the kind described on
Authentication. RFC 8058
requires the absence: a one-click unsubscribe must be sent with no cookies
and no authorization, so that removing yourself from a list cannot be tied
to anything else you have done. Possession of the token is the whole
credential, which is why it is long, random, and specific to one recipient.
What the client sends
A POST whose body is the same key/value pair as the header, encoded as
multipart/form-data or
application/x-www-form-urlencoded. Both work, and so does a
request with no body at all: the endpoint reads only the token in the URL, and
the spec leaves no room for a body to carry anything else.
200 OK once the address is off the list:
{ "ok": true }Repeating the request is not an error — an address already unsubscribed
answers the same way, so a client that retries cannot get a failure for having
succeeded.
404 Not Found means no subscriber
holds that token.
Opening the link in a browser
Some clients ignore the one-click header and simply open the
List-Unsubscribe URL. That is handled: a GET answers
302 and redirects to
/unsubscribe/{token}, the page that unsubscribes and confirms
it. The redirect itself changes nothing — a link scanner that merely follows
the header URL does not take anyone off a list.
The unsubscribe link inside the body of an issue points at that page directly, so a reader who clicks it lands somewhere that explains what happened rather than on a bare API response.
There is no endpoint that takes an email address and removes it — nothing accepts an address here, only a token that belongs to one recipient. Taking someone off a list on their behalf is a dashboard action.
Also shipped
- Custom domain required. Newsletters send from the project's own verified domain; the shared kontacts.dev sending address is not available for newsletter issues.
- Draft, send, and stats — composing an issue, sending it to an audience, and per-issue delivered/bounced counts — are dashboard-only today: there is no REST or MCP surface for any of it. Subscribe is the only endpoint here you would write code against; the unsubscribe URL above is called by mail clients, not by you.