Documentation

Send your first email, then read the reference.

The Irisend API is a REST API over HTTPS with JSON request and response bodies. All requests are made against https://irisend-api.lucamanuel.dev.

Quickstart

Create an account, grab an API key from Settings → API Keys, and send your first email.

1. Send a request

RequestcURL
curl -X POST https://irisend-api.lucamanuel.dev/emails \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@acme.dev>",
    "to": "you@example.com",
    "subject": "Hello from Irisend",
    "html": "<strong>It just works.</strong>"
  }'

2. Get an email ID back

ResponseJSON
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }

3. Check its status any time

RequestcURL
curl https://irisend-api.lucamanuel.dev/emails/em_9f2K3mQ1zPr7vT6yB4wL0nC \
  -H "Authorization: Bearer ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"

Authentication

Authenticate every request with a Bearer token in the Authorization header. Keys look like ir_<8-character prefix>_<24-character secret> — the prefix is used to look the key up, the secret is only ever stored hashed.

  • full_access keys can call every endpoint; sending_access keys can only call POST /emails and POST /emails/batch.
  • A key scoped to a domain can only send with a matching `from` address.
  • The token is shown once, at creation. Store it in a secret manager, not a repo.

Errors

Errors return a JSON body shaped { statusCode, name, message }, so you can branch on `name` instead of parsing text.

namestatusmeaning
missing_required_field422A required field is missing from the request body.
invalid_parameter422A field failed validation, e.g. an ID that is not a valid UUID.
invalid_from_address422The `from` address is not on a verified domain.
invalid_region422The domain `region` is not one of the supported SES regions.
validation_error400The request body does not match the expected schema.
invalid_idempotency_key400The `Idempotency-Key` header is not 1–256 characters.
missing_api_key401No `Authorization` header was sent.
restricted_api_key401A sending-only key called an endpoint it is not scoped for.
invalid_api_key403The API key is malformed or does not exist.
invalid_access403The key is not permitted to act on this resource.
not_found404The endpoint or resource ID does not exist.
method_not_allowed405The HTTP method is not supported on this endpoint.
concurrent_idempotent_requests409Another request with the same idempotency key is still in flight.
invalid_idempotent_request409The idempotency key was reused with a different request body.
rate_limit_exceeded429Your team exceeded its requests-per-second limit.
internal_server_error500Something went wrong on our end. Safe to retry.

Rate limits

Requests are limited per team, in a fixed one-second window shared across every API key on the team. Every response carries the current limit state.

HeaderMeaning
ratelimit-limitRequests allowed per second for this team
ratelimit-remainingRequests left in the current one-second window
ratelimit-resetSeconds until the window resets
retry-afterSent only on a 429; seconds to wait before retrying

The default limit is 10 requests per second per team. A POST /emails/batch call counts as a single request regardless of how many emails it contains.

Emails

POST/emailsSend an email

Provide at least one of html or text. to, cc, bcc, and reply_to accept a string or an array of up to 50 addresses. scheduled_at accepts an ISO 8601 timestamp.

Request bodyJSON
{
  "from": "Acme <hello@acme.dev>",
  "to": ["you@example.com", "cc-fallback@example.com"],
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "reply_to": "support@acme.dev",
  "headers": { "X-Entity-Ref-ID": "order_8841" },
  "tags": [{ "name": "category", "value": "receipt" }],
  "scheduled_at": "2026-10-01T09:00:00Z"
}
ResponseJSON
{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }
POST/emails/batchSend up to 100 emails

Body is a JSON array of up to 100 email objects, each shaped like POST /emails. IDs are returned in the same order as the input.

Request bodyJSON
[
  { "from": "Acme <hello@acme.dev>", "to": "a@example.com", "subject": "Hi A", "html": "<p>Hi A</p>" },
  { "from": "Acme <hello@acme.dev>", "to": "b@example.com", "subject": "Hi B", "html": "<p>Hi B</p>" }
]
ResponseJSON
{
  "data": [
    { "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" },
    { "id": "em_2wR8vLp1kNs5tG3xC9hQ7mB" }
  ]
}
GET/emails/:idRetrieve an email
ResponseJSON
{
  "object": "email",
  "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC",
  "to": ["you@example.com"],
  "from": "Acme <hello@acme.dev>",
  "created_at": "2026-09-25T09:00:12.481Z",
  "subject": "Your receipt from Acme",
  "html": "<p>Thanks for your order.</p>",
  "text": "Thanks for your order.",
  "bcc": null,
  "cc": null,
  "reply_to": ["support@acme.dev"],
  "last_event": "delivered",
  "scheduled_at": null,
  "tags": [{ "name": "category", "value": "receipt" }]
}
PATCH/emails/:idReschedule an email

Only scheduled_at can change, and only while the email is still scheduled.

Request bodyJSON
{ "scheduled_at": "2026-10-01T12:00:00Z" }
ResponseJSON
{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }
POST/emails/:id/cancelCancel a scheduled email
ResponseJSON
{ "object": "email", "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }

Domains

POST/domainsAdd a sending domain

Returns the DNS records you need to add — a DKIM TXT record, an SPF MX and TXT pair on the sending subdomain, and an optional DMARC record. Poll GET /domains/:id or wait for the domain.updated webhook to see records move from not_started to verified.

Request bodyJSON
{ "name": "acme.dev", "region": "eu-west-1" }
ResponseJSON
{
  "object": "domain",
  "id": "dom_4kP1qR9zL7vT2yB6wC0nS3x",
  "name": "acme.dev",
  "status": "not_started",
  "created_at": "2026-09-25T09:00:12.481Z",
  "region": "eu-west-1",
  "open_tracking": false,
  "click_tracking": false,
  "tls": "opportunistic",
  "records": [
    { "record": "DKIM", "type": "TXT", "name": "irisend._domainkey", "value": "p=MIGfMA0GCSq...", "status": "not_started", "required": true },
    { "record": "SPF", "type": "MX", "name": "send", "value": "feedback-smtp.eu-west-1.amazonses.com", "priority": 10, "status": "not_started", "required": true },
    { "record": "SPF", "type": "TXT", "name": "send", "value": "v=spf1 include:amazonses.com ~all", "status": "not_started", "required": true },
    { "record": "DMARC", "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none;", "status": "not_started", "required": false }
  ]
}
GET/domains/:idRetrieve a domain

Same shape as create, with each record's current verification status.

PATCH/domains/:idUpdate tracking settings

Accepts open_tracking, click_tracking, and tls (opportunistic or enforced).

API keys

POST/api-keysCreate an API key

permission is full_access (default) or sending_access. Pass domain_id to scope a sending key to one domain. The token is only ever returned on creation.

Request bodyJSON
{ "name": "Production", "permission": "full_access" }
ResponseJSON
{
  "id": "key_7mN2xQ4vP9sL1yB8wC6hT3r",
  "object": "api_key",
  "name": "Production",
  "token": "ir_8fK3mQ1z_9pR2vT6yB4wL0nC7xE1sH5dJ"
}

Webhooks

POST/webhooksCreate a webhook endpoint

Choose from 11 event types:

email.sentemail.deliveredemail.delivery_delayedemail.bouncedemail.complainedemail.openedemail.clickedemail.failedemail.scheduledemail.suppressedemail.canceled
Request bodyJSON
{
  "endpoint": "https://acme.dev/webhooks/irisend",
  "events": ["email.delivered", "email.bounced", "email.complained"]
}
ResponseJSON
{
  "object": "webhook",
  "id": "wh_3xC7qR1vN9sL4yB2wT8hM0z",
  "endpoint": "https://acme.dev/webhooks/irisend",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "signing_secret": "whsec_MfKzB3n2Rp9vQwL7xC1sH5tY"
}

Payload shape

Every delivery is a JSON envelope of { type, created_at, data }.

email.deliveredJSON
{
  "type": "email.delivered",
  "created_at": "2026-09-25T09:01:04.112Z",
  "data": {
    "email_id": "em_9f2K3mQ1zPr7vT6yB4wL0nC",
    "from": "Acme <hello@acme.dev>",
    "to": ["you@example.com"],
    "subject": "Your receipt from Acme",
    "created_at": "2026-09-25T09:00:12.481Z",
    "tags": { "category": "receipt" }
  }
}

Verifying signatures

Deliveries are signed the Standard Webhooks way, byte-compatible with Svix — the svix and standardwebhooks verifier libraries work unchanged against webhook-id, webhook-timestamp, and webhook-signature (mirrored as svix-id, svix-timestamp, svix-signature). Signatures are valid for 300 seconds from the timestamp.

verify-webhook.tsTypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyIrisendWebhook(
  secret: string,
  headers: Record<string, string | undefined>,
  body: string,
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`)
    .digest("base64");

  // headers may carry several space-separated "v1,<sig>" values during rotation
  return signature.split(" ").some((part) => {
    const candidate = Buffer.from(part.replace(/^v1,/, ""));
    const target = Buffer.from(expected);
    return candidate.length === target.length && timingSafeEqual(candidate, target);
  });
}