Inbound Webhook Reference

    Inbound Message Webhook

    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.

    Getting started with inbound SMS webhooks

    1. 1

      In the Texto dashboard, go to Developer → Inbound Webhook, paste your endpoint URL, and enable the webhook.

    2. 2

      Copy your signing secret. Store it as TEXTO_INBOUND_WEBHOOK_SECRET in your server environment — never commit it to source control.

    3. 3

      Deploy an HTTPS endpoint that verifies the X-Texto-Signature header (snippet below) and returns a 2xx response within 15 seconds.

    Trigger

    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.

    Request

    POST
    <your endpoint URL>

    Headers

    HeaderValueDescription
    Content-Typeapplication/jsonBody is always UTF-8 JSON.
    X-Texto-Eventmessage.inboundEvent type. Currently always message.inbound.
    X-Texto-DeliveryUUIDUnique per delivery attempt. Same UUID on retries — dedupe on this or on message_id.
    X-Texto-Signaturesha256=<hex>HMAC-SHA256 of the raw body using your signing secret. Only sent when signing is enabled.

    Body

    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
    }
    FieldTypeDescription
    message_iduuidStable ID of the inbound message. Use as your idempotency key.
    fromstringThe customer's phone number in E.164 format.
    tostringThe Texto number that received the reply.
    bodystringThe message text as received.
    received_atISO 8601When we received the message.
    in_reply_touuid | nullIf this looks like a reply to an outbound message, the ID of that message.
    is_optoutbooleanTrue if we detected an opt-out keyword (STOP, UNSUBSCRIBE, etc.). We've already recorded the opt-out.

    Verifying the signature (Node.js)

    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);
    });

    Retry schedule

    Up to 3 attempts in total — same schedule as delivery receipts. Reply with any 2xx within 15 seconds to acknowledge.

    AttemptWhen
    1Immediately 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

    Expected response

    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 responseWhat we do
    2xxMarked delivered. No retry.
    4xxTreated as a failure and retried on the schedule above — fix your endpoint and the next attempt will succeed.
    5xxRetried on the schedule above.
    No response / timeoutRetried on the schedule above.

    Idempotency

    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.

    Security

    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.

    Start receiving inbound SMS replies

    Configure your inbound webhook in the Texto dashboard and you're live in minutes.