Newsletter signup for Astro, backed by Acumbamail. A platform-agnostic core, an accessible form, and an endpoint you can drop into a route file — double opt-in by default, GDPR consent checked on the server, honeypot instead of CAPTCHA, and no dependencies.
Extracted from a production integration running on Cloudflare Workers.
Sibling of
astro-sitemap-pro-componentandastro-social-ai-component.
A static Astro site cannot store an email address. Calling the Acumbamail API from the browser would mean publishing your account token in the HTML — and that token doesn't grant access to one list, it grants access to every list on the account: read them, write to them, wipe them.
So every signup form in Astro ends up writing the same four pieces: an endpoint that takes the POST, a form with a bot trap and a consent checkbox, the progressive-enhancement JavaScript that shows the result without a reload, and the copy in every language the site speaks.
This package is those four pieces, already written and tested.
subscribe()— the core. No platform imports, no environment variables, noRequest. Injectablefetch, so it's testable without a network.Subscribe.astro— the form. Honeypot, unchecked consent box,aria-livestatus that takes focus when the result lands, guarded against double submits, survives View Transitions, and submits with JavaScript off (see Without JavaScript).createSubscribeHandler()— the endpoint, in one line, for Cloudflare Workers, Node, Vercel, Netlify or anything that speaksRequest/Response.- 11 built-in languages — en, es, pt, pt-br, fr, it, de, nl, ca, gl, eu — plus per-string overrides.
npm install github:dcarrero/Astro-Acumbamail-Component#v0.2.0dist/ is committed, so installing straight from a GitHub tag needs no build step.
Cloudflare Workers, Astro 6 or 7 — src/pages/api/subscribe.ts:
import { createSubscribeHandler } from "astro-acumbamail-component/cloudflare";
export const { POST, GET } = createSubscribeHandler({ langs: ["en", "es"], defaultLang: "en" });
export const prerender = false;Node, Vercel, Netlify, or Astro 5 on Cloudflare — the generic entry point resolves the environment on its own:
import { createSubscribeHandler } from "astro-acumbamail-component/handlers";
export const { POST, GET } = createSubscribeHandler({ langs: ["en", "es"] });
export const prerender = false;ACUMBAMAIL_AUTH_TOKEN secret — the "Identificador de cliente" from your Acumbamail panel
ACUMBAMAIL_LIST_ID_EN variable — list id for English
ACUMBAMAIL_LIST_ID_ES variable — list id for Spanish
ACUMBAMAIL_DOUBLE_OPTIN variable — "1" (default) or "0"
The handler looks for ACUMBAMAIL_LIST_ID_<LANG> first and falls back to ACUMBAMAIL_LIST_ID, so
a single-language site only needs that one.
On Cloudflare: wrangler secret put ACUMBAMAIL_AUTH_TOKEN for the token, plain vars for the
rest. Locally, .dev.vars — and add it to .gitignore, always.
---
import Subscribe from "astro-acumbamail-component/Subscribe.astro";
---
<Subscribe lang="es" id="footer" privacyHref="/privacidad/" />That's it. The form posts to /api/subscribe, the endpoint calls Acumbamail, and the subscriber
gets a confirmation email.
| Prop | Type | Default | Notes |
|---|---|---|---|
action |
string |
/api/subscribe |
Endpoint path. |
lang |
string |
en |
Which strings to use; also posted so the endpoint can pick the list. |
id |
string |
subscribe |
Prefix for internal ids. Change it if the form appears twice on a page — duplicate ids break the label/field association a screen reader depends on. |
privacyHref |
string |
/privacy/ |
Link inside the consent checkbox. |
fields |
Array<string | FieldSpec> |
[] |
Extra fields beyond email. Each must exist in the Acumbamail list and be whitelisted in the handler. |
labels |
Partial<Strings> |
— | Override any string of the chosen language. |
heading |
string | null |
null |
Small label above the form. |
note |
string | null |
null |
Fine print under the form — how often you write, what you send. |
showEmailLabel |
boolean |
false |
By default the email label is screen-reader only. |
honeypot |
string |
ab_hp |
Name of the trap field. Must match the handler's honeypot option. |
class |
string |
— | Extra class on the wrapper. |
FieldSpec is { name, label?, placeholder?, type?, required?, autocomplete? }.
<Subscribe
lang="en"
id="post-footer"
fields={[{ name: "name", label: "Your name", autocomplete: "given-name" }]}
heading="Get the newsletter"
note="Every two weeks. Nothing else."
/>createSubscribeHandler({
langs: ["en", "es"], // accepted languages; anything else falls back to defaultLang
defaultLang: "en",
fields: ["name"], // whitelist of custom fields accepted from the form
mergeFields: { source: "web" }, // fixed fields added to every signup
doubleOptin: true, // default; env ACUMBAMAIL_DOUBLE_OPTIN="0" turns it off
honeypot: "ab_hp", // must match the component's prop; false disables it
consentField: "consent", // "gdpr" is always accepted as an alias
redirect: { ok: "/thanks/", error: "/error/" }, // 303 for no-JS submits; JSON when JSON is asked for
listId: (lang, env) => env[`LIST_${lang}`], // or a plain string
timeoutMs: 10_000,
onSuccess: ({ email, lang }) => { /* analytics; throwing here won't break the response */ },
})fields is a whitelist on purpose. Without it, a direct POST could write into any custom
field of your list.
| Case | Status | Body |
|---|---|---|
| Success | 200 |
{"ok":true} |
| Honeypot filled | 200 |
{"ok":true} — and nothing happens |
| Unreadable body (wrong content-type, malformed) | 400 |
{"ok":false,"error":"bad_request"} |
| Malformed / missing email | 400 |
{"ok":false,"error":"invalid_email"} |
| Consent not given | 400 |
{"ok":false,"error":"consent_required"} |
| Token or list id missing | 500 |
{"ok":false,"error":"misconfigured"} |
| Acumbamail rate limit hit | 429 |
{"ok":false,"error":"rate_limited"} |
| Acumbamail rejected it | 502 |
{"ok":false,"error":"provider_error"} |
| Acumbamail unreachable / timed out | 502 |
{"ok":false,"error":"provider_unreachable"} |
GET |
405 |
Allow: POST |
| Cross-origin POST | 403 |
Cut by Astro before the endpoint runs |
The error codes are public API. The form maps them to messages, so changing them breaks anyone
who has them mapped. rate_limited is worth retrying later; the rest are not.
The form submits fine with JavaScript off — but by default the browser then renders the raw
{"ok":true}, which leaves the person staring at JSON outside your site. If that matters to
you, set redirect:
createSubscribeHandler({
redirect: { ok: "/thanks/", error: "/subscribe-error/" },
})Now a no-JS submit gets a 303 to those pages (post/redirect/get, so a reload doesn't resubmit),
while anything sending Accept: application/json — including this component's own script — still
gets JSON. Without error, failures go to the ok URL with ?error=<code> appended, so one page
can handle both.
The endpoint is public and there is no rate limiting. With double opt-in on, anyone can loop
curl against it with someone else's address and Acumbamail will send that person a confirmation
email every time — filling their inbox from your domain, hurting your sending reputation and
burning your account quota. The honeypot doesn't help here: a curl client simply doesn't fill it.
If your form is worth attacking, put something in front of the endpoint: rate limiting by IP (Cloudflare Rate Limiting rules, or a KV counter) and/or a challenge such as Turnstile. This component doesn't ship either, on purpose — it can't know your infrastructure — but the exposure is real and it's yours to close.
Acumbamail's own documented limits are per function (5 req/s or 10 req/min on several of them);
addSubscriber declares none. A 429 from them comes back as rate_limited.
import { subscribe } from "astro-acumbamail-component/core";
const result = await subscribe(
{ email: "someone@example.com", consent: true, fields: { name: "Ana" } },
{ authToken: TOKEN, listId: "12345" },
);
// { ok: true, id: 987 } | { ok: false, error: "provider_error", detail: "HTTP 502" }It never throws — every failure comes back as { ok: false, error }. Pass fetch to test it
without a network; that's what the package's own tests do.
Styles are scoped and driven by --ab-* variables that fall back to a host theme
(--ink, --muted, --accent, --border-strong, --surface, --ok, --bad) and then to
neutral values.
Map the host theme's variables — that is the path that works. Define them anywhere they'll be
inherited (:root, a wrapper, whatever):
:root {
--accent: #2563eb;
--border-strong: #767676; /* keep it at 3:1 against your background — WCAG 1.4.11 */
--radius: 0;
}Overriding the --ab-* variables from outside needs a bit of care: the component declares them in
its own scoped selector, which Astro compiles to .ab-subscribe[data-astro-cid-…] (specificity
0,2,0). A plain .ab-subscribe { --ab-accent: red } of yours (0,1,0) loses and does nothing —
this README claimed otherwise until 0.2.0. Repeat the class to win:
.ab-subscribe.ab-subscribe {
--ab-accent: #2563eb;
}Each of these came out of writing the integration for real.
-
Double opt-in is the default. The address doesn't enter the list until its owner clicks the link in the confirmation email. It costs subscribers and the ones left are real — and it leaves a record of consent, which is what the GDPR asks you to be able to show.
-
Consent is checked on the server.
requiredon the checkbox is a convenience for the person filling the form, not a guarantee for the person storing the data: a direct POST skips it. And the box is never pre-checked — consent has to be an affirmative act. -
A honeypot, not a CAPTCHA. A hidden
websitefield; if it arrives filled, the endpoint answers success and does nothing — a bot that gets an error retries another way, one that gets success moves on. Double opt-in already filters fake addresses, and a CAPTCHA is a barrier for real people in exchange for little. -
Deliberately permissive email validation. A strict regex rejects valid addresses — long TLDs,
+, hyphenated domains — and blocks no fake ones:a@b.cpasses any validator and may not exist. What decides whether an address is real is the confirmation email. -
Always set
langs. Without it, the postedlangpicks whichACUMBAMAIL_LIST_ID_*variable is read, so a crafted POST can aim at any list you have configured. Withlangs, anything unexpected falls back todefaultLang. -
One list per language, not one list with a language field. In Acumbamail a campaign is sent to a list, so splitting at signup is what saves you from segmenting on every send.
-
No local copy of the addresses. A second database holding the same emails is a second place they can leak from and a second obligation to answer on an erasure request, in exchange for nothing — exporting from Acumbamail is a CSV.
-
No CSRF check in the endpoint. Astro ships
security.checkOriginon by default and rejects cross-origin form POSTs with a403before your code runs. Writing anotherOrigincheck would be code that never executes and that someone will maintain believing it's the real defence.The precise scope, read from Astro's own middleware: it blocks non-safe methods whose
Content-Typeis form-like (application/x-www-form-urlencoded,multipart/form-data,text/plain) or absent, whenOrigindoesn't match the request's own origin. A cross-origin POST withContent-Type: application/jsonisn't blocked there — it's blocked by the browser, which requires a CORS preflight that a site without CORS headers never answers. Both paths are covered; they're just covered by different things.
Astro.locals.runtime.envdoesn't exist from Astro 6 on. It's what most circulating examples teach; it compiles and blows up at runtime with a 500. On Workers useastro-acumbamail-component/cloudflare, which readscloudflare:workers. On Astro 5 the generic handler still readscontext.locals.runtime.env, so both work.- With Astro 7.1.6,
@astrojs/cloudflare@14.2.0breaks the build ("beginContentEntryCollection" is not exported…); 14.1.7 works. Pin the exact version. - In
wrangler.jsonc,mainmust be"@astrojs/cloudflare/entrypoints/server".
POST https://acumbamail.com/api/1/addSubscriber/
Content-Type: application/x-www-form-urlencoded
| Parameter | Required | What it is |
|---|---|---|
auth_token |
yes | The "Identificador de cliente" from the panel. Secret. |
list_id |
yes | Numeric list id |
merge_fields[email] |
yes | The address. Fields always go inside merge_fields[…] |
merge_fields[<name>] |
no | Any custom field of the list, by its name |
double_optin |
no | 1 sends a confirmation email; 0 subscribes straight away |
update_subscriber |
no | 1 makes the call idempotent |
response_type |
no | json or XML. JSON is the default anyway |
complete_json |
no | 1 makes success return {"email": …, "id": …} instead of a bare integer |
The trailing slash matters: without it the API answers 301, and fetch turns a redirected POST
into a GET, dropping the body.
On success it returns the subscriber id (a number, or the object above with complete_json=1). On
failure, an object with error or errors. There is no ok field, and an invalid token
answers 401 with plain text, not JSON — so you have to look at the body as well as the
status. A successful signup may also come back as 201.
Documented status codes: 200 ok, 201 modified, 400 bad parameters, 401 auth failed,
429 too many requests, 500 server error.
Until 0.2.0 this component sent
_response_type, with a leading underscore. That parameter does not exist: the API ignored it silently and returned JSON because JSON is its default. Fixed in 0.2.0 — if you copied that detail from here, it's wrong.
This component collects addresses. It doesn't send the campaigns — those are written and sent
from the Acumbamail panel — and it doesn't manage the list: getSubscribers, deleteSubscriber
and friends are not implemented, and shouldn't be reachable from a public endpoint anyway.
It fits any form whose destination is a list: newsletter signup, lead capture with extra fields, waiting list, resource download. It is not a contact form: the message would end up as a field on a subscriber, and the sender would be signed up to marketing without asking for it.
npm install
npm test # builds, then runs every test with an injected fetch- 54 unit tests over the core, the handlers and the strings, with
fetchinjected — no network, no server. Every bug fixed in 0.2.0 has a test that fails without the fix. - View Transitions, the CSS custom properties, the focus move and the rendered markup checked in a real Chrome against a real build.
- Built and rendered in a real Astro 5 project and a real Astro 7 +
@astrojs/cloudflare@14.1.7project, installed as a packed tarball (the same path as installing from a GitHub tag). - Every response in the table above exercised end to end: against
node ./dist/server/entry.mjswith the Node adapter, and againstwrangler dev --localon Workers — including the cross-origin403, which Astro cuts before the handler runs. - With a deliberately wrong token, the request does reach Acumbamail: it answers
401and the core turns that intoprovider_error. - Not yet verified: the success path against a real token and list id, which needs an account. Everything up to the API call is covered.
MIT © David Carrero Fernández-Baillo