Configure an endpoint URL in your Texto dashboard and we'll POST a JSON event every time someone replies to one of your numbers — an MO (Mobile Originated) message. Signed with HMAC-SHA256, retried on failure, and idempotent by design.
In the Texto dashboard, go to Developer → Inbound Webhook, paste your endpoint URL, and enable the webhook.
Copy your signing secret. Store it as TEXTO_INBOUND_WEBHOOK_SECRET in your server environment — never commit it to source control.
Deploy an HTTPS endpoint that verifies the X-Texto-Signature header (snippet below) and returns a 2xx response within 15 seconds.
Fired once for every inbound message received on any of your numbers, including replies to team-member sends. Opt-out replies (STOP, UNSUBSCRIBE, etc.) still fire the webhook — is_optout tells you which they were, and the opt-out has already been recorded on your account.
<your endpoint URL>| Header | Value | Description |
|---|---|---|
| Content-Type | application/json | Body is always UTF-8 JSON. |
| X-Texto-Event | message.inbound | Event type. Currently always message.inbound. |
| X-Texto-Delivery | UUID | Unique per delivery attempt. Same UUID on retries — dedupe on this or on message_id. |
| X-Texto-Signature | sha256=<hex> | HMAC-SHA256 of the raw body using your signing secret. Only sent when signing is enabled. |
message_id is the stable identifier for the inbound message — use it as your idempotency key so duplicate deliveries (network hiccups, retries) don't double-process.
{
"event": "message.inbound",
"message_id": "8c1f9b2e-1a4c-4f87-9bd2-2d2f6f6f6f6f",
"from": "+61412345678",
"to": "+61480123456",
"body": "STOP",
"received_at": "2026-05-06T03:14:25.421Z",
"in_reply_to": "1d4e9b2e-1a4c-4f87-9bd2-2d2f6f6f6f6f",
"is_optout": true
}| Field | Type | Description |
|---|---|---|
| message_id | uuid | Stable ID of the inbound message. Use as your idempotency key. |
| from | string | The customer's phone number in E.164 format. |
| to | string | The Texto number that received the reply. |
| body | string | The message text as received. |
| received_at | ISO 8601 | When we received the message. |
| in_reply_to | uuid | null | If this looks like a reply to an outbound message, the ID of that message. |
| is_optout | boolean | True if we detected an opt-out keyword (STOP, UNSUBSCRIBE, etc.). We've already recorded the opt-out. |
Always verify the signature before trusting the payload. Use the raw request bytes (not a re-serialised JSON string) and a constant-time comparison.
import crypto from "node:crypto";
app.post("/webhooks/texto-inbound", express.raw({ type: "application/json" }), (req, res) => {
const signatureHeader = req.header("x-texto-signature") || "";
const expected = crypto
.createHmac("sha256", process.env.TEXTO_INBOUND_WEBHOOK_SECRET)
.update(req.body) // raw bytes — not JSON.stringify(parsed)
.digest("hex");
const provided = signatureHeader.replace(/^sha256=/, "");
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided, "hex"), Buffer.from(expected, "hex"));
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// Dedupe on event.message_id (stable across retries)
res.sendStatus(200);
});Up to 3 attempts in total — same schedule as delivery receipts. Reply with any 2xx within 15 seconds to acknowledge.
| Attempt | When |
|---|---|
| 1 | Immediately when the message is received |
| 2 | ~1 minute after attempt 1 fails |
| 3 | ~5 minutes after attempt 2 fails |
| — | After a further ~30 minutes the delivery is given up on |
Reply with any 2xx status code (typically 200 OK or 204 No Content) to acknowledge receipt. The response body is ignored — you don't need to return JSON. Respond within 15 seconds; longer than that and we treat it as a failure and retry.
| Your response | What we do |
|---|---|
| 2xx | Marked delivered. No retry. |
| 4xx | Treated as a failure and retried on the schedule above — fix your endpoint and the next attempt will succeed. |
| 5xx | Retried on the schedule above. |
| No response / timeout | Retried on the schedule above. |
Always dedupe on message_id in the body — it's stable per inbound message even across retries. The X-Texto-Delivery header is also stable across retries of the same delivery attempt.
Endpoint URLs must use HTTPS. Without signing enabled, anyone who learns your endpoint URL can forge events — leave HMAC signing on and rotate the secret any time from the dashboard.
Configure your inbound webhook in the Texto dashboard and you're live in minutes.