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 emailqa
import 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 dark

Or 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

GET/api/v1/clients

The 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.

GET/api/v1/projects

List 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.

POST/api/v1/projects

Create 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"
  }
}
GET/api/v1/projects/{slug}

Project details: versions, tags, folder, comment counts, preview URL.

PATCH/api/v1/projects/{slug}

Update name, description, isPrivate, or isArchived.

DELETE/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

GET/api/v1/projects/{slug}/versions

List a project’s version history.

POST/api/v1/projects/{slug}/versions

Upload 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

POST/api/v1/projects/{slug}/renders

Render 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

GET/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; treat error strings 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.
DELETE/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 URL

Idempotency — 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

GET/api/v1/usage

Your 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 }
}

Comments

GET/api/v1/projects/{slug}/comments

List 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.

POST/api/v1/projects/{slug}/comments

Post 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 returns 409, the same as in the app.

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).

StatusMeaning
400Invalid request — missing fields, unknown client ids, invalid HTML
401Missing, invalid, or expired API key
402Plan limit reached (monthly preview ceiling, render entitlement)
403Key lacks the required scope, no access to this project, or API access not enabled
404Not found (or not yours — existence is not revealed)
409Conflict — e.g. cancelling a finished job, commenting on a locked version, or reusing an Idempotency-Key for a different project
413HTML larger than 5MB
429Rate limited — see Rate limits below
5xxSomething failed on our side; safe to retry with backoff (reuse your Idempotency-Key)

Rate limits & quotas

LimitValue
Monthly renders3,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 jobs2
Render jobs30 per hour
Sandbox (test) jobs120 per hour, separate from render jobs
Project creation30 per hour
Version uploads60 per hour
HTML size5MB per upload
Screenshot URL lifetime1 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 overage block 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.

Ready to build?

API access is included with the Business plan.

View pricing