QAP Tools

Sign in

Welcome to QAP Tools

Email API

Receive and inspect real emails in automated tests.

For Playwright, Cypress, Selenium, Postman, backend tests and CI/CD.

Loading…

  1. 1Create API token
  2. 2Create inbox
  3. 3Send email
  4. 4Wait for message
  5. 5Assert OTP, link, body or attachment

Temporary inboxes · Custom prefix · TTL · Wait · Subject, sender and recipient filters · Text / HTML · Attachments · Raw MIME · Ordered headers · OTP candidates · Links

Limits

  • 200 inboxes / calendar month UTC
  • 300 accepted emails / calendar month UTC
  • 5 active inboxes
  • 20 accepted emails / inbox lifetime
  • 60 requests / rolling 60 seconds
  • 2 concurrent waits
  • TTL: 15m / 30m / 1h / 2h / 4h; default 2h

Monthly counters reset at 00:00 UTC on the first day. Rejected deliveries do not count. Reads and downloads do not use monthly quota. Deletion and expiry do not refund quota.

Documentation

Send API requests to QAP Tools. Your application under test only sends email to the temporary address returned by the API.

https://qaptools.com/api/email/v1

Quick Start

Email API receives email. It does not send email from your application.

  1. Sign in to QAP Tools.
  2. In the Email API dashboard, open API Tokens → Create API token.
  3. Save the token as QAP_EMAIL_API_TOKEN in your environment or CI secret store.
  4. Create an inbox through the API.
  5. Send an email from your application to the returned address.
  6. Wait for a matching message.
  7. Assert its subject, body, OTP, link or attachment.

For these examples, select inboxes:create, messages:wait and messages:read. Add extractions:read for OTP/link checks and attachments:read or raw:read for downloads.

These examples create an inbox and pause so you can trigger an email in your application. They expect a subject containing Welcome; adapt the assertions to your email. cURL uses Bash, jq and uuidgen; JavaScript uses Node.js 20+; Python uses the standard library.

set -eu
BASE="https://qaptools.com/api/email/v1"
# QAP_EMAIL_API_TOKEN comes from your environment or CI secrets.
IDEMPOTENCY_KEY=$(uuidgen) # Keep this key when retrying this request.
BOX=$(curl --fail-with-body -sS "$BASE/inboxes" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"signup","ttl":"2h"}')
INBOX_ID=$(printf '%s' "$BOX" | jq -r .id)
printf '%s' "$BOX" | jq -r .address
read -r -p "Send a Welcome email from your app to this address, then press Enter: " _
RESULT=$(curl --fail-with-body -sS \
  "$BASE/inboxes/$INBOX_ID/wait?timeout=20&subject_contains=Welcome" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN")
printf '%s' "$RESULT" | jq -e \
  '.status == "matched" and (.message.subject | contains("Welcome"))'

Authentication

All HTTP API operations use a scoped Bearer token. Your dashboard session alone does not authorize API requests. Documentation is public; inboxes require an account.

Sign in on this page, then open Email API dashboard → API Tokens → Create API token. Choose only the scopes your test needs.

Authorization: Bearer $QAP_EMAIL_API_TOKEN
  • The token is displayed once. Save it in a CI secret or environment variable named QAP_EMAIL_API_TOKEN.
  • Keep it out of frontend source, repository commits and URLs.
  • To rotate it, create a replacement, update your CI secret, then revoke the old token.
Scopes
ScopesPurpose
inboxes:createCreate temporary inboxes
inboxes:readRead inbox metadata
inboxes:deleteDelete an inbox and revoke access to its content
messages:readRead accepted messages, headers and attachment metadata
messages:waitWait for a matching accepted message
raw:readDownload raw MIME
attachments:readDownload attachments
extractions:readRead OTP and link candidates
usage:readRead Email API usage and remaining quota

Core concepts

Inbox
A temporary email address with a selected TTL. A readable prefix always gets a random suffix; the full address is never reissued.
Accepted email
A received delivery that passed the account and inbox limits. Only accepted emails count toward the monthly email quota.
Idempotency-Key
Required for inbox creation. Retry the same request with the same key and input to recover the original inbox without another create quota charge. Replay does not renew TTL; GET returns its current status.
Wait
Long-poll for a matching accepted message, until it arrives or the timeout ends.
after
A position returned by WAIT. Pass it to a later WAIT to skip the previously returned message.
TTL
The inbox receiving lifetime. Expired or deleted inbox content is no longer accessible. Retained inbox metadata can still report its status.

Common test scenarios

Signup verification

Create inbox → enter its address in the signup form → submit → WAIT by subject → extract the verification link → check its host/path and open it in your test.

OTP login

Create inbox → trigger OTP → WAIT for the message → read OTP candidates → assert or use the expected code.

Password reset

Create inbox → request a password reset for that address → WAIT by subject → extract and check the reset link.

Playwright: test a signup email

request calls QAP Tools; page visits your application under test. Replace the application URL, form labels and expected subject with your own. This example needs inboxes:create and messages:wait. Allow enough test time for WAIT.

import { test, expect } from "@playwright/test";
import { randomUUID } from "node:crypto";

test("signup email", async ({ page, request }) => {
  test.setTimeout(60_000);
  const base = "https://qaptools.com/api/email/v1";
  const headers = { Authorization: "Bearer " + process.env.QAP_EMAIL_API_TOKEN };
  const created = await request.post(base + "/inboxes", {
    headers: { ...headers, "Idempotency-Key": randomUUID() },
    data: { prefix: "signup", ttl: "2h" },
  });
  expect(created.ok()).toBeTruthy();
  const inbox = await created.json();

  // Your application: replace the URL and form selectors.
  await page.goto("https://app-under-test.example/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Sign up" }).click();

  // QAP Tools: wait for the email sent by your application.
  const waited = await request.get(base + "/inboxes/" + inbox.id +
    "/wait?timeout=20&subject_contains=Welcome", { headers, timeout: 35_000 });
  expect(waited.ok()).toBeTruthy();
  const result = await waited.json();
  expect(result.status).toBe("matched");
  expect(result.message.subject).toContain("Welcome");
});
Check body, OTP, link and attachment

Add this after a matched WAIT in the Playwright example. It needs messages:read and extractions:read. Replace the expected OTP 123456 and welcome.pdf with values from your test fixture. A candidate is not a guaranteed OTP; check the expected link host and path before using it.

// Inside the same test, after a matched WAIT.
const path = base + "/inboxes/" + inbox.id + "/messages/" + result.message.id;
const detail = await request.get(path, { headers });
expect(detail.ok()).toBeTruthy();
const message = await detail.json();
expect(message.text).toContain("Welcome");
expect(message.attachments.map(item => item.filename)).toContain("welcome.pdf");

const extracted = await request.get(path + "/extractions", { headers });
expect(extracted.ok()).toBeTruthy();
const candidates = await extracted.json();
expect(candidates.otp_candidates.map(item => item.value)).toContain("123456");
const verification = candidates.links.find(item => {
  const url = new URL(item.url);
  return url.origin === "https://app-under-test.example" && url.pathname === "/verify";
});
expect(verification).toBeDefined();
await page.goto(verification.url);

Also works with Cypress · Selenium · Postman · backend tests · CI/CD.

API Reference

Paths are relative to the QAP Tools base URL. Every request needs Authorization. Replace {id}, {messageId} and {attachmentId} with returned UUIDs; set INBOX_ID, MESSAGE_ID and ATTACHMENT_ID to those values for cURL. Before a new creation, set IDEMPOTENCY_KEY=$(uuidgen); keep it when retrying. Only the documented query parameters are supported. Common authentication, rate and service errors also apply to every endpoint.

Responses below use illustrative values. Always use the IDs, address and download URL returned by the API; mail.example.invalid is not a receiving domain.

Inboxes

POST /inboxesCreate a temporary inbox

Required scope: inboxes:create

Parameters: Headers: Authorization, Idempotency-Key (1–200 printable ASCII characters, nonblank), Content-Type: application/json. Body: optional prefix (default test), optional ttl (default 2h); max 1024 bytes. TTL: 15m, 30m, 1h, 2h, 4h. Prefix: 1–31 ASCII letters/digits/hyphens, trimmed and lowercased, no leading/trailing hyphen. Reserved: admin, postmaster, abuse, support, root, system, qap, noreply, no-reply. Same key + same normalized input replays the original creation snapshot, including status active; GET gives the current status. Reuse the key on retries, use a new key for a new inbox.

Request example

curl --globoff --fail-with-body -sS -X POST "https://qaptools.com/api/email/v1/inboxes" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"signup","ttl":"2h"}'

Successful response · 201 / 200 (replay)

{
  "id": "11111111-1111-4111-8111-111111111111",
  "address": "signup-0123456789abcdef0123456789abcdef@mail.example.invalid",
  "created_at": "2026-10-05T10:00:00.000Z",
  "expires_at": "2026-10-05T12:00:00.000Z",
  "status": "active"
}

Key errors: IDEMPOTENCY_KEY_REQUIRED · INVALID_PREFIX · INVALID_TTL · IDEMPOTENCY_CONFLICT · ACTIVE_INBOX_LIMIT · INBOX_MONTHLY_QUOTA_EXCEEDED

GET /inboxesList active inboxes

Required scope: inboxes:read

Parameters: No body or query. Active inboxes only, maximum 5, ordered by creation time then ID. No history listing.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "inboxes": [
    {
      "id": "11111111-1111-4111-8111-111111111111",
      "address": "signup-0123456789abcdef0123456789abcdef@mail.example.invalid",
      "created_at": "2026-10-05T10:00:00.000Z",
      "expires_at": "2026-10-05T12:00:00.000Z",
      "status": "active"
    }
  ]
}

Key errors: INVALID_REQUEST

GET /inboxes/{id}Read current inbox metadata

Required scope: inboxes:read

Parameters: id: inbox UUID. No body or query. status is active, expired or deleted; retained metadata is readable after expiry/deletion.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "id": "11111111-1111-4111-8111-111111111111",
  "address": "signup-0123456789abcdef0123456789abcdef@mail.example.invalid",
  "created_at": "2026-10-05T10:00:00.000Z",
  "expires_at": "2026-10-05T12:00:00.000Z",
  "status": "active"
}

Key errors: INBOX_NOT_FOUND

DELETE /inboxes/{id}Delete an inbox

Required scope: inboxes:delete

Parameters: id: inbox UUID. No body or query. Repeating deletion for your inbox is safe. Content access ends; monthly quota is not refunded.

Request example

curl --globoff --fail-with-body -sS -X DELETE "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 204

204 No Content — empty response body.

Key errors: INBOX_NOT_FOUND

Messages

Filters combine with AND. subject_contains is a case-insensitive literal substring (1–512 UTF-8 bytes, no control characters). sender and recipient match full envelope addresses: domain case is normalized; local-part case is preserved. Use the complete returned inbox address for recipient. Unknown or repeated parameters are rejected.

GET /inboxes/{id}/messagesList accepted messages

Required scope: messages:read

Parameters: id: inbox UUID. Optional subject_contains, sender, recipient; combined with AND. Accepted, available messages only, in stable order. Hard maximum 20 accepted emails per inbox lifetime; no pagination or cursor. Filters do not change quota accounting.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages?subject_contains=Welcome" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "messages": [
    {
      "id": "22222222-2222-4222-8222-222222222222",
      "from": "hello@app.example.invalid",
      "to": [
        "signup-0123456789abcdef0123456789abcdef@mail.example.invalid"
      ],
      "subject": "Welcome",
      "received_at": "2026-10-05T10:01:00.000Z",
      "has_attachments": true
    }
  ],
  "admission_pending": false
}

Key errors: INVALID_FILTER · INBOX_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · ADMISSION_PENDING

GET /inboxes/{id}/messages/{messageId}Read a message and attachment metadata

Required scope: messages:read

Parameters: id, messageId: UUIDs. No body or query. from/to are envelope addresses; to contains this inbox address. subject, text, html and content_type can be null. size is bytes. HTML is untrusted data. Full headers are separate. Attachment downloads require attachments:read.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages/$MESSAGE_ID" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "id": "22222222-2222-4222-8222-222222222222",
  "from": "hello@app.example.invalid",
  "to": [
    "signup-0123456789abcdef0123456789abcdef@mail.example.invalid"
  ],
  "subject": "Welcome",
  "received_at": "2026-10-05T10:01:00.000Z",
  "text": "Welcome! Your code is 123456.",
  "html": "<p>Welcome! Your code is 123456.</p>",
  "attachments": [
    {
      "id": "33333333-3333-4333-8333-333333333333",
      "filename": "welcome.pdf",
      "content_type": "application/pdf",
      "size": 1024
    }
  ]
}

Key errors: MESSAGE_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · ADMISSION_PENDING

Wait

WAIT is a long-poll for the first available matching accepted message. Default timeout: 20 seconds; allowed: 1–25 seconds. Maximum: 2 concurrent waits per account, shared across tokens and inboxes. Internal polling does not consume extra external API rate slots.

Pass the returned after value to your next WAIT call to avoid receiving the same message again. Keep it as a decimal string. Without after (default 0), the same message may be returned. Timeout does not advance it. Changing filters keeps earlier positions excluded; use after=0 to search from the beginning.

WAIT returns message metadata. Read /messages/{messageId} for body and attachment metadata; full headers have a separate endpoint.

Next WAIT request

GET /inboxes/{id}/wait?timeout=20&subject_contains=Welcome&after=123

Timeout is a normal HTTP 200 response

{
  "status": "timeout",
  "message": null
}
GET /inboxes/{id}/waitWait for a matching accepted message

Required scope: messages:wait

Parameters: id: inbox UUID. Optional timeout: integer 1–25, default 20; after: decimal string 0–9223372036854775807, default 0. The same subject_contains, sender and recipient filters apply. messages:read is not needed for WAIT itself.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/wait?timeout=20&subject_contains=Welcome" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "status": "matched",
  "message": {
    "id": "22222222-2222-4222-8222-222222222222",
    "from": "hello@app.example.invalid",
    "to": [
      "signup-0123456789abcdef0123456789abcdef@mail.example.invalid"
    ],
    "subject": "Welcome",
    "received_at": "2026-10-05T10:01:00.000Z",
    "has_attachments": true
  },
  "after": "123"
}

Key errors: INVALID_WAIT_TIMEOUT · INVALID_FILTER · INVALID_REQUEST · CONCURRENT_WAIT_LIMIT · INBOX_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · ADMISSION_PENDING

Usage

GET /usageRead current usage and remaining quota

Required scope: usage:read

Parameters: No body or query. Current calendar month UTC (YYYY-MM), limits, used and remaining monthly quota; used.active_inboxes is the current active count. Counters reset at 00:00:00 UTC on the first day. Accepted emails count in the month they were received. Deletion/expiry do not refund quota.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/usage" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "month": "2026-10",
  "limits": {
    "inboxes_created": 200,
    "accepted_deliveries": 300,
    "active_inboxes": 5,
    "messages_per_inbox": 20
  },
  "used": {
    "inboxes_created": 1,
    "accepted_deliveries": 1,
    "active_inboxes": 1
  },
  "remaining": {
    "inboxes_created": 199,
    "accepted_deliveries": 299
  }
}

Key errors: ADMISSION_PENDING · BACKEND_UNAVAILABLE

Downloads

Raw MIME and attachment endpoints return a short-lived download URL. Its configured lifetime is at most 30 seconds and can be shorter. Once issued, it may remain usable briefly after inbox deletion or token revocation. Fetch the returned URL directly; do not send your QAP API token to it.

GET /inboxes/{id}/messages/{messageId}/rawGet a raw MIME download URL

Required scope: raw:read

Parameters: id, messageId: UUIDs from inbox/message responses. No body or query. Download the .eml file using the returned URL.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages/$MESSAGE_ID/raw" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "url": "https://download.example.invalid/signed-example",
  "expires_at": "2026-10-05T10:02:30.000Z"
}

Key errors: INBOX_NOT_FOUND · MESSAGE_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · STORAGE_UNAVAILABLE

GET /inboxes/{id}/messages/{messageId}/attachments/{attachmentId}Get an attachment download URL

Required scope: attachments:read

Parameters: id, messageId, attachmentId: UUIDs. Get attachmentId from the message detail. No body or query. size is bytes; content_type may be null. An inaccessible message returns MESSAGE_NOT_FOUND; an accessible message with a missing/wrong attachment returns ATTACHMENT_NOT_FOUND.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages/$MESSAGE_ID/attachments/$ATTACHMENT_ID" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "id": "33333333-3333-4333-8333-333333333333",
  "filename": "welcome.pdf",
  "content_type": "application/pdf",
  "size": 1024,
  "url": "https://download.example.invalid/signed-example",
  "expires_at": "2026-10-05T10:02:30.000Z"
}

Key errors: INBOX_NOT_FOUND · MESSAGE_NOT_FOUND · ATTACHMENT_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · STORAGE_UNAVAILABLE

Headers

GET /inboxes/{id}/messages/{messageId}/headersRead structured MIME headers

Required scope: messages:read

Parameters: id, messageId: UUIDs. No body or query. Order, original name casing and repeated headers are preserved; folded values are unfolded. RFC 2047 encoded words are not decoded. Header section limit: 128 KiB.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages/$MESSAGE_ID/headers" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "headers": [
    {
      "name": "Subject",
      "value": "Welcome"
    },
    {
      "name": "X-Test",
      "value": "first"
    },
    {
      "name": "X-Test",
      "value": "second"
    }
  ]
}

Key errors: INBOX_NOT_FOUND · MESSAGE_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · HEADERS_TOO_LARGE · INVALID_MIME_HEADERS · STORAGE_UNAVAILABLE

Extractions

GET /inboxes/{id}/messages/{messageId}/extractionsRead OTP candidates and links

Required scope: extractions:read

Parameters: id, messageId: UUIDs. No body or query. Deterministic standalone numeric candidates of 4–8 digits (max 20), not guaranteed OTPs. Absolute HTTP/HTTPS links from text and HTML (max 100); URLs are never fetched. source is text or html. Duplicates keep their first source.

Request example

curl --globoff --fail-with-body -sS -X GET "https://qaptools.com/api/email/v1/inboxes/$INBOX_ID/messages/$MESSAGE_ID/extractions" \
  -H "Authorization: Bearer $QAP_EMAIL_API_TOKEN"

Successful response · 200

{
  "otp_candidates": [
    {
      "value": "123456",
      "source": "text"
    }
  ],
  "links": [
    {
      "url": "https://app.example.invalid/verify?code=example",
      "source": "html"
    }
  ]
}

Key errors: INBOX_NOT_FOUND · MESSAGE_NOT_FOUND · INBOX_EXPIRED · INBOX_DELETED · BACKEND_UNAVAILABLE

Errors

Errors return a stable code, a safe message and a request_id. Keep the request ID when reporting a failure.

{
  "error": {
    "code": "ACTIVE_INBOX_LIMIT",
    "message": "The active inbox limit has been reached.",
    "request_id": "44444444-4444-4444-8444-444444444444"
  }
}
Errors
HTTPErrors / What to do
400
  • INVALID_REQUEST
  • INVALID_PREFIX
  • INVALID_TTL
  • IDEMPOTENCY_KEY_REQUIRED
  • INVALID_FILTER
  • INVALID_WAIT_TIMEOUT

Check the body, headers and supported parameters.

401
  • AUTHENTICATION_REQUIRED
  • INVALID_TOKEN
  • TOKEN_EXPIRED
  • TOKEN_REVOKED

Supply a valid, active Bearer token.

403
  • INSUFFICIENT_SCOPE

Use a token with the endpoint's required scope.

404
  • INBOX_NOT_FOUND
  • MESSAGE_NOT_FOUND
  • ATTACHMENT_NOT_FOUND

Check IDs and ownership. Message content may be unavailable or expired.

409
  • IDEMPOTENCY_CONFLICT

Retry the original input, or use a new key for a new inbox.

410
  • INBOX_EXPIRED
  • INBOX_DELETED

Create a new inbox; this inbox's content is no longer accessible.

413
  • HEADERS_TOO_LARGE

The MIME header section exceeds the supported size.

422
  • INVALID_MIME_HEADERS

The stored MIME headers cannot be parsed.

429
  • RATE_LIMIT_EXCEEDED
  • ACTIVE_INBOX_LIMIT
  • INBOX_MONTHLY_QUOTA_EXCEEDED
  • CONCURRENT_WAIT_LIMIT

Inspect Retry-After. Reduce requests/waits, free an active slot or wait for the monthly reset, as applicable.

503
  • BACKEND_UNAVAILABLE
  • ADMISSION_PENDING
  • STORAGE_UNAVAILABLE

Retry with backoff. Reuse the same key and input if retrying inbox creation.

Rate: 60 requests per rolling 60 seconds per account, shared by all tokens. One WAIT uses one external request slot. For HTTP 429, inspect Retry-After (seconds). For quota and active-inbox limits this is a backoff hint, not a promise that capacity will be available then.

ADMISSION_PENDING means received emails are still being prepared for reading. Retry with backoff; it is not a normal WAIT timeout or a partial successful response.