API Reference

REST API

Every endpoint, request body, and response shape.

#REST API Reference

The REST API is served by Convex HTTP actions (Hono). Your base URL is the HTTP Actions URL from your Convex deployment dashboard, ending in .convex.site.

https://your-deployment.convex.site

All endpoints are JSON over HTTPS. Requests and responses use Content-Type: application/json.

#Authentication

Every /v1/* endpoint requires a Bearer token:

Authorization: Bearer efsa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

API keys are created in the dashboard at Settings → API Keys. Each key has one of two permission levels:

Permission Scope
sending POST /v1/emails, POST /v1/emails/batch only
full All endpoints, including domain management

Keys are stored as SHA-256 hashes. The plaintext value is only shown once at creation time.

#Idempotency

POST /v1/emails accepts an optional Idempotency-Key header (8-128 characters). If a request with the same key has been seen recently, the original response is replayed and a Idempotent-Replayed: true header is included. Use this to safely retry from queue workers or webhooks.

Idempotency-Key: signup-welcome-user-1834

#Rate Limits

  • POST /v1/emails, POST /v1/emails/batch100 requests/minute per API key
  • All other endpoints — 60 requests/minute per API key

Exceeding a limit returns 429 Too Many Requests.


#Emails

#Send one email

POST /v1/emails

Body

{
  "from": "hello@yourdomain.com",
  "to": "user@example.com",
  "subject": "Welcome",
  "html": "<h1>Hello</h1>",
  "text": "Hello",
  "cc": "cc@example.com",
  "bcc": ["bcc1@example.com", "bcc2@example.com"],
  "reply_to": "support@yourdomain.com",
  "tags": [{ "name": "category", "value": "onboarding" }],
  "scheduled_for": "2026-05-01T09:00:00Z"
}
Field Notes
from Required. Must use a verified domain.
to Required. String or array (max 50 per email).
subject Required.
html or text At least one is required.
cc, bcc, reply_to Optional. String or array.
tags Optional. Array of { name, value } pairs for categorization.
scheduled_for Optional. ISO 8601 timestamp. Defers sending until the given time.

Response

{ "id": "m1abc123...", "status": "queued" }

Status progresses through queued → sending → sent → delivered as SES events arrive.

#Batch send

POST /v1/emails/batch

Body is a JSON array of up to 100 email objects, each matching the single-send schema.

Response

{
  "data": [
    { "id": "m1abc..." },
    { "id": "m1def..." },
    { "error": "Domain \"example.com\" is not verified." }
  ]
}

Partial failures are returned per-item; the HTTP status is always 200 unless the entire request is rejected.

#Get email

GET /v1/emails/:emailId

Response

{
  "_id": "m1abc...",
  "from": "hello@yourdomain.com",
  "to": ["user@example.com"],
  "subject": "Welcome",
  "status": "delivered",
  "sentAt": 1744543200000,
  "messageId": "0100018f6c...-ses",
  "events": [
    { "type": "sent", "timestamp": 1744543200000 },
    { "type": "delivered", "timestamp": 1744543201234 },
    { "type": "opened", "timestamp": 1744543400000 }
  ]
}

Status valuesqueued, sending, sent, delivered, bounced, complained, failed.

Event typessent, delivered, bounced, complained, opened, clicked. opened and clicked are recorded via SES tracking and stored on the email record; they do not change the top-level status.


#Contacts

#List contacts

GET /v1/contacts?limit=100

limit is capped at 1000, defaults to 100. Response includes has_more for pagination.

{
  "data": [
    { "_id": "k1...", "email": "ada@lovelace.io", "firstName": "Ada", "subscribed": true }
  ],
  "has_more": false,
  "count": 1
}

#Get contact

GET /v1/contacts/:contactId

#Upsert contact

POST /v1/contacts

Creates a contact if one does not exist with the given email, otherwise updates it.

Body

{
  "email": "user@example.com",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "subscribed": true
}

Response

{ "id": "k1abc..." }

#Bulk create contacts

POST /v1/contacts/bulk

Body is a JSON array of up to 1000 contact objects. Returns a summary of inserted, updated, and skipped rows.

[
  { "email": "ada@lovelace.io", "first_name": "Ada" },
  { "email": "grace@hopper.dev", "first_name": "Grace" }
]

#Delete contact

DELETE /v1/contacts/:contactId

Returns { "deleted": true } or 404 if not found.


#Audiences

#List audiences

GET /v1/audiences
{
  "data": [
    { "_id": "a1...", "name": "Newsletter subscribers", "contactCount": 2104 }
  ]
}

Audience membership is managed from the dashboard today; API mutation endpoints are not yet public.


#Templates

#List templates

GET /v1/templates
{
  "data": [
    { "_id": "t1...", "name": "Welcome email", "subject": "Hi {{first_name}}", "variables": ["first_name"] }
  ]
}

#Events

#Track event

POST /v1/events

Fires any active automations whose trigger matches event. The contact is auto-created if it does not exist.

Body

{
  "event": "user.signed_up",
  "email": "user@example.com",
  "data": { "plan": "pro", "source": "landing_page" }
}
Field Notes
event Required. Event name that matches an automation trigger.
email Required. Contact email.
data Optional. JSON object stored alongside the event.

Response

{ "success": true }

#Domains

#List domains

GET /v1/domains
{
  "data": [
    {
      "_id": "d1...",
      "domain": "yourdomain.com",
      "status": "verified",
      "region": "us-east-1",
      "dkimTokens": ["abc", "def", "ghi"]
    }
  ]
}

Status values: pending, verified, failed.

#Add domain

POST /v1/domains

Requires a full permission API key.

Body

{
  "domain": "yourdomain.com",
  "region": "us-east-1"
}

Response

{ "id": "d1abc..." }

After adding, configure the three DKIM CNAME records returned in the dashboard. SES verifies the domain automatically once DNS propagates.


#Webhooks

Configure outbound webhooks at Settings → Webhooks. On each relevant event, the platform sends a signed POST request to your URL.

#Events

Only status-changing SES events are delivered as webhooks:

  • email.delivered
  • email.bounced
  • email.complained

Open and click tracking are recorded on the email record (see GET /v1/emails/:id) and surfaced in Analytics, but do not fire webhooks today.

#Payload

{
  "type": "email.delivered",
  "data": {
    "email_id": "m1abc...",
    "from": "hello@yourdomain.com",
    "to": ["user@example.com"],
    "subject": "Welcome",
    "status": "delivered",
    "metadata": null
  }
}

For bounces and complaints, metadata includes SES-specific fields (bounceType, bounceSubType, complaintFeedbackType).

#Headers

Every webhook request carries:

Header Value
X-Webhook-Signature v1=<hex HMAC-SHA256>
X-Webhook-Timestamp Unix seconds at signing time
X-Webhook-Event e.g. email.delivered
X-Webhook-Delivery Unique per-attempt ID (idempotent)

#Verifying the signature

Compute HMAC-SHA256(webhookSecret, "${timestamp}.${body}") and compare to the hex value after v1=.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(
  rawBody: string,
  header: string | null,
  timestamp: string | null,
  secret: string,
): boolean {
  if (!header || !timestamp) return false;
  const signed = `${timestamp}.${rawBody}`;
  const expected = createHmac("sha256", secret).update(signed).digest("hex");
  const received = header.replace(/^v1=/, "");
  return timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(received, "hex"),
  );
}

Reject any request older than 5 minutes (using X-Webhook-Timestamp) to defend against replay.


#Errors

All errors return a JSON body with an error string. HTTP status codes:

Code Meaning
400 Malformed request, missing required field, validation error
401 Missing or invalid API key
403 Key lacks the required permission
404 Resource not found
429 Rate limit exceeded
500 Internal error — please retry with exponential backoff

Example:

{ "error": "Domain \"example.com\" is not verified." }