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/batch— 100 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 values — queued, sending, sent, delivered, bounced, complained, failed.
Event types — sent, 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.deliveredemail.bouncedemail.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." }