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:

BASE URL
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.

HEADER
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.

ERROR ENVELOPE
{
  "statusCode": 403,
  "name": "domain_not_verified",
  "message": "The domain acme.eu is not verified for this project"
}
StatusWhen
400invalid_json — the body is not valid JSON
401missing_api_key, invalid_api_key
403domain_not_verified — sending from an unverified domain; insufficient_scope — the action is dashboard-only (e.g. minting an API key)
404not_found — the resource does not exist in your project
409conflicts — e.g. domain_exists, idempotency_key_reused, not_cancelable
413attachment_too_large — attachments exceed 10 MB
422validation_error, all_recipients_suppressed
429rate_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: true header — 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.
HEADER
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

POST/emails

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.

FieldDescription
fromrequiredstring"Name <you@domain>" or a bare address
torequiredstring | string[] — up to 50 recipients across to/cc/bcc
subjectrequiredstring
htmlone ofstring — provide html and/or text
textone ofstring
cc / bccoptionalstring | string[] — bcc rides the envelope only
reply_tooptionalstring | string[]
headersoptionalobject — extra headers (owned headers rejected)
attachmentsoptionalarray{ filename, content (base64), content_type? }, ≤10 MB total
curl
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>"
  }'
202 Accepted
{
  "id": "em_01KX3EE5EQ65EDMSD4PYGXDKCM"
}
POST/emails/batch

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.

request body
[
  { "from": "billing@acme.eu",
    "to": "a@client.ee",
    "subject": "Receipt",
    "html": "…" },
  { "from": "billing@acme.eu",
    "to": "b@client.ee",
    "subject": "Receipt",
    "html": "…" }
]
202 Accepted
{
  "data": [
    { "id": "em_01KX…A1" },
    { "id": "em_01KX…B2" }
  ]
}
GET/emails

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.

GET /emails?status=bounced&limit=50
{
  "object": "list",
  "data": [ { "object": "email", "id": "em_…", "status": "bounced", … } ],
  "next_cursor": "em_…"
}
GET/emails/:id

Retrieve one email. Attachment content is never returned — only metadata (filename, content_type, size).

200 OK
{
  "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"
}
GET/emails/:id/events

The event timeline for one email — queued, sent, delivered, bounce, complaint, delivery_delay, failed — each with occurred_at and provider payload.

POST/emails/:id/cancel

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

POST/domains

Body: { "name": "acme.eu" } (a bare domain). Returns 201 with the four DNS records to publish. 409 domain_exists if already registered.

201 Created
{
  "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
}
GET/domains   GET/domains/:id

List all domains, or retrieve one (including its dns_records and verification timestamps).

POST/domains/:id/verify

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.

200 OK
{
  "object": "domain", "status": "pending", …
  "checks": { "dkim_dns": true, "backend_dkim": true, "backend_mail_from": false }
}
DELETE/domains/:id

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

POST/webhooks

Body: { "url": "https://…", "events": ["email.bounced", …] }. The signing secret (whsec_…) is returned exactly once, at creation — store it. See Verifying webhooks.

201 Created
{
  "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_…"
}
GET/webhooks   GET/webhooks/:id

List or retrieve webhooks. The secret is never included after creation.

PATCH/webhooks/:id

Update any of url, events, or status (enabled / disabled).

POST/webhooks/:id/test

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/webhooks/:id

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

GET/suppressions

List suppressed addresses (paginated with limit ≤200 + cursor).

POST/suppressions

Manually suppress an address. Body: { "email": "user@example.com" }. Idempotent.

201 Created
{ "object": "suppression", "email": "user@example.com", "reason": "manual" }
GET/suppressions/:email   DELETE/suppressions/:email

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.

GET/api-keys

List keys — key_prefix, last_used_at, revoked_at. Secret material is never returned.

DELETE/api-keys/:id

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:

HEADERS
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).

event payload
{
  "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.

send.ts
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.