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.
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" },
... 8 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 your 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. 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).
{
"data": {
"id": "…", "name": "Spring campaign", "slug": "n3j6ip1vb8kj",
"description": 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. Owner only; requires 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": 1000, "remaining": 996 }
}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 preview = one successful screenshot; a dark-mode-capable client produces two per render.
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, scoped to your account) 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.
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": 1000, "remaining": 996 },
"rateLimits": {
"maxConcurrentJobs": 2, "maxJobsPerHour": 30,
"activeJobs": 0, "jobsInLastHour": 3
}
}
}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": 1240, "limit": 1000, "remaining": 0,
"overage": { "used": 240, "cap": 1000, "hardLimit": 4000,
"ratePerPreviewCents": 15, "estimatedCents": 3600 }
}Webhooks
Instead of polling, subscribe to events from your EmailQA account (webhooks can be scoped to a project, a team, or all your projects). Create and manage webhooks — with test deliveries and signing secrets — from the API console. The event for API renders is render.completed; other events include comment.created, version.uploaded, and approval.given.
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.
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 |
| 413 | HTML larger than 5MB |
| 429 | Rate limited — see Rate limits below |
| 5xx | Something failed on our side; safe to retry with backoff |
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 |
| 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.
- 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.
- 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.