Sending a text from your app takes one POST request. Receiving texts is more work. Replies arrive whenever the customer sends them, sometimes twice, sometimes out of order, and sometimes the reply is "STOP," which carries legal weight. If your platform texts on behalf of your own customers, every inbound message also has to reach the right tenant, the right thread and the right person.
This post covers the inbound side for developers: how to set up the webhook, how to verify it, how to parse keywords without rebuilding opt-out logic, and how to route replies in a multi-tenant app. We make Ready, so the Ready-specific parts are grounded in what our API does. The general patterns apply to any provider.
How inbound SMS reaches your app
The flow is the same on every serious messaging API:
- A contact texts one of your numbers.
- The carrier delivers it to the provider.
- The provider sends an HTTP POST to a URL you registered: your webhook.
- Your endpoint acknowledges it, and your app does something useful with it.
The exact webhook payload, event names and signature header are listed in the webhooks section of our docs at docs.tryready.com. We're not reproducing field names here because the docs are the source of truth, and code copied from a blog post goes stale. The patterns below don't depend on field names.
Setting up the webhook endpoint
Acknowledge fast, process later
The most common inbound bug is doing real work inside the request handler. You look up the contact, call your CRM, run a database migration someone forgot about, and the handler takes eight seconds. The provider times out, treats the delivery as failed, and retries. Now you have two copies of the same reply.
The pattern that holds up:
- Verify the signature (next section).
- Write the raw payload to a queue or an
inbound_messagestable. - Return a 2xx immediately.
- Let a worker do the parsing, routing and side effects.
Make processing idempotent
Retries happen even when your endpoint is fast, because networks fail. Treat every webhook as "at least once" delivery. Store the provider's unique message identifier with a unique constraint, and skip anything you've already processed. This is the inbound version of the Idempotency-Key header you send on outbound requests. Our post on stopping duplicate texts covers the outbound side.
Reconcile, don't trust the pipe blindly
Webhooks can be missed during a deploy or an outage. Run a reconciliation job that pages through GET /v1/messages with before_id and backfills anything your table doesn't have. Hourly is plenty for most apps. It means a bad deploy costs you some latency instead of lost customer replies.
Verifying webhook signatures
An unauthenticated webhook URL is an open door. Anyone who finds it can post fake "replies" into your customers' inboxes, or fake opt-outs. Signature verification closes it.
The general shape of HMAC-style verification, which is what most providers use:
- Use the raw request body. Compute the signature over the exact bytes you received, before any JSON parsing. Re-serialized JSON rarely matches byte for byte, so verifying after parsing is the classic reason checks fail on valid requests.
- Compare in constant time. Use your language's timing-safe comparison (
crypto.timingSafeEqualin Node,hmac.compare_digestin Python), not==. - Check the timestamp if the scheme includes one. Reject anything older than a few minutes to blunt replay attacks.
- Keep the secret out of source control. Rotate it like any other credential.
Follow the exact header names and algorithm in our docs. If verification fails, return a 4xx and log it. Don't silently accept the request.
Keyword parsing: what's yours and what isn't
STOP is already handled, so don't rebuild it
On Ready, inbound STOP and UNSUBSCRIBE are honored automatically. The opt-out propagates across the account, so that contact can't be texted again from any campaign. If your code later tries to send to an opted-out number, the API refuses it up front and doesn't charge for it.
Your job is to mirror that state, not enforce it. Keep your own UI accurate so your customers don't keep queuing texts that will be refused:
curl https://api.tryready.com/v1/opt-outs/+12125550199 \
-H "X-API-Key: $READY_API_KEY" Use GET /v1/opt-outs for a scheduled sync, for example in a nightly job that flags contacts in your database. That list holds only this account's opted-out contacts, so check GET /v1/opt-outs/:phone before another tool texts a number. Our SMS opt-out API post goes deeper on this, including why a second opt-out system inside your app creates more risk than it removes.
Your own keywords
Ready itself acts on three keyword sets: opt-out words, opt-in words and HELP. An opt-in word from a contact who had opted out opts them back in, so don't reuse one for your own meaning. Pick confirm words outside the opt-in list (the table below uses C and CONFIRM). Everything else is application logic, and it's yours to parse:
| Inbound text | Typical meaning | What your worker does |
|---|---|---|
| "C" / "CONFIRM" | Appointment confirmation | Mark the appointment confirmed, notify staff |
| "R" / "RESCHEDULE" | Wants a new time | Open a task, hand off to a human |
| A number ("2") | Answer to a menu | Map it to the option you sent |
| Anything else | Free-text reply | Put it in the conversation thread for a person |
A few rules that save pain later:
- Normalize before matching. Trim whitespace, uppercase, and strip trailing punctuation, so "yes!" and " Yes" both match.
- Match whole messages, not substrings. "Yes but can we do Tuesday" is not a confirmation. Route anything that isn't an exact keyword to a human.
- Scope keywords to context. "1" means something only if you just sent a menu to that contact. Store what you asked and when, and expire it.
- A keyword is not a consent record. A reply doesn't create an opt-in record on Ready. Our attestation log records who sent, to how many, and when, per bulk or API send. It isn't a per-contact consent store. If a reply is part of your opt-in flow, your app keeps the timestamp, the wording the contact saw and where they saw it.
Routing replies in a multi-tenant app
If your platform texts for its own customers, give each one a Ready sub-account with its own numbers and its own 10DLC registration. That makes routing simple, because the receiving number tells you which tenant the message belongs to.
Number → sub-account → tenant
Because each of your customers has a Ready sub-account with its own numbers, the receiving number maps to exactly one tenant. Keep a numbers table synced from GET /v1/numbers (per sub-account), keyed on the E.164 number. When an inbound message arrives, look up the receiving number, find the tenant, and you're done. You don't have to guess from message content.
Contact + number = thread
Inside a tenant, thread on the pair (contact phone, your number). That matters if a tenant has several numbers. With local presence on, Ready sends from the number closest to the contact's area code, and a contact keeps the number it already texts with. Threads stay stable because the contact always sees the same number. Every one of those numbers still sits on the tenant's registered 10DLC campaign, so local presence chooses among registered numbers rather than getting around registration.
Sending the reply
When an agent or your automation replies, send from the number the contact texted, so the conversation stays in one thread on their phone:
curl -X POST https://api.tryready.com/v1/messages \
-H "X-API-Key: $READY_API_KEY" \
-H "Idempotency-Key: 6f1c2b8e-3d4a-4f5e-9b7c-1a2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{"to": "+12125550199", "from": "+13125550123", "body": "Thanks, you are confirmed for Tuesday at 10 AM.", "consent_attested": true}' Two behaviors are worth designing around:
- Quiet hours. An after-hours auto-reply that would land during the recipient's quiet hours is skipped, not held. A text from an automation or drip is held for up to 24 hours and sent when sending opens, and you're charged only when it sends. Don't build a "reply failed" alert that fires on held messages.
- No new AI auto-replies. Ready's AI helps write and check messages. New accounts can't create an AI agent that answers inbound texts on its own. An account that set up an AI agent before agents were retired still has it, and an agent in auto mode answers texts on its own until you pause its automation in Automations. If you want automated replies, they're your keyword logic, and you own their wording.
If you use our blue line (iMessage), replies, reactions and typing indicators land in the same inbox thread as SMS. Our post on one thread for blue and green texts covers that setup. GoHighLevel users get two-way sync, with inbound messages mapped to the matching GHL location, so they don't need to build any of this.
What inbound costs
Replies are billed like sent texts on Ready: $0.0075 per segment on Standard, plus a $0.0045 carrier fee per segment. Here's a worked example with stated assumptions. Suppose a tenant gets 2,000 replies a month, averaging 1.2 segments each because some people write long or use emoji:
- 2,000 × 1.2 = 2,400 segments
- Rate portion: 2,400 × $0.0075 = $18
- Carrier portion: 2,400 × $0.0045
- All-in = $48 + (2,400 × $0.0045). That excludes the compliance surcharge: scrubbing and landline validation each add $0.003 per segment if switched on, and both are off by default.
Blue line replies are priced differently, at 6 cents a segment, and reactions are free. If your customers pay you per message, pass inbound through in your pricing. Some SMS providers don't bill inbound, or bill it at different rates, so check this when comparing quotes. Our SMS API pricing breakdown walks through it.
A practical checklist
Before you ship inbound, confirm you have:
- A webhook endpoint that verifies signatures over the raw body and returns 2xx in well under a second
- A queue or table between receipt and processing
- Dedupe on the provider's message identifier
- A reconciliation job against
GET /v1/messages - A number → tenant map synced from
GET /v1/numbers - Opt-out state read from
GET /v1/opt-outs/:phone, not re-implemented - Keyword matching that's normalized, whole-message and scoped to what you asked
- Your own consent records for any opt-in that happens by reply
Wrapping up
Inbound SMS is mostly ordinary webhook engineering: verify, enqueue, dedupe, reconcile. The SMS-specific parts are opt-outs and routing. Let the platform enforce STOP while your app mirrors it, and if you text for your own customers, use sub-accounts so the receiving number tells you which tenant a message belongs to.
If you want to try it, the quickstart and messages API reference cover both directions, and the two-way SMS API post covers the conversation model. You can sign up and use the first 20 free one-to-one texts to test your webhook end to end before you register.