Send an email
POST /api/v1/send — the one way to send outbound mail through the API.
POST /api/v1/send
Sends one email as the project's identity. This is the only send endpoint — there is no scheduled-send, no idempotency key, no sandbox/test mode, and no bulk or batch variant. One call, one message.
Auth
Authorization: Bearer <key> with inbox ≥ write — see
Authentication. A key scoped to exactly one
project defaults to sending as that project; every other key (all-projects,
or scoped to several projects) must send project_id in the body, and must
be authorized for that project.
Request body
| Field | Required |
|---|---|
to | yes |
subject | yes |
text | yes |
html | no |
project_id | no |
Notes on the two optional-looking fields:
html— plain string, sent as-is; omit it and onlytextrenders.project_id— required unless the key is scoped to exactly one project, in which case it may be omitted and that project is assumed. This is not "ignored if present, own project always wins": if you do send aproject_id, it is checked like any other key's — a single-project key that names a project outside its own scope still gets403 API key is not authorised for this project, it just isn't required to name its own.
There is no `from` field
The project's own identity decides the From address. This is intentional
— a from field would let one project send as another's address —
and it means a request that includes one is simply ignored, not rejected.
Response
201 Created on success:
{
"id": "email-uuid",
"from": "you@yourproject.example",
"to": "teammate@example.com",
"providerId": "resend-message-id",
"persisted": true,
"remaining": 9
}id can be null even on a 201. The message still sent — persisted: false
means the audit-log row failed to write after a successful provider call.
That failure is logged server-side and never turned into an error response,
so a client retry logic keyed only on HTTP status will not double-send.
remaining is what's left in the org's current quota window after this
send.
Errors
| Status | Body error | Cause |
|---|---|---|
| 401 | missing or malformed API key | No Authorization header, or it isn't Bearer <key>. |
| 401 | invalid API key | The key doesn't match any stored hash. |
| 401 | API key has been revoked | revoked_at is set. |
| 403 | API key has no owner; create a new one | The key's creator account no longer exists. |
| 403 | this key lacks inbox:write | The key doesn't hold inbox ≥ write. |
| 403 | API key is not authorised for this project | The key's scope doesn't cover project_id. |
| 400 | to must be a valid email address | to missing or not address-shaped. |
| 400 | subject is required | Empty or missing. |
| 400 | text is required | Empty or missing. |
| 400 | project_id is required for this key | No project_id sent, and the key isn't scoped to exactly one project. |
| 404 | the project this key belongs to no longer exists | Project was deleted after the key was minted. |
| 409 | sending is not configured for this project | The project has no send identity yet. |
| 429 | send quota exceeded (...) | Org's daily or monthly cap reached — see Limits below. remaining: 0 in the body. |
| 500 | send failed | Key lookup or send-identity resolution failed unexpectedly. |
| 500 | quota check failed | The quota RPC itself errored. |
| 502 | send failed | The mail provider rejected or could not be reached. |
Error strings are exact, read from apps/web/app/api/v1/send/route.ts at the
time this page was written — if you're matching on message text rather than
status code, treat it as a snapshot.
curl
curl -X POST https://kontacts.dev/api/v1/send \
-H "Authorization: Bearer gm_live_…" \
-H "Content-Type: application/json" \
-d '{"to":"teammate@example.com","subject":"Hello","text":"Sent from the API"}'
Limits
Sending draws from the organisation's plan quota — the same pool the
dashboard compose window spends, and the same pool the MCP send_email and
reply_email tools spend. Free is capped at 10 sends per organisation per
UTC day, shared across every project in the org, not 10 per project. Indie
Hacker raises that to 200/day and 1,000/month (apps/web/lib/plans.ts).
The credit is spent before the call to the mail provider, and refunded
only when the message demonstrably never left (a provider-side error, not a
successful send that failed to record) — see isRefundableSendError in
apps/web/lib/outbound.ts if you need the exact boundary.
Keys are minted under Settings → API keys
(/dashboard/settings/api-keys).