Docs
Let your AI assistant set up Irisend.
Copy the prompt below and paste it into Claude Code, Cursor, Copilot, or any chat assistant working in your codebase. It carries the full API reference and step-by-step instructions, so the assistant can add a send helper, env config, and webhook verification without you reading the docs first.
Promptmarkdown
You are helping me add Irisend (a transactional email API) to this project. Everything you need is in the reference below — don't guess endpoints or fields that aren't in it.
Do this:
1. Look at the codebase and figure out the language, framework, and how config/secrets are loaded. If there is already an email provider (Resend, SendGrid, Postmark, Nodemailer, ...), tell me and ask whether to replace it before touching it.
2. Add `IRISEND_API_KEY` to the project's env config (and to `.env.example` with a placeholder, never a real key). Ask me for the `from` address; it must be on a domain I have verified in Irisend.
3. Create one small, typed email helper/module (e.g. `sendEmail({ to, subject, html, text })`) that calls `POST https://api.irisend.com/emails` with the project's existing HTTP client — there is no Irisend SDK package to install. It must:
- send `Authorization: Bearer $IRISEND_API_KEY` and `Content-Type: application/json`;
- accept an optional idempotency key and send it as `Idempotency-Key`;
- on failure, surface the error `name` and `message` from the JSON body;
- retry only on 429 (honouring `retry-after`) and 5xx, with the same idempotency key.
4. Wire it into the place I point you to (or the most obvious existing email trigger), and show me how to send a test email.
5. Only if I ask for delivery tracking: add a webhook endpoint that verifies the signature as shown below, using the raw request body, and store the secret as `IRISEND_WEBHOOK_SECRET`.
Keep changes minimal and match the project's existing style. When you're done, list the files you changed and the env vars I need to set.
---
# 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-useBefore you paste
- Create an API key under Configuration → API keys and keep it ready. Put it in your env file yourself — don't paste the key into the chat.
- Verify the domain you'll send from. The assistant can add it through the API, but the DNS records still have to be published at your DNS provider.
Plain-text docs for tools
The same reference, without the instructions, is served as Markdown at /llms.txt, following the llms.txt convention. Point an agent at it directly — for example, add it as a docs source in Cursor or tell Claude Code to fetch https://irisend.com/llms.txt.