Developer documentation
Charlie API
EU-hosted email sending. REST for your app, MCP for your agent. Every endpoint below is live — paste a key and it works.
# base URL https://charlieai.co/ext/v1 # send an email — the whole integration, really curl -X POST https://charlieai.co/ext/v1/transactional \ -H "Authorization: Bearer $CHARLIE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to_email":"you@example.com","subject":"It works", "html":"<p>Hello from charlie.</p>"}'
Free tier: 50 emails/day, no credit card · get a key · full quickstart below
Quickstart
Three steps from nothing to a delivered email.
# 1. get a key — https://charlieai.eu/dev/signup (free, no card) # 2. check what your account can do curl https://charlieai.co/ext/v1/status \ -H "Authorization: Bearer $CHARLIE_API_KEY" # 3. send curl -X POST https://charlieai.co/ext/v1/transactional \ -H "Authorization: Bearer $CHARLIE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to_email":"you@example.com", "subject":"It works", "html":"<p>Hello from charlie.</p>"}'
Authentication
Send your key as a bearer token, or in X-Charlie-API-Key. Both are equivalent.
Authorization: Bearer chk_live_xxxxxxxxxxxxxxxx
# or
X-Charlie-API-Key: chk_live_xxxxxxxxxxxxxxxx
The key identifies the workspace. No endpoint accepts a workspace id in the body, so a leaked key can never reach another tenant's data. Keys are stored hashed — we cannot show you an existing key again, only rotate it. Rotating revokes the previous key immediately.
Sending domain
Nothing sends from an unauthenticated domain. Pick a subdomain you control — the convention is
send.yourdomain.com — and add three records:
| Type | Host | Value |
|---|---|---|
| CNAME | s1._domainkey.send.yourdomain.com | s1._domainkey.charliesend.eu |
| CNAME | bounce.send.yourdomain.com | bounce.charliesend.eu |
| TXT | _dmarc.send.yourdomain.com | v=DMARC1; p=none; rua=mailto:dmarc@charliesend.eu |
The two CNAMEs are required (DKIM signing and the bounce return-path); DMARC is checked but does
not block. No SPF record is needed — the bounce CNAME delegates the return-path to a domain whose
SPF we maintain. Verification is automatic once DNS propagates; GET /status
tells you when can_send turns true.
POST /transactional
One individual email: receipts, password resets, confirmations. Sends synchronously.
| Field | Type | Notes |
|---|---|---|
to_emailrequired | string | Recipient. |
subjectrequired | string | |
htmlrequired | string | Full HTML body. |
text | string | Plain-text alternative. Recommended. |
from_email | string | Local part only — the domain is always your verified sending domain. Unknown values fall back to the workspace default. |
from_name | string | Display name. |
reply_to | string | Any address, including on your root domain. |
unsubscribe_url | string | Sets the RFC 8058 one-click pair. Only for per-recipient mail that is genuinely marketing — prefer /messages, which also records the send. |
{"sent": true, "to": "user@example.com",
"from": "hello@send.yourdomain.com",
"provider_message_id": "2026...@mta1"}
POST /messages
One personalised marketing email to one subscriber — when every copy differs and a single-HTML campaign cannot express it. Requires a paid plan.
Unlike /transactional this records the send, which is what makes bounce and
complaint suppression work at all; it injects the RFC 8058 pair plus a visible unsubscribe link,
counts against warmup, and refuses unknown or unsubscribed addresses.
| Field | Type | Notes |
|---|---|---|
to_emailrequired | string | Must already exist as a subscribed contact. |
subject, htmlrequired | string | |
text, from_email, from_name, reply_to | string | As above. |
unsubscribe_url | string | Your own opt-out page. Omit and Charlie uses its own per-recipient token. |
idempotency_key | string | Strongly recommended for cron. A repeat returns the original and sends nothing. |
batch_key | string | Groups a run under one batch for reporting, e.g. week-2026-32. |
POST /campaigns
One HTML body to a whole segment, optionally scheduled. Requires a paid plan.
segment — one call is one batch, not a loop.| Field | Type | Notes |
|---|---|---|
subject, htmlrequired | string | |
segment | string | all_subscribed (default), engaged_30d, recent_buyers_90d, vip, dormant_180d. |
scheduled_send_at | ISO-8601 | Omit to send now. The dispatcher runs every minute. |
idempotency_key | string | A repeat returns the original batch instead of mailing everyone twice. |
reply_to, from_email, from_name, text | string | As above. |
05:00Z in summer and
06:00Z in winter. Compute the UTC instant from local time at send time; don't hardcode.{"send_batch_id": 42, "status": "pending",
"recipient_count": 1280,
"scheduled_send_at": "2026-08-09T05:00:00+00:00"}
POST /contacts
Upsert a subscriber. You own consent; Charlie records its provenance.
{"email":"user@example.com","first_name":"Alex","country":"FI",
"status":"subscribed",
"consent_method":"app_signup_checkbox",
"consent_timestamp":"2026-08-01T09:12:00Z"}
status is subscribed (default), pending or
unsubscribed. Returns 201 created or 200 updated. An address
that unsubscribed through Charlie returns 409 and can only return via a fresh opt-in
recorded as pending — that decision belongs to the subscriber, not the app.
POST /suppressions/check
Suppressions are stored hashed — we keep no readable list of people who left — so you ask about addresses you already hold. Max 1000 per call.
{"emails":["a@example.com","b@example.com"]}
→
{"checked":2,"suppressed":[{"email":"a@example.com","reason":"hard_bounce"}]}
Mirror these into your own database so your app stops mailing them too.
GET /status
{"workspace":"example.dev","status":"active","plan_tier":"api_dev",
"sending_domain":"send.example.dev","sending_domain_verified":true,
"subscribed_contacts":412,"can_send":true,
"warmup_capacity_today":2000}
Errors
Every error is JSON with an error code and, where useful, a human-readable
message — written to be understood by an agent as well as a person.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing or revoked key. |
| 402 | bulk_requires_paid_plan | Free sends transactional only. The message carries the upgrade link. |
| 402 | shopify_billed | Workspace has a connected Shopify store; it is billed through Shopify. |
| 404 | unknown_contact | Marketing to an address that isn't a synced contact. |
| 422 | sending_domain_not_verified | Add the DNS records first. |
| 422 | suppressed | Unsubscribed, bounced or complained. |
| 422 | not_subscribed · empty_segment · workspace_inactive | |
| 429 | daily_send_limit · monthly_send_limit | Plan cap reached; includes cap and used. |
| 429 | warmup_cap | Today's ramp is smaller than the send. Not a bug — reputation protection. |
| 503 | providers_unhealthy | Retry with backoff. |
Plans & limits
| Plan | Volume | Bulk/marketing | Price |
|---|---|---|---|
| Free | 50/day (~1,500/mo) | — | €0 |
| Dev | 20,000/month | yes | €19 |
| Startup | 50,000/month | yes | €49 |
| Above | quoted | yes | talk to us |
Priced per send, not per stored contact. Paid plans include a 14-day trial; cancel and your key keeps working on the free tier, so nothing in your app breaks the day a subscription ends.
Guardrails
These are enforced, not advisory — an agent sending unattended has no judgement about deliverability, so the platform holds it instead.
- No unauthenticated sending. No override flag exists.
- Suppression on every path, transactional included. Unsubscribes, hard bounces and complaints are permanent.
- Unsubscribe headers injected per recipient on marketing sends (RFC 8058 one-click plus a visible link).
- Warmup and pool tiering. New senders ramp automatically and start on probation pools, so one careless neighbour cannot spend your reputation.
- No tracking pixels, no link rewriting. There is no open or click data — by design. Campaign results come from your own data, not from surveillance.
Templates — free, no account
448 email templates as public JSON. No key, no signup, no rate limit. Hand the endpoint to your own model and let it browse and adapt them.
curl https://charlieai.co/api/templates/
curl https://charlieai.co/api/templates/<id>
curl https://charlieai.co/api/popups/ # 20 pop-up templates
MCP
The same platform, exposed as tools your model can call directly. Add this endpoint as a custom connector in Claude, ChatGPT or Gemini:
https://charlieai.co/mcp
It authenticates with OAuth or the same API key. Your agent can then authenticate a domain, create contacts, browse templates, send test mail, run a campaign and read deliverability — the whole surface above, without you writing a client.
Support
Write to hello@charlieai.co — a person reads it — or book 15 minutes.
