Two-Way Messaging Patterns
Two-way messaging means your application can both send messages to users and receive replies from them. Here's how to set it up and handle the common failure modes.
How inbound messages arrive
When a contact texts your number, Surge receives the message from the carrier and forwards it to your webhook endpoint as a message.received event.
{
"type": "message.received",
"data": {
"id": "msg_01jav96823f9x9054d6gyzpp16",
"blast_id": null,
"body": "Is the 3pm slot still available?",
"metadata": {},
"attachments": [],
"received_at": "2024-10-21T23:29:43Z",
"conversation": {
"id": "cnv_01jav8xy7fe4nsay3c9deqxge9",
"phone_number": {
"id": "pn_01jsjwe4d9fx3tpymgtg958d9w",
"number": "+18015556789",
"type": "local"
},
"contact": {
"id": "ctc_01ja88cboqffhswjx8zbak3ykk",
"first_name": "Dominic",
"last_name": "Toretto",
"phone_number": "+18015551234"
}
}
}
}The first time a contact messages your number, Surge also fires a conversation.created event. Use that event to initialize a conversation record in your system.
Reply in the same thread
Reply into the same conversation by passing the conversation ID as the conversation field in your outbound message. Surge routes the reply from the same number the contact previously reached.
@app.post("/webhooks/surge")
async def surge_webhook(request: Request):
payload = wh.verify(await request.body(), request.headers)
if payload["type"] == "message.received":
data = payload["data"]
conversation_id = data["conversation"]["id"]
# Send a reply into the same conversation thread
await surge_client.post(
f"/accounts/{YOUR_ACCOUNT_ID}/messages",
json={
"conversation": conversation_id,
"body": "Yes, 3pm is still available. Want me to book it?"
}
)
return Response(status_code=200)Handle opt-outs inline
When a contact replies STOP (or any recognized opt-out keyword), Surge fires contact.opted_out before the next inbound message arrives. Update your database and stop sending to that contact.
case "contact.opted_out":
contact = payload["data"]
await db.contacts.update(contact["id"], {"sms_opted_out": True})Surge automatically blocks sends to opted-out contacts — you'll get an opted_out error if you try. But it's still good practice to track the state in your own system so you don't attempt sends unnecessarily.
Common setup failures
No inbound messages arriving:
- Verify your webhook URL is saved in Settings → Webhooks in the dashboard
- Confirm the URL is publicly accessible (not localhost or behind a firewall)
- Check that your endpoint returns
2xxwithin the timeout — if it returns 4xx or 5xx, Surge retries but logs the failure - Look at the webhook delivery logs in the dashboard for error details
Inbound messages arriving but replies not delivering:
- Confirm the conversation ID you're replying to matches the one from the event
- Check that the phone number associated with the conversation is still active and attached to an approved campaign
Duplicate event delivery:
Surge retries failed webhook deliveries, which means your handler may receive the same event more than once. Make your handler idempotent: use the message ID as a deduplication key before processing.
if await db.processed_events.exists(message_id=data["id"]):
return Response(status_code=200) # already handled
await db.processed_events.insert(message_id=data["id"])
# ... process the messageHandling concurrent inbound messages
If your system creates records on inbound messages and a burst of messages arrives simultaneously, you may see duplicate conversation records. Use the conversation.id from the webhook event (not the contact phone number) as your primary key for deduplication — Surge guarantees a single conversation ID per thread.