charlie API v1 Home Blog Pricing Get a key

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>"}'
Until your sending domain is verified, delivery is limited. Add the three DNS records shown in your account — see Sending domain.

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:

TypeHostValue
CNAMEs1._domainkey.send.yourdomain.coms1._domainkey.charliesend.eu
CNAMEbounce.send.yourdomain.combounce.charliesend.eu
TXT_dmarc.send.yourdomain.comv=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.

FieldTypeNotes
to_emailrequiredstringRecipient.
subjectrequiredstring
htmlrequiredstringFull HTML body.
textstringPlain-text alternative. Recommended.
from_emailstringLocal part only — the domain is always your verified sending domain. Unknown values fall back to the workspace default.
from_namestringDisplay name.
reply_tostringAny address, including on your root domain.
unsubscribe_urlstringSets 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.

FieldTypeNotes
to_emailrequiredstringMust already exist as a subscribed contact.
subject, htmlrequiredstring
text, from_email, from_name, reply_tostringAs above.
unsubscribe_urlstringYour own opt-out page. Omit and Charlie uses its own per-recipient token.
idempotency_keystringStrongly recommended for cron. A repeat returns the original and sends nothing.
batch_keystringGroups 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.

Recipients are not passed. Charlie fans out to your synced contacts matching segment — one call is one batch, not a loop.
FieldTypeNotes
subject, htmlrequiredstring
segmentstringall_subscribed (default), engaged_30d, recent_buyers_90d, vip, dormant_180d.
scheduled_send_atISO-8601Omit to send now. The dispatcher runs every minute.
idempotency_keystringA repeat returns the original batch instead of mailing everyone twice.
reply_to, from_email, from_name, textstringAs above.
Time zones. 08:00 Helsinki is 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.

StatusCodeMeaning
401unauthorizedMissing or revoked key.
402bulk_requires_paid_planFree sends transactional only. The message carries the upgrade link.
402shopify_billedWorkspace has a connected Shopify store; it is billed through Shopify.
404unknown_contactMarketing to an address that isn't a synced contact.
422sending_domain_not_verifiedAdd the DNS records first.
422suppressedUnsubscribed, bounced or complained.
422not_subscribed · empty_segment · workspace_inactive
429daily_send_limit · monthly_send_limitPlan cap reached; includes cap and used.
429warmup_capToday's ramp is smaller than the send. Not a bug — reputation protection.
503providers_unhealthyRetry with backoff.

Plans & limits

PlanVolumeBulk/marketingPrice
Free50/day (~1,500/mo)€0
Dev20,000/monthyes€19
Startup50,000/monthyes€49
Abovequotedyestalk 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.

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

Browse the gallery →

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.