# Irisend

> Irisend is a developer-first email API for transactional and marketing email, built on AWS SES. It is a plain REST API over HTTPS with JSON bodies, modelled on the Resend API (same request/response shapes, snake_case fields).

## Basics

- Base URL: `https://api.irisend.com`
- Auth: `Authorization: Bearer <API key>` on every request. Keys look like `ir_<8 chars>_<24 chars>`. Read the key from the `IRISEND_API_KEY` environment variable; never hardcode or commit it.
- Content type: `Content-Type: application/json` on every request with a body.
- No official SDK yet (a Node SDK is in development). Use the language's standard HTTP client (`fetch` in Node/TS, `requests` in Python, `net/http` in Go, etc.). Do not install or import an `irisend` package — it does not exist.
- Key permissions: `full_access` (every endpoint) or `sending_access` (only `POST /emails` and `POST /emails/batch`). A key scoped to a domain can only send from that domain. Use a `sending_access` key in application code that only sends.

## Before the first send

1. The user creates an account at Irisend and an API key in the dashboard (Configuration → API keys). The token is shown once.
2. The `from` address must be on a verified domain. `POST /domains` with `{ "name": "yourdomain.com" }` returns DNS records (DKIM TXT, SPF MX + TXT on the `send` subdomain, optional DMARC TXT) that the user must add at their DNS provider. Then `POST /domains/:id/verify` or poll `GET /domains/:id` until `status` is `verified`. Sending from an unverified domain fails with 403 `validation_error`.

## Send an email — POST /emails

```bash
curl -X POST https://api.irisend.com/emails \
  -H "Authorization: Bearer $IRISEND_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8841-receipt" \
  -d '{
    "from": "Acme <hello@yourdomain.com>",
    "to": ["you@example.com"],
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>",
    "text": "Thanks for your order."
  }'
```

Response: `{ "id": "em_9f2K3mQ1zPr7vT6yB4wL0nC" }`

Body fields:
- `from` (required): `email@example.com` or `Name <email@example.com>`, on a verified domain.
- `to` (required), `cc`, `bcc`, `reply_to`: a string or an array of up to 50 addresses.
- `subject` (required): 1–998 characters.
- `html` and/or `text`: at least one is required.
- `headers`: object of extra string headers.
- `tags`: up to 50 `{ "name", "value" }` objects; both only ASCII letters, digits, `_` or `-`, max 256 chars.
- `attachments`: up to 20 `{ "filename", "content" (base64) | "path" (URL), "content_type"?, "content_id"? }` — exactly one of `content` or `path`.
- `scheduled_at`: ISO 8601 timestamp with offset, to send later.

Header `Idempotency-Key` (1–256 chars): reusing a key returns the original email without re-sending. Generate one per logical send (e.g. per order/user action), never a constant. Keys are team-scoped and currently don't expire.

## Other email endpoints

- `POST /emails/batch` — body is a JSON array of 1–100 email objects shaped like `POST /emails`. Response `{ "data": [{ "id" }, ...] }` in input order. Counts as one request for rate limiting.
- `GET /emails/:id` — returns `{ object: "email", id, to, from, subject, html, text, cc, bcc, reply_to, created_at, scheduled_at, last_event, tags }`. `last_event` is the latest status (e.g. `sent`, `delivered`, `bounced`).
- `PATCH /emails/:id` — body `{ "scheduled_at": "..." }`; only while the email is still scheduled. Response `{ object: "email", id }`.
- `POST /emails/:id/cancel` — cancels a scheduled email. Response `{ object: "email", id }`.

## Domains

- `POST /domains` — `{ "name": "yourdomain.com", "region"?: "eu-west-1" }`. Returns `{ object: "domain", id, name, status, region, open_tracking, click_tracking, tls, records: [{ record, type, name, value, priority?, ttl, status, required }] }`.
- `GET /domains` (list, no records), `GET /domains/:id` (with records), `POST /domains/:id/verify` (re-check DNS now).
- `PATCH /domains/:id` — `open_tracking`, `click_tracking` (booleans), `tls` (`opportunistic` | `enforced`).
- `DELETE /domains/:id` — returns `{ object, id, deleted: true }`.

## API keys

- `POST /api-keys` — `{ "name", "permission"?: "full_access" | "sending_access", "domain_id"? }`. Returns `{ id, token }`; the token is only returned here.
- `GET /api-keys` — `{ object: "list", has_more, data: [{ id, name, created_at, last_used_at }] }`.
- `DELETE /api-keys/:id` — 200 with an empty body.

## Errors

Every error is JSON `{ "statusCode", "name", "message" }`. Branch on `name`, not on `message`.

| name | status | meaning |
| --- | --- | --- |
| missing_required_field | 422 | A required field is missing from the request body. |
| invalid_from_address | 422 | The `from` field doesn't parse as `email@example.com` or `Name <email@example.com>`. |
| validation_error | 400 / 403 / 422 | The body failed schema validation (422), the JSON itself was malformed (400), or a business rule was violated — e.g. sending from an unverified domain, or re-registering a domain name (403). |
| invalid_idempotency_key | 400 | The `Idempotency-Key` header is not 1–256 characters. |
| missing_api_key | 401 | No `Authorization` header was sent. |
| restricted_api_key | 401 / 403 | A sending-only key called a management endpoint (401), or a domain-scoped key tried to send from a non-matching domain (403). |
| invalid_api_key | 403 | The API key is malformed or does not exist. |
| not_found | 404 | The endpoint, or a resource ID owned by a different team, does not exist. |
| rate_limit_exceeded | 429 | Your team exceeded its requests-per-second limit. |
| internal_server_error | 500 | Something went wrong on our end. Safe to retry. |

Retry only on 429 (wait `retry-after` seconds) and 5xx (exponential backoff), always with the same `Idempotency-Key`. Never retry 4xx validation/auth errors.

## Rate limits

10 requests per second per team by default, shared across all keys, fixed one-second window. Response headers: `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset`, and `retry-after` on 429. Prefer `POST /emails/batch` for bulk sends.

## Webhooks

- `POST /webhooks` — `{ "endpoint": "https://...", "events": [...] }`. Returns `{ object: "webhook", id, signing_secret }` (`whsec_...`). Store the secret as `IRISEND_WEBHOOK_SECRET`.
- `GET /webhooks`, `GET /webhooks/:id` (includes `signing_secret`), `DELETE /webhooks/:id`.
- Event types: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.scheduled`, `email.suppressed`, `email.canceled`, `domain.reputation.warning`, `domain.reputation.paused`, `account.created`, `account.suspended`, `account.resumed`, `domain.dmarc.unknown_source`, `domain.dmarc.ramp_ready`. `account.*` events fire on the PARENT account's webhooks (sub-account lifecycle), not the sub-account's own.
- Payload: every delivery is a `{ "type", "created_at", "data" }` envelope, but the `data` shape depends on the event family — branch on `type` before reading `data`:
  - `email.*` → `data: { email_id, from, to, subject, created_at, tags }`
  - `domain.reputation.warning, domain.reputation.paused` → `data: { domain_id, domain, hard_bounce_rate, complaint_rate, sample_size, window }`
  - `account.created, account.suspended, account.resumed` → `data: { account_id, name, external_id, status, suspended_reason }`
  - `domain.dmarc.unknown_source` → `data: { domain_id, domain, source_ip, messages }`
  - `domain.dmarc.ramp_ready` → `data: { domain_id, domain, recommended_policy }`
- Signatures follow Standard Webhooks and are byte-compatible with Svix: headers `webhook-id`, `webhook-timestamp`, `webhook-signature` (mirrored as `svix-*`). The `svix` and `standardwebhooks` libraries work unchanged. Verify against the raw request body exactly as received (not re-serialized JSON), reject timestamps older than 300 seconds, and respond 2xx quickly.

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

export function verifyIrisendWebhook(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    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,/, ""));
    return candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
}
```

## Deliverability

- Hard bounces and spam complaints add the recipient to the team's suppression list automatically; suppressed recipients are dropped before sending (`email.suppressed` if none remain).
- Keep hard bounce rate under 5% and complaint rate under 0.1%.
- One-click unsubscribe (`List-Unsubscribe`) is not sent automatically yet.

## Links

- Human docs: /docs
- Acceptable use policy: /legal/acceptable-use
