Kit Confirmation Emails Not Sending From a Static Astro Site
Traced a silent 200-OK with no confirmation email through a wrong API version, a 12-hour per-address suppression window, and a per-form template scope that doesn't inherit globally.
ON THIS PAGE
I was setting up a Kit (ConvertKit) newsletter subscription form on a static Astro site deployed to Vercel. The goal was double opt-in: subscriber submits email, receives a confirmation email, clicks to confirm. The API returned 200 on every request. No confirmation email arrived on any attempt.
Environment
| Component | Detail |
|---|---|
| Site | Astro 5, static, deployed on Vercel |
| Newsletter provider | Kit (formerly ConvertKit) |
| Subscription endpoint | Vercel Edge Function (api/subscribe.ts) |
| Kit API | v3 (api.convertkit.com/v3) |
| Kit form ID | <form-id> |
Step 1 — First Attempt: Buttondown (CORS Block)
The initial implementation used Buttondown’s embed endpoint, called directly from the browser via fetch:
fetch('https://buttondown.com/api/emails/embed-subscribe/<username>', {
method: 'POST',
body: formData,
});
This failed immediately with a CORS error. Buttondown’s embed endpoint is designed to be a form action target, not a fetch endpoint — it sets no CORS headers for cross-origin browser requests.
A Vercel Edge Function proxy was added to forward the request server-side. CORS resolved. Buttondown then returned 400 with no body on every email address attempted. Their support confirmed the domain was flagged by their spam filter. Moved to Kit.
Step 2 — Kit API Version Mismatch
Kit’s dashboard shows two keys: an API key and an API secret. Kit also runs two completely separate API versions with different auth schemes.
The first implementation used the v4 key (prefixed kit_) against the v4 endpoint:
const res = await fetch(
`https://api.kit.com/v4/forms/<form-id>/subscribers`,
{
method: 'POST',
headers: {
'Authorization': `Bearer <redacted-token>`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email_address: email }),
}
);
Response:
{"errors": ["The access token is invalid"]}
HTTP 401. The kit_ prefixed key is the v4 API key. It should have worked with the v4 endpoint. It didn’t — the account was provisioned during a transitional period and the kit_ key was not activated for the v4 API.
The fix was to switch to the v3 API, which uses api_key as a POST body parameter rather than a Bearer token:
const res = await fetch(
`https://api.convertkit.com/v3/forms/<form-id>/subscribe`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey, email }),
}
);
Response: HTTP 200. Subscriber created. No confirmation email received.
Step 3 — Subscriber Created as inactive, No Email
The full API response:
{
"subscription": {
"id": "<redacted-token>",
"state": "inactive",
"created_at": "2026-03-24T10:14:33.000Z",
"source": "API::V3",
"referrer": null,
"subscribable_id": "<form-id>",
"subscribable_type": "form",
"subscriber": {
"id": "<redacted-token>",
"email_address": "test@example.com"
}
}
}
state: inactive is the correct state for a double opt-in subscriber — it means Kit is waiting for the subscriber to click the confirmation link. The confirmation email (Kit calls it the “incentive email”) is supposed to fire at this point.
Checked in Kit dashboard: subscriber present, state inactive. Inbox: empty. Spam folder: empty. Verified items:
- Sender email verified ✓
- “Send incentive email” toggled on ✓
- Form ID in API call matched the form ID in the dashboard ✓
Step 4 — 12-Hour Per-Address Suppression
The same email address had been used for every test across the debugging session — roughly six to eight subscription attempts over two hours.
Kit sends the incentive email once per email address per 12 hours. Every subsequent subscription attempt within that window returns HTTP 200 and creates/updates the subscriber record, but the confirmation email is silently suppressed. There is no field in the API response that indicates this suppression is occurring.
Fix:
- Delete the test subscriber from Kit → Subscribers
- Use a fresh address (
yourname+kittest1@gmail.com) - Subscribe once
Confirmation email arrived within 30 seconds.
Step 5 — Custom Template Not Applied
After confirmation worked, the email body did not match the template designed in Kit’s email editor. The email used Kit’s generic default layout.
Kit’s incentive email is scoped per form, not globally. Any template work done in the broadcast editor, on a landing page, or in a different form’s settings has no effect on what this form sends.
Fix:
- Kit → Forms → [form
<form-id>] → Edit → Settings tab → Incentive → Edit email - This is the editor for the specific confirmation email this form fires
- Whatever is saved here is what gets sent — nothing else
After setting the template at the form level, the correct email arrived on the next test with a fresh address.
Final Working Configuration
Edge function at api/subscribe.ts:
export const config = { runtime: 'edge' };
export default async function handler(req: Request): Promise<Response> {
if (req.method !== 'POST') return json({ error: 'Method not allowed' }, 405);
const { email } = await req.json();
if (!email?.includes('@')) return json({ error: 'Valid email required' }, 400);
const res = await fetch(
`https://api.convertkit.com/v3/forms/<form-id>/subscribe`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: process.env.KIT_API_KEY, email }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return json({ error: (data as any)?.message ?? 'Subscription failed.' }, res.status);
}
return json({ ok: true, message: 'Almost there — check your inbox to confirm.' });
}
KIT_API_KEY is the v3 API key — the one shown without the kit_ prefix in Kit → Settings → Advanced → API. The api_secret (also on that page) is only needed for write operations like creating broadcasts.
Vercel env var added via dashboard: KIT_API_KEY → production + preview environments.
Production Rules
1. Kit suppresses incentive emails silently after the first send per 12 hours
200 OK with state: inactive does not mean the confirmation email was sent. If you test with the same address more than once, subsequent emails are dropped with no indication. Always delete the test subscriber and use a new address for each test.
2. Kit v3 and v4 are separate APIs with different auth schemes
v4 (api.kit.com/v4) uses Authorization: Bearer <kit_key>. v3 (api.convertkit.com/v3) uses api_key as a POST body param. The dashboard kit_ prefixed key may not be activated on v4 for accounts created during the transition period — fall back to v3 if you get a 401.
3. Kit incentive email templates are per-form, not global
Designing a template anywhere except Kit → Forms → [your form] → Settings → Incentive → Edit email has no effect on the confirmation email sent by API subscriptions. Verify the template at the form level, not in the broadcast editor.
Discussion