Kontacts docs
Newsletters

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

StatusBody errorCause
400invalid_bodyBody isn't parseable JSON (for a JSON request).
400invalid_emailemail / Email missing or not address-shaped.
403origin_not_allowedThe audience has an origin allowlist configured and the request's Origin isn't on it.
404audience_not_foundNo audience matches {public_key}, or the owner disabled it.
413payload_too_largeBody over 65536 bytes (64 KB).
429rate_limitedMore than 10 subscribe attempts from this IP against this audience in the past hour.
429limit_exceededThe project's plan has a subscriber-count ceiling and this audience is at it. Body carries a message naming the limit.
500server_errorUnexpected 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.

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.

On this page