API Reference
Send by Kodar is a transactional email API. One authenticated POST reaches the inbox — DKIM-signed, tracked through delivery, and reported back over signed webhooks.
The base URL for all requests is:
https://send.kodar.io
Every request and response is JSON. IDs are prefixed and opaque (em_ email, dom_ domain, wh_ webhook, key_ API key). All timestamps are ISO 8601 UTC.
Authentication
Authenticate every request with your API key as a Bearer token. Keys look like km_live_… and are scoped to one project.
Authorization: Bearer km_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Requests with no key return 401 missing_api_key; an unknown or revoked key returns 401 invalid_api_key. Create keys in the dashboard; list and revoke them via the API keys endpoints. Treat keys as secrets — anyone with one can send on your behalf.
Errors
Errors use standard HTTP status codes and a consistent JSON envelope. The name field is a stable, machine-readable code; message is human-readable and may change.
{
"statusCode": 403,
"name": "domain_not_verified",
"message": "The domain acme.eu is not verified for this project"
}| Status | When |
|---|---|
| 400 | invalid_json — the body is not valid JSON |
| 401 | missing_api_key, invalid_api_key |
| 403 | domain_not_verified — sending from an unverified domain; insufficient_scope — the action is dashboard-only (e.g. minting an API key) |
| 404 | not_found — the resource does not exist in your project |
| 409 | conflicts — e.g. domain_exists, idempotency_key_reused, not_cancelable |
| 413 | attachment_too_large — attachments exceed 10 MB |
| 422 | validation_error, all_recipients_suppressed |
| 429 | rate_limit_exceeded — see rate limits |
Rate limits
Requests are limited per API key by a token bucket: 10 requests per second with a burst of 50. Over the limit returns 429 rate_limit_exceeded with a Retry-After header (seconds). The SDK retries 429s automatically, honoring that header.
Idempotency
Make POST /emails and POST /emails/batch safe to retry by sending an Idempotency-Key header (any unique string, ≤256 chars). Within a 24-hour window:
- An identical retry replays the stored response and adds an
idempotency-replayed: trueheader — no second email is sent. - The same key with a different body returns
409 idempotency_key_reused. - A concurrent duplicate still in flight returns
409 concurrent_idempotent_request.
Idempotency-Key: invoice-4471
Pagination
List endpoints return { "object": "list", "data": [...], "next_cursor": "…" }. Pass cursor (the previous page's next_cursor) and limit to page forward. When next_cursor is null, you've reached the end.
Emails
The core resource. An email is accepted, durably queued, then delivered by the worker through Amazon SES. Statuses:
queuedsendingsent deliveredbouncedcomplained failedcanceled
Send an email. Returns 202 the moment it's durably queued. The from domain must be verified in your project. Suppressed recipients are dropped and reported in suppressed_recipients; if every to recipient is suppressed the request returns 422 all_recipients_suppressed.
| Field | Description | |
|---|---|---|
| from | required | string — "Name <you@domain>" or a bare address |
| to | required | string | string[] — up to 50 recipients across to/cc/bcc |
| subject | required | string |
| html | one of | string — provide html and/or text |
| text | one of | string |
| cc / bcc | optional | string | string[] — bcc rides the envelope only |
| reply_to | optional | string | string[] |
| headers | optional | object — extra headers (owned headers rejected) |
| attachments | optional | array — { filename, content (base64), content_type? }, ≤10 MB total |
curl https://send.kodar.io/emails \ -H "Authorization: Bearer km_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invoice-4471" \ -d '{ "from": "billing@acme.eu", "to": "mari@client.ee", "subject": "Invoice #4471", "html": "<p>Thanks!</p>" }'
{
"id": "em_01KX3EE5EQ65EDMSD4PYGXDKCM"
}Send up to 100 emails in one request as a top-level JSON array. All-or-nothing: if any item is invalid, none are queued (the error names the index, e.g. emails[3]: …). Attachments are not supported on batch. Honors Idempotency-Key for the whole batch. Responses are returned in input order.
[
{ "from": "billing@acme.eu",
"to": "a@client.ee",
"subject": "Receipt",
"html": "…" },
{ "from": "billing@acme.eu",
"to": "b@client.ee",
"subject": "Receipt",
"html": "…" }
]{
"data": [
{ "id": "em_01KX…A1" },
{ "id": "em_01KX…B2" }
]
}List emails, newest first. Filter with status, to (recipient address), created_after / created_before (ISO 8601), and page with limit (default 25, max 100) + cursor.
{
"object": "list",
"data": [ { "object": "email", "id": "em_…", "status": "bounced", … } ],
"next_cursor": "em_…"
}Retrieve one email. Attachment content is never returned — only metadata (filename, content_type, size).
{
"object": "email",
"id": "em_01KX3EE5EQ65EDMSD4PYGXDKCM",
"from": "billing@acme.eu",
"to": ["mari@client.ee"],
"subject": "Invoice #4471",
"status": "sent",
"provider_message_id": "0110019f46e7…",
"attempt": 1, "max_attempts": 5,
"created_at": "2026-07-09T12:42:57.367Z",
"updated_at": "2026-07-09T12:42:57.679Z"
}The event timeline for one email — queued, sent, delivered, bounce, complaint, delivery_delay, failed — each with occurred_at and provider payload.
Cancel an email while it is still queued. Returns the updated email, or 409 not_cancelable if it already started sending, or 404 not_found.
Domains
Register a sending domain and the platform generates and owns its DKIM keys, provisions it with SES (BYODKIM + a custom MAIL FROM), and returns the DNS records to publish. A worker polls until it's verified. Statuses: pendingverifiedfailed
Body: { "name": "acme.eu" } (a bare domain). Returns 201 with the four DNS records to publish. 409 domain_exists if already registered.
{
"object": "domain",
"id": "dom_…",
"name": "acme.eu",
"status": "pending",
"dkim_selector": "km1",
"mail_from_subdomain": "send",
"dns_records": [
{ "purpose": "dkim", "type": "TXT", "host": "km1._domainkey.acme.eu", "value": "v=DKIM1; k=rsa; p=…" },
{ "purpose": "mail_from_mx", "type": "MX", "host": "send.acme.eu", "value": "10 feedback-smtp.eu-north-1.amazonses.com" },
{ "purpose": "mail_from_spf", "type": "TXT", "host": "send.acme.eu", "value": "v=spf1 include:amazonses.com ~all" },
{ "purpose": "dmarc_suggested", "type": "TXT", "host": "_dmarc.acme.eu", "value": "v=DMARC1; p=none; rua=…" }
],
"verified_at": null
}List all domains, or retrieve one (including its dns_records and verification timestamps).
Re-check verification immediately (the worker also polls automatically). Re-opens a failed domain for another 72-hour window. Returns the domain plus a checks breakdown.
{
"object": "domain", "status": "pending", …
"checks": { "dkim_dns": true, "backend_dkim": true, "backend_mail_from": false }
}Deprovision and delete a domain. Returns 409 domain_in_use once the domain has sent any email.
Webhooks
Subscribe an HTTPS endpoint to email events. Deliveries are signed (Svix-compatible) and retried on failure. Subscribable event types:
email.sentemail.deliveredemail.bounced email.complainedemail.delivery_delayedemail.failed
Body: { "url": "https://…", "events": ["email.bounced", …] }. The signing secret (whsec_…) is returned exactly once, at creation — store it. See Verifying webhooks.
{
"object": "webhook",
"id": "wh_…",
"url": "https://acme.eu/hooks/kodar",
"events": ["email.bounced", "email.complained"],
"status": "enabled",
"created_at": "2026-07-09T14:07:40.9Z",
"secret": "whsec_…"
}List or retrieve webhooks. The secret is never included after creation.
Update any of url, events, or status (enabled / disabled).
Enqueue a synthetic event through the real dispatcher, so you see exactly what production traffic looks like (headers, signature, retries). Returns 409 webhook_disabled if the webhook isn't enabled.
Delete a webhook and its pending deliveries.
Suppressions
Addresses that must never be emailed. Hard bounces and complaints are added automatically; you can also add addresses manually. Every send checks this list and drops suppressed recipients. Reasons: hard_bouncecomplaintmanual
List suppressed addresses (paginated with limit ≤200 + cursor).
Manually suppress an address. Body: { "email": "user@example.com" }. Idempotent.
{ "object": "suppression", "email": "user@example.com", "reason": "manual" }Check whether an address is suppressed (200 or 404 not_found), or remove it from the list. Matching is case-insensitive. DELETE lifts suppressions you added yourself (reason: "manual") and hard bounces (a mailbox that has since recovered will simply re-bounce and be re-suppressed); addresses suppressed because the recipient unsubscribed or reported spam return 403 suppression_not_removable — that is the recipient's decision, not the sender's.
API keys
List and revoke the keys that authenticate requests. Revocation takes effect immediately.
Keys are minted in the dashboard only — Dashboard → API keys (/dashboard/api-keys), which requires a signed-in session and an owner or admin role. The full secret is shown once. POST /api-keys returns 403 insufficient_scope: a key that can mint keys would defeat revocation, since anyone holding a leaked key could issue a fresh one before you revoked the old one.
List keys — key_prefix, last_used_at, revoked_at. Secret material is never returned.
Revoke a key. The key stops authenticating on its very next request. Idempotent.
Verifying webhooks
Each delivery is a POST with a JSON body { type, created_at, data } and three Svix-compatible headers. Verify the signature before trusting the payload — any standard Svix library works, or verify manually:
webhook-id: whd_… webhook-timestamp: 1751800000 webhook-signature: v1,<base64 HMAC-SHA256>
The signature is HMAC-SHA256 over "{id}.{timestamp}.{body}", keyed by the base64-decoded portion of your whsec_ secret, base64-encoded and prefixed v1,. Reject deliveries whose timestamp is more than 5 minutes old (replay protection).
{
"type": "email.bounced",
"created_at": "2026-07-09T14:07:42.1Z",
"data": {
"email_id": "em_…",
"from": "billing@acme.eu",
"to": ["old@gone.lt"],
"subject": "Invoice #4471",
"bounce_type": "Permanent"
}
}Return any 2xx to acknowledge. Non-2xx or a 10-second timeout triggers retries at 30 s, 5 m, 30 m, 2 h, and 10 h before the delivery is marked failed.
TypeScript SDK
@kodar/send is a zero-dependency TypeScript client (Node 18+). It maps camelCase inputs to the wire format, retries 429s automatically honoring Retry-After, and throws SendError (carrying the API's name, statusCode, and message) on any non-2xx.
import { Send } from "@kodar/send"; const send = new Send("km_live_…", { baseUrl: "https://send.kodar.io" }); // send one const { id } = await send.emails.create( { from: "billing@acme.eu", to: "mari@client.ee", subject: "Invoice #4471", html: invoice }, { idempotencyKey: "invoice-4471" }, ); // batch, then inspect await send.emails.batch(receipts.map((r) => ({ from, to: r.email, subject: r.subject, html: r.html }))); const email = await send.emails.get(id); // register a domain → returns the DNS records to publish const domain = await send.domains.create({ name: "acme.eu" });
Every resource is available: send.emails, send.domains, send.webhooks, send.suppressions, and send.apiKeys — the last is list and revoke only, mirroring the API: there is no apiKeys.create, since keys are minted in the dashboard.
SDK 0.2.0 is a breaking release: apiKeys.create and the ApiKeyWithSecret type were removed. Pin ^0.2.0 and create keys in the dashboard.