Route your app's SMS to a mock inbox in the test environment. In the collection, a pre-request script picks a unique phone number and stores the current time. The request that triggers the SMS uses that number, the next request reads the inbox and retries itself with pm.execution.setNextRequest until the code arrives, and the verify request sends it back to your API. Newman runs the same collection in CI.
Step 1: route SMS to the mock
Your API sends the SMS, not Postman. In the test or staging environment, point your provider's SDK at otpmock with one option. Each provider's option is on its provider page. Postman then only needs to call your API and the otpmock inbox.
Step 2: the inbox API
GET https://api.otpmock.com/v1/inbox/{phone}/code?since={unix_ms}
Authorization: Bearer {{OTPMOCK_API_KEY}}
200 {"code":"482913","messageSid":"SM…","body":"Your code is 482913","receivedAt":1791417652342}
404 {"error":"no_code_yet"}The phone number goes in the path, so URL-encode it: +15551234567 becomes %2B15551234567.
Step 3: collection variables
Keep the otpmock URL and key as collection variables (or in an environment) and set the collection's Authorization to Bearer Token with {{OTPMOCK_API_KEY}}. Requests to your own API can override auth per request. The per-run values (phone, since, attempts) are written by scripts with pm.collectionVariables.set, so they never leak between collections.
Step 4: trigger the SMS
On the request that starts verification (for example POST {{baseUrl}}/auth/phone/start with body {"phone":"{{phone}}"}), add a pre-request script:
const phone = '+1555' + Array.from({ length: 7 }, () => Math.floor(Math.random() * 10)).join('');
pm.collectionVariables.set('phone', phone);
pm.collectionVariables.set('phoneEncoded', encodeURIComponent(phone));
pm.collectionVariables.set('since', String(Date.now() - 5000)); // clock skew margin
pm.collectionVariables.set('attempts', '0');Step 5: poll with setNextRequest
Add a request named Wait for code: GET {{OTPMOCK_URL}}/v1/inbox/{{phoneEncoded}}/code?since={{since}}. Its post-response script reruns the same request while the inbox answers 404:
const attempts = Number(pm.collectionVariables.get('attempts')) + 1;
pm.collectionVariables.set('attempts', String(attempts));
if (pm.response.code === 404 && attempts < 50) {
pm.execution.setNextRequest('Wait for code'); // run this request again
return;
}
pm.test('code received', () => pm.response.to.have.status(200));
pm.collectionVariables.set('otp', pm.response.json().code);Add a short pause between attempts in its pre-request script. The sandbox waits for pending timers before sending the request:
if (Number(pm.collectionVariables.get('attempts')) > 0) {
setTimeout(() => {}, 300);
}The next request, POST {{baseUrl}}/auth/phone/verify with {"phone":"{{phone}}","code":"{{otp}}"}, finishes the flow. setNextRequest only takes effect in the Collection Runner and Newman, not when you click Send on a single request. Older collections use postman.setNextRequest; it still works, but pm.execution.setNextRequest is the current name.
Alternative: poll inside one script with pm.sendRequest
If you prefer a single request, poll from the verify request's pre-request script. pm.sendRequest returns a promise when called without a callback, and scripts support await:
const base = pm.collectionVariables.get('OTPMOCK_URL');
const url = `${base}/v1/inbox/${pm.collectionVariables.get('phoneEncoded')}/code?since=${pm.collectionVariables.get('since')}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 50; i++) {
const res = await pm.sendRequest({
url,
method: 'GET',
header: { Authorization: 'Bearer ' + pm.collectionVariables.get('OTPMOCK_API_KEY') },
});
if (res.code === 200) { pm.collectionVariables.set('otp', res.json().code); break; }
if (res.code !== 404) throw new Error('otpmock ' + res.code + ': ' + res.text());
await sleep(300);
}
if (!pm.collectionVariables.get('otp')) throw new Error('no code arrived');Clear otp in the trigger request's pre-request script so a value from an earlier run is never reused.
Running it in CI with Newman
Export the collection and pass secrets on the command line, so they never sit in the JSON file:
- name: OTP flow
run: |
npx newman run tests/otp.postman_collection.json \
--env-var "OTPMOCK_URL=https://api.otpmock.com" \
--env-var "OTPMOCK_API_KEY=${{ secrets.OTPMOCK_API_KEY }}" \
--env-var "baseUrl=https://staging.example.com"Environment variables take precedence over collection variables with the same name, so the defaults in the collection are overridden. Newman exits non-zero when any pm.test fails. If your staging API uses a private CA, export NODE_EXTRA_CA_CERTS=/path/to/ca.pem before running Newman rather than turning off SSL checks with --insecure.
Common pitfalls
- Unencoded
+in the path. Use the encoded variable in the inbox URL and the raw one in JSON bodies. - Endless loops. Always cap retries with a counter; a missing SMS should fail the run, not hang it.
- Setting
sincetoo late. Set it in the pre-request script of the triggering request, never after it. - Shared numbers across parallel Newman runs. Generate the number per run, never hardcode it.
FAQ
Does this work in the Postman desktop app?
Yes. Run the collection with the Collection Runner so setNextRequest is honoured. The pm.sendRequest variant also works when you click Send.
Can I test without a real backend?
Yes. Replace the trigger request with POST /v1/messages/send on otpmock, which records an SMS as if your app had sent it.
How many messages does a run use?
One per SMS your API sends. Polling the inbox is not counted as a message.
The free plan includes 100 messages a month. No card required.
Get a free API key