Surge

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:

  1. Verify your webhook URL is saved in Settings → Webhooks in the dashboard
  2. Confirm the URL is publicly accessible (not localhost or behind a firewall)
  3. Check that your endpoint returns 2xx within the timeout — if it returns 4xx or 5xx, Surge retries but logs the failure
  4. Look at the webhook delivery logs in the dashboard for error details

Inbound messages arriving but replies not delivering:

  1. Confirm the conversation ID you're replying to matches the one from the event
  2. 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 message

Handling 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.