Surge

Webhook Events

Surge sends 13 event types. All events arrive at your configured webhook endpoint as HTTP POST requests with a JSON body. Voice events (call.ended, recording.completed, voicemail.received) are flag-gated and only sent to accounts with voice enabled.

See Receiving Messages & Webhooks for endpoint configuration and signature verification.

Event structure

Every event has the same top-level shape:

{
  "type": "message.received",
  "data": { ... }
}

The type field tells you what happened. The data field contains the resource that changed.


Message events

message.sent

Fires when an outbound message is accepted by the carrier network. Includes sent_at (ISO 8601 timestamp), the full conversation thread, and any attachments.

{
  "type": "message.sent",
  "data": {
    "id": "msg_01jjnn7s0zfx5tdcsxjfy93et2",
    "blast_id": null,
    "body": "Your order has shipped.",
    "metadata": {},
    "attachments": [],
    "sent_at": "2024-10-21T23:29:41Z",
    "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"
      }
    }
  }
}

message.delivered

Fires when the carrier confirms delivery to the handset. Not all carriers send delivery receipts — if you don't receive this event, it doesn't necessarily mean delivery failed.

{
  "type": "message.delivered",
  "data": {
    "id": "msg_01jjnn7s0zfx5tdcsxjfy93et2",
    "blast_id": null,
    "body": "Your order has shipped.",
    "metadata": {},
    "attachments": [],
    "delivered_at": "2024-10-21T23:29:42Z",
    "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"
      }
    }
  }
}

message.failed

Fires when a message cannot be delivered. The failure_reason field explains why.

{
  "type": "message.failed",
  "data": {
    "id": "msg_01jjnn7s0zfx5tdcsxjfy93et2",
    "blast_id": null,
    "body": "Your order has shipped.",
    "metadata": {},
    "attachments": [],
    "failed_at": "2024-10-21T23:29:42Z",
    "failure_reason": "carrier_error",
    "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"
      }
    }
  }
}

message.received

Fires when an inbound message arrives on one of your numbers. This is how you implement two-way messaging — your handler receives the conversation.id and the body, and you can reply by sending a message to the same conversation.

{
  "type": "message.received",
  "data": {
    "id": "msg_01jav96823f9x9054d6gyzpp16",
    "blast_id": null,
    "body": "What are your hours?",
    "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"
      }
    }
  }
}

Contact events

contact.opted_in

Fires when a contact sends a recognized opt-in keyword. Surge recognises these exact words (case-insensitive):

KeywordNotes
STARTStandard re-opt-in keyword
YESCommon confirmation keyword
UNSTOPUsed to re-subscribe after a STOP
{
  "type": "contact.opted_in",
  "data": {
    "id": "ctc_01ja88cboqffhswjx8zbak3ykk"
  }
}

When this event fires, the conversation status returns to active and you can send to the contact again.

contact.opted_out

Fires when a contact sends a recognized opt-out keyword. Surge recognises these exact words (case-insensitive):

KeywordNotes
STOPStandard carrier opt-out keyword
STOPALLVariant of STOP
UNSUBSCRIBECommon unsubscribe keyword
CANCELAlternative opt-out keyword
ENDAlternative opt-out keyword
QUITAlternative opt-out keyword
OPTOUTAlternative opt-out keyword
REVOKEAlternative opt-out keyword

Once opted out, sending to this contact returns an opted_out error. Do not attempt to re-subscribe contacts without their explicit new consent — wait for them to send an opt-in keyword or otherwise affirmatively re-consent.

{
  "type": "contact.opted_out",
  "data": {
    "id": "ctc_01ja88cboqffhswjx8zbak3ykk"
  }
}

Opt-out state is per-conversation, not per-contact. A contact can opt out of messages from one of your numbers while remaining active on another. This matters for multi-number accounts: a contact who texts STOP to +18015556789 can still receive messages from +18025557890 if they haven't opted out of that thread.


Conversation events

conversation.created

Fires when a new conversation thread starts — either when a contact sends you their first message, or when you initiate a conversation with a contact for the first time. The event includes the phone number and contact on the new thread.

{
  "type": "conversation.created",
  "data": {
    "id": "cnv_01jav8xy7fe4nsay3c9deqxge9",
    "phone_number": {
      "id": "pn_01jsjwe4d9fx3tpymgtg958d9w",
      "number": "+18015556789",
      "type": "local",
      "campaign_id": null
    },
    "contact": {
      "id": "ctc_01ja88cboqffhswjx8zbak3ykk",
      "first_name": "Dominic",
      "last_name": "Toretto",
      "phone_number": "+18015551234"
    }
  }
}

Campaign events

campaign.approved

Fires when a campaign's status changes to active — meaning it's been approved by the US carriers and is cleared for production sending. Surge automatically attaches any existing phone numbers on the account to the newly active campaign.

{
  "type": "campaign.approved",
  "data": {
    "id": "cpn_01jrzhe8d9enptypyx360pcmxl",
    "status": "active"
  }
}

Phone number events

phone_number.attached_to_campaign

Fires when a phone number is successfully attached to a campaign. Once you receive this event, the number is ready for production traffic.

{
  "type": "phone_number.attached_to_campaign",
  "data": {
    "id": "pn_01jrzhe8d9enptypyx360pcmxm",
    "number": "+18015559876",
    "campaign_id": "cpn_01jrzhe8d9enptypyx360pcmxl"
  }
}

link.followed

Fires when a recipient clicks a tracked link in a message. Only fires on the first click — subsequent clicks on the same link by the same contact don't trigger additional events.

Link tracking requires link shortening to be enabled on your account. When enabled, Surge replaces the original URLs in your messages with short links at Surge's link-shortener domain. When a recipient clicks one of these links, Surge fires the link.followed event and then redirects them to the original URL. Contact support to enable link shortening.

Note: If link shortening is not enabled on your account, no link.followed events will fire, even if your messages contain URLs.

{
  "type": "link.followed",
  "data": {
    "id": "lnk_01kedctzhxexdbr5xf2bht5q84",
    "message_id": "msg_01jjnn7s0zfx5tdcsxjfy93et2",
    "message": {
      "id": "msg_01jjnn7s0zfx5tdcsxjfy93et2",
      "blast_id": null,
      "body": "Track your order: https://acme.com/track/12345",
      "metadata": {},
      "attachments": [],
      "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"
        }
      }
    },
    "url": "https://acme.com/track/12345"
  }
}

The message_id top-level field is deprecated. Use message.id instead. See Deprecation Notices.


Voice events (flag-gated)

These events are only sent to accounts with voice enabled.

call.ended

Fires when a voice call completes.

{
  "type": "call.ended",
  "data": {
    "id": "call_01jjnn7s0zfx5tdcsxjfy93et2",
    "contact": {
      "id": "ctc_01ja88cboqffhswjx8zbak3ykk",
      "first_name": "Dominic",
      "last_name": "Toretto",
      "phone_number": "+18015551234"
    },
    "duration": 142,
    "initiated_at": "2025-03-31T21:01:37Z",
    "status": "completed"
  }
}

recording.completed

Fires when a call recording is processed and ready for download.

{
  "type": "recording.completed",
  "data": {
    "id": "rec_01kfyc9dgdec1avkgs7tng8htg",
    "duration": 124,
    "call": {
      "id": "call_01jjnn7s0zfx5tdcsxjfy93et2",
      "contact": {
        "id": "ctc_01ja88cboqffhswjx8zbak3ykk",
        "first_name": "Dominic",
        "last_name": "Toretto",
        "phone_number": "+18015551234"
      },
      "duration": 184,
      "initiated_at": "2025-03-31T21:01:37Z",
      "status": "completed"
    }
  }
}

voicemail.received

Fires when a voicemail is left on one of your numbers and the recording is ready.

{
  "type": "voicemail.received",
  "data": {
    "id": "vml_01kfycac7rery81axwmpzcgpp8",
    "recording_id": "rec_01kfyc9dgdec1avkgs7tng8htg",
    "duration": 124,
    "call": {
      "id": "call_01jjnn7s0zfx5tdcsxjfy93et2",
      "contact": {
        "id": "ctc_01ja88cboqffhswjx8zbak3ykk",
        "first_name": "Dominic",
        "last_name": "Toretto",
        "phone_number": "+18015551234"
      },
      "duration": 184,
      "initiated_at": "2025-03-31T21:01:37Z",
      "status": "completed"
    }
  }
}