Developer Documentation
EmailQA API
Render emails on real email clients — real Outlook on Windows, real iPhones and Android devices, not simulations — from your CI pipeline, campaign platform, or scripts. Upload HTML, trigger renders, and get back light and dark mode screenshots plus shareable preview links.
API access is available on the Business plan. See pricing. All endpoints are served from https://emailqa.live. Machine-readable spec: OpenAPI 3.1 — import it into Postman or feed it to your code generator. Official TypeScript SDK: npm install emailqa. Live service health and uptime history: status.emailqa.live, checked every minute by a monitor outside our own infrastructure.
Quick start
Fastest path is the TypeScript SDK (Node 18+, zero dependencies):
npm install emailqaimport EmailQA from 'emailqa';
const emailqa = new EmailQA({ apiKey: process.env.EMAILQA_API_KEY });
const project = await emailqa.createProject({
name: 'Spring campaign',
html: '<!DOCTYPE html>...',
});
const { job } = await emailqa.createRender(project.slug, {
clients: ['outlook-desktop', 'gmail-ios'],
});
const done = await emailqa.waitForRender(job.id);
// done.results -> per-client screenshots, light and darkOr raw HTTP — three requests: create a project with your HTML, trigger a render, poll for the screenshots.
# 1. Create a project with your email HTML (max 5MB)
curl -X POST https://emailqa.live/api/v1/projects \
-H "Authorization: Bearer eq_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Spring campaign", "html": "<!DOCTYPE html>..."}'
# -> { "data": { "slug": "n3j6ip1vb8kj", "previewUrl": "...", ... } }
# 2. Trigger renders (omit "clients" for the full fleet)
curl -X POST https://emailqa.live/api/v1/projects/n3j6ip1vb8kj/renders \
-H "Authorization: Bearer eq_your_api_key" \
-H "Content-Type: application/json" \
-d '{"clients": ["outlook-desktop", "gmail-ios"]}'
# -> 202 { "data": { "id": "rj_G6kHhvruMlPO6ibM", "status": "queued", ... } }
# 3. Poll until completed (or subscribe to the render.completed webhook)
curl https://emailqa.live/api/v1/renders/rj_G6kHhvruMlPO6ibM \
-H "Authorization: Bearer eq_your_api_key"
# -> { "data": { "status": "completed", "results": [ { "client": "...",
# "status": "success", "screenshotUrl": "https://..." } ] } }A full-fleet render typically completes in 10–15 minutes (mobile devices are the slowest lane); single desktop clients in about a minute. Re-submitting identical HTML returns cached screenshots instantly.
Authentication
Create an API key in Settings → API Keys and send it as a Bearer token on every request. Keys are shown once at creation and stored hashed — if you lose one, revoke it and create another.
curl https://emailqa.live/api/v1/projects \
-H "Authorization: Bearer eq_your_api_key"Keys carry scopes: read (fetch projects, results, comments) and write (create projects, upload HTML, trigger and cancel renders, post comments). Keys can optionally expire, and can be revoked at any time from Settings.
Team keysbelong to a team rather than to the person who created them. Any team owner or member can create one. A team key reaches that team’s projects, keeps working when people join or leave the team (or delete their accounts), and draws on the team’s render allowance, which comes from the team owner’s Business plan. The team owner can see and revoke every team key, so when someone leaves, rotate the keys they had. Use team keys for CI and shared integrations.
Personal keysact as you: they reach your own projects and the projects of the teams you’re on, with the access you have in the app right now (a team viewer can read and comment, but not upload or render). They stop reaching a team’s projects when you leave that team, and are deleted with your account. A personal key can be moved to a team without changing it, so an integration that already uses it keeps working.
Render clients
/api/v1/clientsThe live client catalog. No authentication required. Use the id values in render requests. Clients under maintenance are listed but temporarily rejected by the render endpoint.
{
"data": [
{ "id": "outlook-desktop", "label": "Outlook Windows (Classic)",
"category": "Desktop", "os": "Windows", "darkMode": true, "status": "available" },
{ "id": "apple-mail", "label": "Apple Mail (Mac)",
"category": "Desktop", "os": "Mac", "darkMode": true, "status": "available" },
{ "id": "gmail-ios", "label": "Gmail (iOS)",
"category": "Mobile", "os": "iOS", "darkMode": true, "status": "available" },
... 9 more
]
}Clients with darkMode: true return separate light and dark screenshots automatically (each counts as one preview). The Gmail web clients return a single screenshot because Gmail strips dark-mode CSS from emails.
Projects
A project holds one email and its version history, comments, and renders.
/api/v1/projectsList the projects this key can reach: a team key’s team projects, or a personal key’s own projects plus its teams’ (each has a teamId, null for personal projects). Query params: archived (true/false), limit, offset.
/api/v1/projectsCreate a project. Body fields: name (required), description, html (creates version 1; max 5MB), isPrivate, teamId. API-created projects are private by default — pass isPrivate: false to make the preview link viewable by anyone who has it (useful for sharing with reviewers who don’t have accounts).
A team key always creates the project in its team, where the whole team can review it. A personal key creates a personal project, or a team project when you pass the teamId of a team you own or are a member of.
{
"data": {
"id": "…", "name": "Spring campaign", "slug": "n3j6ip1vb8kj",
"description": null, "teamId": null, "isPrivate": true, "createdAt": "…",
"previewUrl": "https://emailqa.live/preview/n3j6ip1vb8kj"
}
}/api/v1/projects/{slug}Project details: versions, tags, folder, comment counts, preview URL.
/api/v1/projects/{slug}Update name, description, isPrivate, or isArchived.
/api/v1/projects/{slug}Delete a project and everything in it. Requires the delete scope. A personal project can be deleted by its owner; a team project by the team owner, or by a team key (only the team owner can give a team key the delete scope).
Versions
/api/v1/projects/{slug}/versionsList a project’s version history.
/api/v1/projects/{slug}/versionsUpload a new HTML version. Body: { "html": "..." } (max 5MB). Returns the new version number and a preview URL pinned to it.
{
"data": {
"id": "…", "versionNumber": 3, "createdAt": "…",
"previewUrl": "https://emailqa.live/preview/n3j6ip1vb8kj?version=3"
}
}Triggering renders
/api/v1/projects/{slug}/rendersRender a version on real email clients. Body fields (both optional): version (defaults to the latest) and clients(array of client ids; defaults to the full available fleet). API render jobs get priority in the render queue — they’re scheduled ahead of waiting non-priority jobs. Returns 202 Accepted with a job:
{
"data": {
"id": "rj_G6kHhvruMlPO6ibM",
"status": "queued",
"cached": false,
"projectSlug": "n3j6ip1vb8kj",
"versionNumber": 2,
"requestedClients": ["gmail-web"],
"queuePosition": 0,
"estimatedWaitSeconds": 59,
"results": [],
"createdAt": "2026-07-17T12:10:09.063Z",
"completedAt": null
},
"monthlyPreviews": { "used": 4, "limit": 3000, "remaining": 2996 }
}If identical HTML was rendered recently, the response is 200 OK with cached: true, status: "completed", and the screenshots included immediately — no fleet time, results in about a second.
Every response includes monthlyPreviews so you always know where your quota stands. One render = one email client that rendered successfully: light and dark mode of the same client count once, so a full sweep of all 12 clients uses 12. Failed clients are free.
Render jobs
/api/v1/renders/{id}Job status and results. While the job runs, results stream in per client (completedCount / totalCount show progress). Job status is one of queued, processing, completed, error, cancelled.
{
"data": {
"id": "rj_G6kHhvruMlPO6ibM",
"status": "completed",
"results": [
{
"client": "gmail-web",
"status": "success",
"screenshotUrl": "https://…s3.us-east-2.amazonaws.com/…png?X-Amz-…",
"screenshotUrlExpiresInSeconds": 3600
}
],
"createdAt": "2026-07-17T12:10:09.063Z",
"completedAt": "2026-07-17T12:11:07.297Z"
}
}Two things to design for:
- Partial results are normal.Each client succeeds or fails independently — a job can complete with 18 screenshots and 2 per-client errors. Check each result’s
status; treaterrorstrings as opaque text. Failed clients never consume previews. - Screenshot URLs expire after 1 hour. They’re signed links — download the images or re-fetch the job for fresh URLs.
/api/v1/renders/{id}Cancel a queued or in-progress job. Finished jobs return 409.
Sandbox & idempotency
Sandbox mode — pass "test": true in a render request to get an instantly-completed job with fixture screenshots: no fleet time, no quota consumed, no email sent, nothing added to the project gallery. The render.completed webhook still fires (with test: true in the payload), so you can integration-test your entire pipeline end to end.
POST /api/v1/projects/{slug}/renders
{ "test": true, "clients": ["outlook-desktop"] }
# -> 200, status "completed" immediately, test: true,
# results carry a stable fixture screenshot URLIdempotency — send an Idempotency-Key header (max 256 chars) on render requests and a retried request returns the original job with "replayed": true instead of rendering — and billing — twice. Use something stable per logical render, like a deploy or commit id. If a request fails with a 5xx before your render starts, the key is released, so retrying with the same key starts the render.
Idempotency-Keys are scoped to the account a key acts for: yours for a personal key, the team owner’s for a team key (shared with the owner’s personal keys). Reusing one for a render of a different project returns 409 instead of replaying the other project’s job, so if several pipelines use commit ids, include the project slug in the key.
curl -X POST .../renders \
-H "Authorization: Bearer eq_..." \
-H "Idempotency-Key: deploy-4f2a91c" \
-d '{"clients": ["outlook-desktop"]}'Usage
/api/v1/usageYour current quota and rate-limit state — check where you stand without triggering a render or waiting for a 402.
{
"data": {
"monthlyPreviews": { "used": 4, "limit": 3000, "remaining": 2996 },
"rateLimits": {
"maxConcurrentJobs": 2, "maxJobsPerHour": 30,
"activeJobs": 0, "jobsInLastHour": 3,
"maxSandboxJobsPerHour": 120, "sandboxJobsInLastHour": 0
}
}
}On Business plans past the included quota, monthlyPreviews gains an overage block (also echoed in render responses) so you can track spend as it accrues:
"monthlyPreviews": {
"used": 3240, "limit": 3000, "remaining": 0,
"overage": { "used": 240, "cap": 9000, "hardLimit": 12000,
"ratePerPreviewCents": 5, "estimatedCents": 1200 }
}Webhooks
Instead of polling, subscribe to events from your EmailQA account. Create and manage webhooks — with test deliveries and signing secrets — from the API console. The event for API renders is render.completed. Review activity sends version.uploaded, version.locked, comment.created, comment.replied, comment.resolved and approval.given, whether it happens in the app, through the API or from a Slack reply. Each payload carries the project (id, name, slug), the version, who did it, and a previewUrl.
Like keys, a webhook belongs to you or to a team, and can be limited to one project. A team webhookcovers the team’s projects, keeps firing when people join or leave the team, and can be seen and removed by the team owner. Any team owner or member can add one. A personal webhook that isn’t limited to a project hears your personal projects and the renders your keys start.
Webhooks deliver while the account they belong to has API access (the team owner’s, for a team webhook). If the plan lapses they stay listed but pause, and start delivering again when API access is back.
POST <your webhook URL>
X-EmailQA-Event: render.completed
X-EmailQA-Delivery: <uuid>
X-EmailQA-Timestamp: <ISO timestamp>
X-EmailQA-Signature: sha256=<hex HMAC of the raw body>
{
"event": "render.completed",
"timestamp": "2026-07-17T12:11:07.000Z",
"data": {
"render": {
"id": "rj_G6kHhvruMlPO6ibM",
"status": "completed",
"cached": false,
"project": { "slug": "n3j6ip1vb8kj", "name": "Spring campaign" },
"versionNumber": 2,
"requestedClients": ["gmail-web"],
"results": [ { "client": "gmail-web", "status": "success" } ]
}
}
}Webhook payloads deliberately contain no screenshot URLs — on receipt, call GET /api/v1/renders/{id} for fresh signed links.
Retries:if your endpoint doesn’t answer with a 2xx (a network error, no response within 15 seconds, or a 408, 429 or 5xx), we retry after 30 seconds and again after 5 minutes. Every attempt carries the same X-EmailQA-Delivery id, so you can de-duplicate. For a pipeline that must not miss a result, also poll GET /api/v1/renders/{id}.
Verify signatures: if your webhook has a secret, the X-EmailQA-Signature header is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Compute it over the exact bytes received and compare with a constant-time comparison.
Errors
Errors are JSON: { "error": "message" }, sometimes with extra context (invalid client requests include validClients; quota errors include monthlyPreviews).
| Status | Meaning |
|---|---|
| 400 | Invalid request — missing fields, unknown client ids, invalid HTML |
| 401 | Missing, invalid, or expired API key |
| 402 | Plan limit reached (monthly preview ceiling, render entitlement) |
| 403 | Key lacks the required scope, no access to this project, or API access not enabled |
| 404 | Not found (or not yours — existence is not revealed) |
| 409 | Conflict — e.g. cancelling a finished job, commenting on a locked version, or reusing an Idempotency-Key for a different project |
| 413 | HTML larger than 5MB |
| 429 | Rate limited — see Rate limits below |
| 5xx | Something failed on our side; safe to retry with backoff (reuse your Idempotency-Key) |
Rate limits & quotas
| Limit | Value |
|---|---|
| Monthly renders | 3,000 included per month, shared with in-app rendering (1 render = 1 email client; light and dark count once) |
| Overage (Business) | $0.05 per render beyond the included 3,000 (app and API alike), up to 12,000 total per month |
| Concurrent render jobs | 2 |
| Render jobs | 30 per hour |
| Sandbox (test) jobs | 120 per hour, separate from render jobs |
| Project creation | 30 per hour |
| Version uploads | 60 per hour |
| HTML size | 5MB per upload |
| Screenshot URL lifetime | 1 hour (re-fetch the job for fresh links) |
- API render jobs are scheduled ahead of non-priority jobs in the render queue.
- Limits and the monthly allowance belong to the account a key acts for, so a team’s keys share the team owner’s limits and the team’s allowance.
- Failed client renders never count against your allowance.
- Cached results count as renders but return in about a second.
- Sandbox test renders are free — they never consume your allowance or your 30 render jobs an hour.
- Renders of the same HTML are deduplicated — render only the clients you need per commit (e.g. just Outlook) and save full-fleet runs for release candidates.
- Business plans don’t hard-stop at the included quota: API renders beyond 3,000 bill automatically at $0.05 each, up to a ceiling of 12,000 total per month. Responses include an
overageblock while that’s active, so integrations can track spend. - We email you at 80% and 100% of your included previews — no surprise invoices.
- Need a higher ceiling? Get in touch — custom limits are available.
Comments
/api/v1/projects/{slug}/commentsList review comments on a version (defaults to latest). Query params:
version,status(open/resolved). Includes author, assignee, pin position, and reply counts — useful for syncing review state into your own tools./api/v1/projects/{slug}/commentsPost a comment. Body:
content(required),type(pin/box/general, default general),pinX/pinY(percentage coordinates for pins),versionNumber. A version with commenting locked takes no new comments and returns409, the same as in the app.