comms
api reference v1 · stable

The whole API, on one page.

Four endpoints across two channels, one header to authenticate, six error codes. Every snippet on this page is a real curl request you can paste into a terminal.

No SDK required. / Plain HTTP, plain JSON.

01 · quick start

From zero to delivered in three minutes.

Grab an API key from the dashboard. Paste the request below into a terminal, swapping in your key and a recipient you control. Check your inbox.

POST /v1/messages/email
curl https://api.comms.ndcoders.com/v1/messages/email \
  -H "x-api-key: $COMMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sender":      { "fromAddress": "hello@yourapp.com" },
    "destination": { "to": ["you@yourcompany.com"] },
    "content": {
      "type":    "raw",
      "subject": "Hello from Comms",
      "body":    "If this lands in your inbox, you are integrated."
    }
  }'

response · 202 accepted

response body 202 accepted
{
  "messageId": "5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71",
  "status":    "accepted"
}

02 · authentication

One header. API key.

Every request needs an x-api-key: $COMMS_KEY header. Keys live in the dashboard, one per environment by convention. Keys are shown once at creation and cannot be recovered, only revoked.

GET /v1/messages/{messageId}
curl https://api.comms.ndcoders.com/v1/messages/5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71 \
  -H "x-api-key: $COMMS_KEY"
missing key 401 unauthorized
{
  "code":    "UNAUTHORIZED",
  "message": "Missing x-api-key header"
}
  • Keys are scoped to a single app. The retrieve and list endpoints return only the messages that key sent — scope is derived from the key, never from a query parameter.
  • Revoking a key is instant. In-flight requests using the revoked key complete; new requests fail with UNAUTHORIZED.

03 · send a message

One route per channel.

The channel is the route: POST /v1/messages/email. The envelope is the same for every channel — sender, destination, content — with channel-specific fields inside each. Email is live today; WhatsApp is coming soon.

email

POST /v1/messages/email
curl https://api.comms.ndcoders.com/v1/messages/email \
  -H "x-api-key: $COMMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sender":      { "fromAddress": "billing@your-app.com", "fromName": "Acme Billing" },
    "destination": { "to": ["alex@startup.io"] },
    "content": {
      "type":    "raw",
      "subject": "Receipt #2891",
      "body":    "Thanks for your payment."
    },
    "metadata": { "order": "2891", "userId": "u_3v7" }
  }'
request body application/json
sender
object required
Sender identity and provider credentials: fromAddress, plus optional fromName and replyTo.
destination
object required
Recipients under to (one or more), plus optional cc and bcc. Each recipient is one billable send.
content
object required
What to send: { type: "raw", subject, body } or { type: "template", templateId, templateParams }.
metadata
object
Opaque string key/value pairs. Stored on the message and returned by the retrieve and list endpoints.
scheduledAt
string (ISO 8601)
Hold the message until this timestamp, then dispatch. The engine owns the wait; you do not run a worker.

response · 202 accepted

response body 202 accepted
{
  "messageId": "5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71",
  "status":    "accepted"
}

The status: "accepted" in the 202 body is the enqueue acknowledgement. Delivery state lives on the message itself: it starts at PROCESSING and moves through ACCEPTED_BY_PROVIDER → DELIVERED, with RETRYING for transient errors and terminal FAILED / UNDELIVERABLE. Read it back with GET /v1/messages/{id}.

04 · retrieve a message

Every attempt is in the record.

GET /v1/messages/{id} returns the full message including the ordered attempts[] array. Position in the array is chronology. A message owned by another app is indistinguishable from one that never existed — both return 404. Records are retained for 90 days.

GET /v1/messages/{messageId}
curl https://api.comms.ndcoders.com/v1/messages/5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71 \
  -H "x-api-key: $COMMS_KEY"
response body 200 ok
{
  "messageId":   "5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71",
  "channel":     "EMAIL",
  "status":      "DELIVERED",
  "destination": { "to": ["alex@startup.io"] },
  "content":     { "type": "raw", "subject": "Receipt #2891", "body": "Thanks for your payment." },
  "metadata":    { "order": "2891" },
  "createdAt":   "2026-05-26T15:10:31.991Z",
  "updatedAt":   "2026-05-26T15:10:41.029Z",
  "attempts": [
    {
      "provider":      "ses-primary",
      "status":        "RETRYING",
      "errorCategory": "PROVIDER_OUTAGE",
      "durationMs":    8120,
      "timestamp":     "2026-05-26T15:10:32.404Z"
    },
    {
      "provider":   "ses-fallback",
      "status":     "DELIVERED",
      "durationMs": 412,
      "timestamp":  "2026-05-26T15:10:40.617Z"
    }
  ]
}

05 · list messages

Cursor pagination. No magic.

GET /v1/messages returns a page of messages sorted newest first, scoped to the API key. Filters combine with AND. Pass cursor from the previous response to walk forward; the field is absent on the last page.

GET /v1/messages
curl --get https://api.comms.ndcoders.com/v1/messages \
  -H "x-api-key: $COMMS_KEY" \
  --data-urlencode "channel=EMAIL" \
  --data-urlencode "status=FAILED" \
  --data-urlencode "after=2026-05-25T00:00:00Z" \
  --data-urlencode "limit=50"
query parameters
channel "EMAIL"
Filter by channel. Omit to return every channel.
status "PROCESSING" | "ACCEPTED_BY_PROVIDER" | "DELIVERED" | "RETRYING" | "FAILED" | "UNDELIVERABLE"
Filter by current delivery status.
after string (ISO 8601)
Return only messages created at or after this timestamp.
before string (ISO 8601)
Return only messages created at or before this timestamp.
limit integer · 1..100
Page size. Defaults to 20. Filtered pages may return fewer items than requested.
cursor string
Opaque pagination cursor. Pass the value of cursor from the previous response; walk until it is absent.

response · 200 ok

response body 200 ok
{
  "items": [
    {
      "messageId":   "5f1d8c2e-9b3a-4c7e-8f1a-2d6b9e0c4a71",
      "channel":     "EMAIL",
      "status":      "FAILED",
      "destination": { "to": ["alex@startup.io"] },
      "content":     { "type": "raw", "subject": "Receipt #2891", "body": "…" },
      "createdAt":   "2026-05-26T15:10:31.991Z",
      "updatedAt":   "2026-05-26T15:11:02.118Z",
      "attempts":    []
    }
  ],
  "cursor": "eyJwayI6Im9yZyMxMjMi..."
}

06 · errors

Six codes. One shape.

Every error response carries a stable code and a human message. Validation failures on the send endpoints add a details field describing which field was rejected.

error body 400 bad request
{
  "code":    "VALIDATION_ERROR",
  "message": "Validation failed"
}
status codes
  1. 400 VALIDATION_ERROR

    A field is missing or fails its schema. Send endpoints add a details payload.

  2. 400 PARSE_ERROR

    The request body is not valid JSON.

  3. 401 UNAUTHORIZED

    Missing or invalid x-api-key header.

  4. 404 NOT_FOUND

    No message with that id exists on this account.

  5. 429 RATE_LIMITED

    Too many requests. The Retry-After header is in seconds.

  6. 500 INTERNAL_ERROR

    Our problem. Already paging on-call.

07 · rate limits

100 requests a minute.

Limits are per API key over a one-minute sliding window: 100 requests a minute, reads and sends alike. The 429 response carries a Retry-After in seconds and echoes it as retryAfter in the body. Respect it.

rate-limited response 429 too many requests
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12

{
  "code":       "RATE_LIMITED",
  "message":    "Too many requests",
  "retryAfter": 12
}
  • The same ceiling covers reads and sends — a message you send and then poll both draw from the one bucket.
  • Outgrowing it? Talk to us — we lift it per account and quote a rate.

ready when you are

Send your first message in fifteen minutes.

No card. No waitlist. The API key is one click away.