otpbox

OTPBox / Guides / Postman

Postman

Test email verification with Postman

Not every OTP flow is behind a browser form. When the thing under test is a signup API — create account, receive a code by email, call verify — there's no page for Playwright or Selenium to drive, and Postman is a natural fit. The catch: Postman's own tooling has no native "wait for this to arrive" primitive, so getting from "email sent" to "code received" takes a bit more assembly than in a real test framework.

The pattern

Four requests in a collection, run in order: mint a key, create an inbox and save its address, call your app's signup endpoint with that address, then retry a read of the inbox until a code shows up and call your app's verify endpoint with it.

Request 1: create the inbox

A plain POST to /api/v1/inboxes with your bearer key. In the request's Tests tab (runs after the response comes back), save the fields you'll need later into environment variables:

POST https://otpbox.io/api/v1/inboxes
Authorization: Bearer {{otpbox_key}}
Content-Type: application/json

{}

Tests tab:

const body = pm.response.json(); // { id, address, domain, expiresAt, token }
pm.environment.set("inbox_id", body.id);
pm.environment.set("inbox_address", body.address);
pm.environment.set("inbox_token", body.token);

pm.test("inbox created", function () {
  pm.response.to.have.status(201);
  pm.expect(body.address).to.be.a("string");
});

If you don't already have otpbox_key set as an environment variable, add a request before this one that calls POST /api/v1/keys/free (no auth, 200 requests/month, 5/day/IP) and saves key from the response the same way — see Get a key.

Request 2: call your app's signup endpoint

Whatever your app's own signup route looks like, use {{inbox_address}} as the email field:

POST https://your-app.example.com/api/signup
Content-Type: application/json

{ "email": "{{inbox_address}}", "password": "Testpass123!" }

Request 3: retry until the code arrives

This is the part Postman doesn't do natively. There's no built-in polling step in a collection — a request either runs once or it doesn't. The workaround is a Tests script that re-issues the same request with pm.sendRequest in a loop, using a counter stored in an environment variable so it gives up after a fixed number of tries:

// Tests tab on a GET {{otpbox_base}}/inboxes/{{inbox_id}}/messages request
const MAX_TRIES = 10;
const DELAY_MS = 1500;

let tries = parseInt(pm.environment.get("otp_tries") || "0", 10);
const body = pm.response.json();
const code = body.messages && body.messages[0] && body.messages[0].code;

if (code) {
  pm.environment.set("otp_code", code);
  pm.environment.set("otp_tries", "0");
  pm.test("OTP received", function () { pm.expect(code).to.be.a("string"); });
} else if (tries < MAX_TRIES) {
  pm.environment.set("otp_tries", String(tries + 1));
  setTimeout(function () {
    pm.execution.setNextRequest(pm.info.requestName); // re-run this same request
  }, DELAY_MS);
} else {
  pm.environment.set("otp_tries", "0");
  pm.test("OTP received before max retries", function () { pm.expect.fail("no code after " + MAX_TRIES + " tries"); });
}

The request itself is a plain authenticated GET:

GET https://otpbox.io/api/v1/inboxes/{{inbox_id}}/messages
Authorization: Bearer {{otpbox_key}}
Be honest about this pattern's limits. pm.execution.setNextRequest re-running a request from inside its own Tests script works in the Postman app and in Newman, but it's a workaround, not a designed feature — it's easy to get wrong (infinite loops if the counter reset logic has a bug, awkward interaction with collection-level test reports), and it doesn't compose well with parallel test runs. If your team already has Playwright, Cypress, or a Python/Node test runner in the loop, a real while/retry loop against the same two REST endpoints (see the Playwright guide) is simpler to read and debug. Postman is genuinely good at the "hit this endpoint, assert the shape" parts of this flow; the waiting part is where it's weaker than a real test framework.

Request 4: call your app's verify endpoint

POST https://your-app.example.com/api/verify
Content-Type: application/json

{ "email": "{{inbox_address}}", "code": "{{otp_code}}" }

Tests tab:

pm.test("verification succeeded", function () {
  pm.response.to.have.status(200);
});

Cleanup

Add a final request to delete the inbox once the collection run finishes, so nothing is left orphaned (the 15-minute expiry cron would clean it up regardless, but an explicit delete is cheap and keeps a CI dashboard tidy):

DELETE https://otpbox.io/api/v1/inboxes/{{inbox_id}}
Authorization: Bearer {{otpbox_key}}

Running it via Newman in CI

This whole pattern is far more common run headlessly through Newman in CI than clicked through in the Postman GUI — a GUI run is fine for building and debugging the collection, but a CI job wants a command that exits non-zero on failure:

npm install -g newman
newman run otp-signup.postman_collection.json \
  --environment otp-signup.postman_environment.json \
  --env-var "otpbox_key=$OTPBOX_KEY"

Store OTPBOX_KEY as a CI secret and pass it in with --env-var rather than committing it into the environment JSON file — see CI/CD for the general secret-injection pattern across GitHub Actions, GitLab CI, Jenkins and CircleCI. If Newman's retry-via-script timing feels fragile in CI (network jitter can push a run past its retry budget), an alternative is a thin CI-level wrapper: run Newman for requests 1–2, poll GET /api/v1/inboxes/:id/messages from a small shell or Node script in the CI job itself until a code appears, export it as an env var, then run a second Newman invocation for requests 3–4. That trades one script for a more predictable wait than a Postman Tests-tab loop can guarantee.

Next steps

Ready to try it against your own app? Create a free account

← Back to OTPBox