mobile
Receive inbound SMS with webhooks
Connect a MailSlurp number to an SMS webhook, verify callbacks, save incoming work before acknowledging it, and process replies without duplicate actions.
An SMS webhook tells your application when a phone number receives a text. A customer can reply while no browser test or polling loop is running; the HTTP callback gives your service a way to add that reply to a conversation, route it to support, or start another workflow.
The receiving service has two jobs: accept the notification reliably and finish the work it represents. If it returns success before saving the event, a restart can lose the reply. If it repeats a downstream action every time a callback arrives, one text can create several tickets.
MailSlurp provides phone numbers and an SMS API with phone-specific NEW_SMS webhooks. The following Node example registers an endpoint, verifies incoming notifications, and stores a job before acknowledging receipt. A separate worker retrieves the original SMS and applies your application's action.
Connect a phone number to your endpoint
Use a MailSlurp number assigned to your workflow and a reachable HTTPS endpoint, such as https://your-app.example/webhooks/sms. Keep the API key on your server. Install the client in your Node project:
npm install mailslurp-client
Save this registration helper as setup.ts and call it once when configuring the workflow, with your API key, phone-number entity ID, and callback URL:
import {
MailSlurp,
CreateWebhookOptionsEventNameEnum,
} from "mailslurp-client";
export async function registerSmsWebhook({
apiKey, phoneNumberId, url,
}: {
apiKey: string;
phoneNumberId: string;
url: string;
}) {
if (!apiKey || !phoneNumberId || new URL(url).protocol !== "https:") {
throw new Error("Provide an API key, assigned phone ID and HTTPS callback");
}
const mailslurp = new MailSlurp({ apiKey });
return mailslurp.webhookController.createWebhookForPhoneNumber({
phoneNumberId,
createWebhookOptions: {
eventName: CreateWebhookOptionsEventNameEnum.NEW_SMS,
url,
},
});
}
Keep the returned webhook's id in your receiver configuration alongside the phone-number ID. Reuse that registration for subsequent messages; creating another webhook on every application restart can produce multiple notifications for the same SMS.
This example uses MailSlurp's default JSON payload. If you configure a custom request-body template, preserve the fields validated below. A NEW_SMS callback reports an incoming message; it is not an outbound delivery-status notification.
Webhook or wait API?
Use a webhook when replies can arrive at any time. For one browser test that triggers an OTP and immediately consumes it, the SMS wait API keeps the trigger and assertion together in the test.
| Your task | Useful approach |
|---|---|
| Process every inbound customer reply | Phone-number webhook |
| Receive one OTP after a Cypress or Playwright action | SMS wait call |
| Investigate a missing reply | Message history and SMS retrieval API |
| Recover accepted work that failed later | Durable job retry, followed by SMS retrieval |
Build a dependable webhook receiver
Keep the MailSlurp message identity
The callback contains both a notification ID and an SMS ID. They serve different purposes:
| Field | Meaning |
|---|---|
messageId |
Identifies the webhook notification; compare it with x-message-id and deduplicate delivery with it. |
smsId |
Identifies the received SMS; pass it to getSmsMessage. |
phoneNumber |
Identifies the receiving phone-number entity. It is a UUID, not a telephone number. |
toNumber and fromNumber |
Contain the destination and sender telephone numbers. |
webhookId |
Identifies the webhook registration that sent the event. |
The NEW_SMS payload reference describes the full schema. Keep these IDs with your application's conversation or job ID so you can trace a reply back to its original message.
Acknowledge receipt before heavy work
Verify the callback and commit a pending job before returning HTTP 200. Slow parsing and customer-system updates can then run outside the request. MailSlurp documents at-least-once webhook delivery, so the receiver must also accept an exact repeat of an already-saved notification without inserting duplicate work.
Save this handler in receiver.ts. It accepts an Express-style request and response. saveJobOnce is your storage adapter: it must resolve only when the job is durably saved, or when that exact job already exists. A failed save must reject.
import type { MailSlurp, SmsDto } from "mailslurp-client";
export type SmsJob = {
messageId: string;
smsId: string;
phoneNumberId: string;
webhookId: string;
};
type Request = {
body: unknown;
get(name: string): string | undefined;
};
type Response = {
status(code: number): Response;
json(body: { accepted: boolean }): unknown;
};
export function createSmsWebhookHandler({
mailslurp, phoneNumberId, webhookId, saveJobOnce,
}: {
mailslurp: MailSlurp;
phoneNumberId: string;
webhookId: string;
saveJobOnce: (job: SmsJob) => Promise<void>;
}) {
return async (request: Request, response: Response) => {
const messageId = request.get("x-message-id");
const signature = request.get("x-signature");
const event = request.body as Record<string, unknown> | null;
if (!messageId || !signature) {
return response.status(401).json({ accepted: false });
}
if (!event || event.eventName !== "NEW_SMS" ||
event.messageId !== messageId ||
event.phoneNumber !== phoneNumberId ||
event.webhookId !== webhookId ||
typeof event.smsId !== "string" ||
!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(event.smsId)) {
return response.status(400).json({ accepted: false });
}
try {
const { isValid } =
await mailslurp.webhookController.verifyWebhookSignature({
verifyWebhookSignatureOptions: { messageId, signature },
});
if (!isValid) {
return response.status(401).json({ accepted: false });
}
await saveJobOnce({
messageId, smsId: event.smsId, phoneNumberId, webhookId,
});
return response.status(200).json({ accepted: true });
} catch {
return response.status(503).json({ accepted: false });
}
};
}
Mount the handler at the registered path after a bounded JSON parser, passing a configured MailSlurp client, the expected phone and webhook IDs, and your saveJobOnce function. For Express, the wiring is:
app.post(
"/webhooks/sms",
express.json({ limit: "32kb" }),
createSmsWebhookHandler({
mailslurp,
phoneNumberId,
webhookId,
saveJobOnce,
}),
);
The handler passes x-message-id and x-signature to MailSlurp's signature verification API. This verifies that pair; it is not a raw-body HMAC calculation. The routing checks also reject an unexpected event, webhook, phone, or mismatched header/body notification ID. Keep the API key in the configured server client, never in the public callback URL or browser.
Store pending jobs in a local SQLite file
For a small service with one worker, a database table can itself be the work queue. This sqlite-jobs.mjs example uses Node's node:sqlite module and a persistent file. Its transaction inserts one pending job, checks that an existing notification has the same routing fields, and commits before saveJobOnce resolves.
import { DatabaseSync } from "node:sqlite";
// A bounded, local single-worker example. Use a persistent file, not ":memory:".
// A production multi-worker queue also needs leases, recovery and retention.
export function openSmsJobs(path) {
if (path === ":memory:") throw new Error("Use a persistent database file");
const db = new DatabaseSync(path);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
CREATE TABLE IF NOT EXISTS sms_jobs (
message_id TEXT PRIMARY KEY,
sms_id TEXT NOT NULL,
phone_number_id TEXT NOT NULL,
webhook_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
);
`);
const insert = db.prepare(`
INSERT INTO sms_jobs(message_id, sms_id, phone_number_id, webhook_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(message_id) DO NOTHING
`);
const find = db.prepare("SELECT * FROM sms_jobs WHERE message_id = ?");
return {
db,
async saveJobOnce(job) {
db.exec("BEGIN IMMEDIATE");
try {
insert.run(job.messageId, job.smsId, job.phoneNumberId, job.webhookId);
const stored = find.get(job.messageId);
if (!stored || stored.sms_id !== job.smsId ||
stored.phone_number_id !== job.phoneNumberId ||
stored.webhook_id !== job.webhookId) {
throw new Error("Notification ID has conflicting routing fields");
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
},
find(messageId) { return find.get(messageId); },
close() { db.close(); },
};
}
Open the adapter once at application startup and pass its function to the receiver:
const jobs = openSmsJobs("./data/sms-jobs.sqlite");
const saveJobOnce = jobs.saveJobOnce;
Create the data directory first and keep the database on persistent storage. The worker reads pending rows from this table; there is no separate broker publication between saving and acknowledging. If your architecture uses a separate message broker, use a transactional outbox or equivalent design so a crash between the database write and publication cannot strand the job.
This adapter demonstrates durable admission for one local worker. Add worker claims, retry scheduling, failure handling, retention, and monitoring for your service before relying on it in production.
Make downstream work safe to repeat
A saved job is ready for processing, but it does not prove that a support reply or other application action succeeded. Retrieve the SMS by smsId, check that it belongs to the expected phone, and apply the action with a stable key. Add this worker function to receiver.ts:
export async function processSmsJob({
mailslurp, job, phoneNumberId, applyOnce,
}: {
mailslurp: MailSlurp;
job: SmsJob;
phoneNumberId: string;
applyOnce: (input: { idempotencyKey: string; sms: SmsDto }) => Promise<void>;
}) {
if (job.phoneNumberId !== phoneNumberId) {
throw new Error("Unexpected phone number");
}
const sms = await mailslurp.smsController.getSmsMessage({ smsId: job.smsId });
if (sms.id !== job.smsId || sms.phoneNumber !== phoneNumberId) {
throw new Error("SMS does not belong to this workflow");
}
await applyOnce({
idempotencyKey: `support-reply:${phoneNumberId}:${sms.id}`,
sms,
});
}
applyOnce is application code, not a MailSlurp method. For a database-backed conversation, enforce a unique action key in the same transaction that inserts the reply. For an external ticketing API, use its idempotency facility or reconcile its result before retrying. The example key includes the SMS ID so separate notifications about one SMS cannot create two support replies.
Mark a job complete only after processSmsJob succeeds. If retrieval or the application action fails, keep it pending for retry. The function uses the body returned by the authenticated SMS API rather than taking the callback's text as the final source of the reply.
Retrieving an SMS marks it as read. If a test also waits on this number, an unreadOnly filter can miss a message already retrieved by the worker. Use separate numbers for independent workflows, and base test isolation on the current attempt rather than read state.
Separate unknown senders from trusted workflows
A valid webhook establishes notification provenance, not permission for an SMS sender to change an account. Apply your normal sender checks, message validation, and account authorization before acting on the text. Phone-number rulesets can help filter senders, while your application decides which actions a reply is allowed to trigger.
The same pattern can append a customer reply to a conversation or advance an event-driven verification workflow. When combining email and SMS, retain the channel, original address or number, and source-message ID in the conversation record. A direct browser-controlled OTP test usually remains simpler with a wait call.
Test the webhook before release
Use a controlled number and a staging HTTPS receiver. Send an authorized test SMS and verify three separate results: MailSlurp stored the text, a callback reached the endpoint, and the pending job produced the expected application action.
Then repeat the same notification and confirm that it does not create another job or application result. Stop the worker after a job is accepted, restart it, and verify that the saved job remains available. Make retrieval or downstream processing fail, then check that recovery completes the action once rather than losing it or duplicating it.
A webhook test request is useful for checking endpoint reachability. It does not establish that a real text traversed the carrier path or that your application finished processing it. Use webhook testing and pipeline recovery when investigating those separate stages.
Troubleshooting
MailSlurp received the SMS but my application did nothing
Check the webhook's destination and delivery attempt, then the receiver response and job table. A 401 from this handler means signature data was missing or invalid. A 400 means the event or routing fields did not match. A 503 means verification or storage did not complete, so the handler did not acknowledge acceptance. If a pending job exists, investigate the worker separately.
The application created duplicate work
Check both deduplication boundaries. messageId prevents duplicate admission of one notification; the downstream action key prevents repeated effects for one SMS. Marking a job complete before the action succeeds, or recording an action only after an external call without its own idempotency support, leaves a failure window.
The webhook works in development but not staging
Verify the registered HTTPS URL, network access, route path, JSON parsing, and configured webhook and phone-number IDs. Check the server's API credentials without logging them. After fixing the endpoint, send a new controlled text and compare the stored SMS, delivery attempt, and application result.
FAQ
What is an SMS webhook?
It is an HTTP notification sent to your endpoint when a phone number receives a text. MailSlurp's NEW_SMS event includes an SMS ID that your server can use to retrieve the original message.
Do I still need the SMS API when I use webhooks?
Yes. In this receiver, the webhook admits work and the worker uses the API to retrieve and validate the SMS. The API and dashboard also help investigate messages whose application processing failed.
Can I use SMS webhooks for OTP tests?
Yes, particularly when the system under test processes messages asynchronously. For a Cypress or Playwright test that requests and enters one code, the SMS wait API usually makes the test easier to follow.