# Authentication Source: https://docs.surge.app/api-reference/authentication Create and manage API keys, authenticate with a Bearer token, scope account requests, and view API logs. Every Surge API request requires an API key. Pass it as a Bearer token. Keys are scoped to a project, and account-specific resources are addressed by the account ID in the request path. ## Create and manage API keys All requests to the Surge API require an API key. You create and manage keys in the dashboard under the **API Keys** section. There are two key types: | Key prefix | Type | Use | | ------------- | ---- | ----------------------------------------------- | | `sk_live_...` | Live | Production API calls | | `sk_demo_...` | Demo | Basic evaluation against the shared demo number | Demo keys are limited to sends through the shared demo number. They have no other capabilities and shouldn't be used beyond basic evaluation of Surge. Surge is likely to deprecate demo keys in the future in favor of sandboxes. ## Authenticate with a Bearer token Pass your API key as a bearer token in the `Authorization` header: ```http theme={null} Authorization: Bearer sk_live_your_api_key_here ``` All requests must use HTTPS. Plaintext HTTP requests are rejected. ## Scope requests to an account API keys are scoped to a **project**: they can access all accounts under that project. Account-specific resources are scoped by the account ID in the request path: ``` GET /accounts/acct_01jrzhe8d9enptypyx360pcmxj/messages POST /accounts/acct_01jrzhe8d9enptypyx360pcmxj/messages ``` A single project API key can create and manage multiple accounts, send messages from any of them, and read data across all of them. ## Authentication errors | Error | HTTP status | Cause | | ----------------- | ----------- | ---------------------------------------------------- | | `missing_api_key` | 401 | No `Authorization` header | | `invalid_api_key` | 401 | Key not found or revoked | | `demo_api_key` | 403 | Demo key used for an action that requires a live key | | `platform_locked` | 403 | Your project has been locked | ## View API request logs All authenticated API requests are logged. You can view them in the dashboard under the **API Logs** section. Logs include the method, path, response status, and response time for each request. # Archive account Source: https://docs.surge.app/api-reference/endpoint/accounts/archive DELETE /accounts/{id} Archive an account, preventing further sends. Archived accounts cannot be restored via the API. # Check account status Source: https://docs.surge.app/api-reference/endpoint/accounts/check-status GET /accounts/{account_id}/status Check an account's registration status and see which fields are still needed for the requested capabilities. # Create account Source: https://docs.surge.app/api-reference/endpoint/accounts/create POST /accounts Create a new account under your project. Submit organization details to begin the carrier registration process. # Retrieve account Source: https://docs.surge.app/api-reference/endpoint/accounts/get GET /accounts/{id} Retrieve a single account by ID. # List accounts Source: https://docs.surge.app/api-reference/endpoint/accounts/list GET /accounts Return all accounts under your project. Useful for syncing the accounts you manage and inspecting their carrier registration status. # Update account Source: https://docs.surge.app/api-reference/endpoint/accounts/update PATCH /accounts/{id} Update organization details on an existing account to fill gaps identified by the status check. # Retrieve attachment file Source: https://docs.surge.app/api-reference/endpoint/attachments/get-file GET /attachments/{attachment_id}/file Download the media file for an MMS attachment. Returns a redirect to a short-lived signed URL. # Add contact to audience Source: https://docs.surge.app/api-reference/endpoint/audiences/add-contact POST /audiences/{audience_id}/contacts Add a contact to an audience. # Create audience Source: https://docs.surge.app/api-reference/endpoint/audiences/create POST /accounts/{account_id}/audiences Create a named audience (contact group) that you can reuse across multiple blast sends. # List audience contacts Source: https://docs.surge.app/api-reference/endpoint/audiences/list-contacts GET /audiences/{audience_id}/contacts List all contacts in an audience with cursor-based pagination. # Create blast Source: https://docs.surge.app/api-reference/endpoint/blasts/create POST /accounts/{account_id}/blasts Send a message to a list of recipients or an audience in a single API call. # Create campaign Source: https://docs.surge.app/api-reference/endpoint/campaigns/create POST /accounts/{account_id}/campaigns Register a new A2P 10DLC campaign with The Campaign Registry under an account. # Retrieve campaign Source: https://docs.surge.app/api-reference/endpoint/campaigns/get GET /campaigns/{id} Retrieve a single campaign by ID, including its current registration status. # List campaigns Source: https://docs.surge.app/api-reference/endpoint/campaigns/list GET /accounts/{account_id}/campaigns List all campaigns registered to an account with cursor-based pagination. # Resend brand verification OTP Source: https://docs.surge.app/api-reference/endpoint/campaigns/resend-brand-verification-message POST /accounts/{account_id}/brand_verification_messages Enqueues an SMS OTP for a sole proprietor brand awaiting identity verification. # Update campaign Source: https://docs.surge.app/api-reference/endpoint/campaigns/update PATCH /campaigns/{id} Update campaign details to address changes-needed feedback from carrier review. # Create contact Source: https://docs.surge.app/api-reference/endpoint/contacts/create POST /accounts/{account_id}/contacts Create a contact record identified by phone number under an account. # Retrieve contact Source: https://docs.surge.app/api-reference/endpoint/contacts/get GET /contacts/{id} Retrieve a single contact by ID, including opt-out status and metadata. # List contacts Source: https://docs.surge.app/api-reference/endpoint/contacts/list GET /accounts/{account_id}/contacts List all contacts for an account with cursor-based pagination. # Update contact Source: https://docs.surge.app/api-reference/endpoint/contacts/update PATCH /contacts/{id} Update contact details such as name, email, or custom metadata. # Send message Source: https://docs.surge.app/api-reference/endpoint/messages/create POST /accounts/{account_id}/messages Queue an SMS or MMS message from a phone number to be sent to a recipient, asynchronously. # Retrieve message Source: https://docs.surge.app/api-reference/endpoint/messages/get GET /messages/{id} Retrieve a single message by ID, including its current delivery status. # List messages Source: https://docs.surge.app/api-reference/endpoint/messages/list GET /accounts/{account_id}/messages List messages for an account with cursor-based pagination and optional conversation filtering. # Retrieve phone number Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/get GET /phone_numbers/{id} Retrieves a PhoneNumber object. # List phone numbers Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/list GET /accounts/{account_id}/phone_numbers List all phone numbers provisioned under an account. # List available numbers Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/list-available-numbers GET /accounts/{account_id}/available_phone_numbers Browse purchasable phone numbers from Surge inventory before buying. Pagination cursors are always null for now. # Purchase phone number Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/purchase POST /accounts/{account_id}/phone_numbers Purchase a local or toll-free phone number and assign it to an account. # Release phone number Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/release DELETE /phone_numbers/{id} Releases a phone number from the account. # Update phone number Source: https://docs.surge.app/api-reference/endpoint/phone-numbers/update PATCH /phone_numbers/{id} Updates a phone number's details. # Delete recording Source: https://docs.surge.app/api-reference/endpoint/recordings/delete DELETE /recordings/{id} Permanently delete a call recording and its associated audio file from storage. # Retrieve recording Source: https://docs.surge.app/api-reference/endpoint/recordings/get GET /recordings/{id} Retrieve a single call recording by ID, including its processing status. # Download recording file Source: https://docs.surge.app/api-reference/endpoint/recordings/get-file GET /recordings/{recording_id}/file Download the audio file for a call recording. Returns a redirect to a short-lived signed URL. The response is a `302` redirect to a short-lived signed URL. Follow the redirect immediately — the signed URL expires quickly. The file is a WAV audio recording. The signed URL serves it with `Content-Type: audio/wav`, and the object key ends in `.wav`. If you save the file locally, use a `.wav` extension. # List recordings Source: https://docs.surge.app/api-reference/endpoint/recordings/list GET /accounts/{account_id}/recordings List call recordings for an account. # Create user Source: https://docs.surge.app/api-reference/endpoint/users/create POST /accounts/{account_id}/users Create a user record under an account. Users are shown inside embeddable UI components. # Create user token Source: https://docs.surge.app/api-reference/endpoint/users/create-token POST /users/{user_id}/tokens Create a short-lived JWT for a user to authenticate with Surge's embeddable UI components. # Delete user Source: https://docs.surge.app/api-reference/endpoint/users/delete DELETE /users/{id} Permanently delete a user record from an account. # Retrieve user Source: https://docs.surge.app/api-reference/endpoint/users/get GET /users/{id} Retrieve a single user by ID. # List users Source: https://docs.surge.app/api-reference/endpoint/users/list GET /accounts/{account_id}/users List all users provisioned under an account with cursor-based pagination. # Update user Source: https://docs.surge.app/api-reference/endpoint/users/update PATCH /users/{id} Update a user's name or custom metadata. # Check verification Source: https://docs.surge.app/api-reference/endpoint/verifications/check POST /verifications/{id}/checks Submit the OTP a user received to complete phone number verification. # Create verification Source: https://docs.surge.app/api-reference/endpoint/verifications/create POST /accounts/{account_id}/verifications Send a one-time passcode (OTP) to a phone number to begin the verification flow. # Error Reference Source: https://docs.surge.app/api-reference/errors Every API error type by category: HTTP status codes, causes, and messaging-layer delivery failures with descriptions. All errors follow a consistent structure: ```json theme={null} { "error": { "type": "error_type_slug", "message": "Human-readable description." } } ``` Some error types include a `detail` map with extra context. The most common case is validation errors, which surface per-field errors: ```json theme={null} { "error": { "type": "validation_error", "message": "Could not perform the request with the provided parameters.", "detail": { "body": ["can't be blank"], "to": ["is not a valid E.164 phone number"] } } } ``` *** ## Authentication and authorization | Type | HTTP status | Description | | ----------------- | ----------- | ------------------------------------------------------ | | `missing_api_key` | 401 | No `Authorization` header was provided | | `invalid_api_key` | 401 | The API key doesn't exist or has been revoked | | `demo_api_key` | 403 | The action requires a live key but a demo key was used | | `platform_locked` | 403 | The project has been locked due to a policy violation | *** ## Resource errors | Type | HTTP status | Description | | ------------------- | ----------- | -------------------------------------------------------------------------- | | `not_found` | 404 | The requested resource doesn't exist | | `account_not_found` | 404 | The account ID in the path doesn't exist or isn't accessible | | `account_archived` | 410 | The account has been archived and no longer accepts mutating operations | | `bad_request` | 400 | The request body is malformed (unparseable JSON, wrong content type, etc.) | *** ## Validation errors | Type | HTTP status | Description | | -------------------- | ----------- | ----------------------------------------------------------------------------------- | | `validation_error` | 422 | One or more fields failed validation. Check the `detail` map for field-level errors | | `invalid_pagination` | 422 | The cursor value is invalid or the pagination parameters are out of range | *** ## State and business rule errors | Type | HTTP status | Description | | ------------------------- | ----------- | -------------------------------------------------------------------------------------------- | | `account_not_ready` | 422 | The account doesn't have enough information to perform this action (e.g., create a campaign) | | `accounts_limit_exceeded` | 422 | The project has reached its maximum number of accounts | | `link_size_limit` | 422 | The message contains too many URLs | | `opted_out` | 422 | The recipient has opted out; sending is not permitted | | `campaign_locked` | 422 | The campaign cannot be updated in its current status | *** ## Rate and quota errors | Type | HTTP status | Description | | ------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ | | `demo_message_limit` | 403 | The demo number's message limit has been reached | | `monthly_outbound_message_limit` | 403 | The account's monthly outbound message quota has been reached. Contact support to raise the limit | | `monthly_outbound_verification_limit` | 403 | The account's monthly outbound verification quota has been reached. Contact support to raise the limit | *** ## Server errors | Type | HTTP status | Description | | ----------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | | `internal_server_error` | 500 | Something went wrong on Surge's side. These alert our team automatically; contact support if they persist | *** ## Messaging-layer errors These errors appear as `failure_reason` in `message.failed` webhook events after a message was accepted. They come from the carrier network or Surge's delivery pipeline, not from the Surge API directly. | Type | Description | | ------------------------------ | --------------------------------------------------------------------------------------- | | `account_archived` | The sending account was archived before the message could be dispatched | | `account_locked` | The account was locked due to a policy violation | | `attachment_retrieval_error` | Surge couldn't fetch the attachment URL to build the MMS | | `blocked` | The number has been explicitly blocked at the carrier level | | `carrier_error` | A transient error from the carrier network | | `configuration_error` | The account's carrier credentials are misconfigured | | `daily_message_cap_reached` | The campaign's daily T-Mobile volume cap was exceeded | | `geo_not_enabled` | The destination country is not in the project's allowed regions | | `invalid_attachment_url` | The attachment URL is malformed or not publicly accessible | | `invalid_content_type` | The MMS attachment type isn't supported by the recipient's carrier | | `invalid_mobile_number` | The destination is not a valid mobile number (permanently undeliverable) | | `matching_sender_and_receiver` | The sending and receiving number are identical | | `message_filtered` | The carrier's spam filter or Surge's content policy blocked the message | | `message_too_big` | The message body exceeds the 1,600-character limit | | `mms_not_supported` | The recipient's carrier or handset doesn't support MMS | | `opted_out` | The contact has opted out of receiving further messages; the message was not dispatched | | `premium_rate_number` | The destination is a premium-rate number | | `suspected_fraud` | Surge's fraud detection system flagged the send | | `unknown_error` | An unclassified error occurred | | `unreachable_carrier` | The destination carrier network is unreachable (permanently undeliverable) | | `unreachable_destination` | The recipient's number is disconnected or out of service | | `unregistered_number` | The sending number isn't registered with the carrier for this message type | | `unsupported_route` | No valid carrier route exists for this number pair | **Permanently undeliverable numbers.** When Surge records `invalid_mobile_number` or `unreachable_carrier` for a destination, that number is flagged globally. All future sends to it (from any account) are cancelled immediately without reaching the carrier. This prevents wasted volume quota. The flag clears automatically if a future send to that number succeeds. See [Handle Failures](../sending/handle-failures) for per-reason remediation guidance. *** ## Phone number purchase errors | Type | Description | | ------------------------------ | ------------------------------------------------- | | `no_matching_numbers` | No numbers match the requested type and area code | | `phone_numbers_limit_exceeded` | The account has reached its phone number limit | *** ## Quick lookup | Question | Answer | | ------------------------------------------------------------ | -------------------------------------------------------------- | | Which errors mean "this number will never be delivered to"? | `invalid_mobile_number`, `unreachable_carrier` | | Which errors should I retry automatically? | `carrier_error`, `daily_message_cap_reached` (next day) | | Which 4xx codes mean the request was malformed? | `bad_request` (400) and `validation_error` (422 with `detail`) | | Where do I find live remediation guidance? | [Handle Failures](../sending/handle-failures) | | Where do delivery-time errors appear in the webhook payload? | The `failure_reason` field on `message.failed` events | # Introduction Source: https://docs.surge.app/api-reference/introduction The Surge REST API is a JSON API over HTTPS. Base URL: https://api.surge.app. Surge provides a REST API for sending and receiving SMS and MMS, managing voice calls, registering messaging campaigns with carriers, and provisioning phone numbers. All requests go to `https://api.surge.app` and all responses are JSON. The API is organized around accounts. Your project can have many accounts, one per customer is the recommended pattern. Most endpoints take an account ID in the path, for example: ``` POST /accounts/acct_01jrzhe8d9enptypyx360pcmxj/messages GET /accounts/acct_01jrzhe8d9enptypyx360pcmxj/phone_numbers ``` ## Authentication Every request requires an API key. Pass it as a bearer token: ```http theme={null} Authorization: Bearer sk_live_your_api_key_here ``` See [Authentication](./authentication) for key types and error codes. ## Format * Request bodies must be JSON with `Content-Type: application/json` * Timestamps are ISO 8601 in UTC (`2024-10-21T23:29:43Z`) * IDs use [TypeID](https://github.com/jetpack-io/typeid), prefixed UUIDs like `msg_01jav968...`, `acct_01jrz...` * Phone numbers are E.164 with a leading `+`, for example `+18015551234` ## Errors All errors return a JSON body with a `type` slug and a human-readable `message`. Validation errors include a `detail` map with per-field errors. See [Error Reference](./errors) for the full list. ## OpenAPI spec Surge publishes a machine-readable OpenAPI 3.1 spec that powers this site's endpoint pages. ## MCP server This documentation site hosts a Model Context Protocol (MCP) server at: ``` https://docs.surge.app/mcp ``` Add that URL as an MCP server in any MCP-compatible client (such as an AI coding assistant) to give it tools for searching and reading the Surge docs, including the full API reference, while you build. # call.ended Source: https://docs.surge.app/api-reference/webhooks/call/ended webhook callEnded Fires when a call ends, regardless of direction or how it was terminated. Fires when a call ends, regardless of direction or how it was terminated. ## Common use case Use `call.ended` to log call records, trigger post-call follow-up messages, or update CRM entries with call duration and outcome. # campaign.approved Source: https://docs.surge.app/api-reference/webhooks/campaign/approved webhook campaignApproved Fires when a campaign transitions to active after carrier approval. Fires when a campaign transitions to `active` status after being approved by the carriers. Once phone numbers are successfully attached to this campaign, they will be able to send production messages. ## Common use case Use `campaign.approved` to automatically progress a customer's onboarding, for example, to notify them that they can start sending, or to trigger attaching their purchased phone numbers to the now-approved campaign. # contact.opted_in Source: https://docs.surge.app/api-reference/webhooks/contact/opted_in webhook contactOptedIn Fires when a contact opts back in to messaging by sending a keyword like START, YES, or UNSTOP. Fires when a contact who had previously opted out sends an opt-in keyword (START, YES, UNSTOP) to one of your phone numbers. After this event fires, Surge allows messages to the contact again. ## Common use case Use `contact.opted_in` to update your records and re-enable messaging features for a contact. Fetch the full contact with `GET /contacts/{contact_id}` if you need the phone number or other details. # contact.opted_out Source: https://docs.surge.app/api-reference/webhooks/contact/opted_out webhook contactOptedOut Fires when a contact opts out of messaging by sending a keyword like STOP, CANCEL, or UNSUBSCRIBE. Fires when a contact sends an opt-out keyword (STOP, CANCEL, UNSUBSCRIBE, END, QUIT) to one of your phone numbers. Surge automatically stops message delivery to opted-out contacts. You should also suppress future sends to this contact in your own system. ## Common use case Use `contact.opted_out` to suppress the contact in your own marketing or CRM system. Any attempt to send to an opted-out contact via the Surge API will return an `opted_out` error. # conversation.created Source: https://docs.surge.app/api-reference/webhooks/conversation/created webhook conversationCreated Fires when a new conversation thread is created, on the first inbound message from a contact. Fires when a new conversation is created. A conversation is the thread that ties all messages between a specific phone number and a specific contact. Surge creates a new conversation the first time a contact messages one of your numbers (or the first time you message a new contact). When a contact sends their first message, both `conversation.created` and `message.received` fire. Subsequent messages on the same thread fire only `message.received`. ## Common use case Use `conversation.created` to create a support ticket, add a lead to your CRM, or notify your team when a new contact reaches out for the first time. # Webhooks Overview Source: https://docs.surge.app/api-reference/webhooks/intro How Surge webhook delivery works: event types, retry behavior, and where to configure your endpoint. Surge sends an HTTP POST to a URL you configure whenever something happens in your project: a message arrives, a contact opts out, a campaign is approved. You register one endpoint URL per project and filter by `type` in your handler. ## Where to configure your endpoint Webhook endpoints are configured at the project level in the dashboard, under the top-level **Webhooks** page. A project-level endpoint receives events from all accounts under that project. ## Event taxonomy Surge can send the following events to your webhook endpoint depending on the subscriptions you configure: | Category | Events | | ------------ | ------------------------------------------------------------------------- | | Message | `message.sent`, `message.delivered`, `message.failed`, `message.received` | | Contact | `contact.opted_in`, `contact.opted_out` | | Conversation | `conversation.created` | | Campaign | `campaign.approved` | | Phone number | `phone_number.imported`, `phone_number.attached_to_campaign` | | Link | `link.followed` | | Voice | `call.ended`, `recording.completed`, `voicemail.received` | ## Envelope shape Every webhook payload has the same outer envelope: ```json theme={null} { "id": "evt_01jav96823f9x9054d6gyzpp16", "type": "message.received", "timestamp": "2024-10-21T23:29:43Z", "account_id": "acct_01jrz...", "data": { ... } } ``` The `data` object varies by event type. See the individual event pages for their payload shapes. ## Retries and timeouts * Surge expects a `2xx` response within the request timeout. If your endpoint doesn't respond in time or returns a non-2xx status, Surge retries with exponential back-off. * Your handler should be idempotent. Surge may deliver the same event more than once. * Return `200` quickly; enqueue work for slow processing. ## Signature verification Every request includes Standard Webhooks signature headers. You should verify the signature before processing the payload. See [Signature Verification](./signature-validation) for the full details and per-language examples. ## Setting up a handler For a walkthrough of configuring your endpoint, verifying signatures, and handling events in our SDKs, see [Receiving Messages & Webhooks](../../receiving/index). # link.followed Source: https://docs.surge.app/api-reference/webhooks/link/followed webhook linkFollowed Fires the first time a contact clicks a shortened link in a message. Fires the first time a contact follows a shortened link that Surge embedded in a message. Subsequent clicks on the same link by the same contact do not fire additional events. **Deprecation note:** The top-level `message_id` field in the payload is deprecated. Use the nested `message.id` field instead. `message_id` will be removed in a future API version. ## Common use case Use `link.followed` to measure click-through rates on campaigns, trigger re-engagement sequences when a contact shows interest, or attribute web conversions back to specific messages. # message.delivered Source: https://docs.surge.app/api-reference/webhooks/message/delivered webhook messageDelivered Fires when the carrier confirms an outbound message was delivered to the recipient's handset. Fires when the carrier network confirms that an outbound message was delivered to the recipient's device. Not all carriers provide delivery receipts; if a carrier doesn't report delivery status, this event may not fire. A `message.delivered` event indicates the carrier accepted the message for delivery — it does **not** always mean the message reached the recipient's handset. Carriers vary in how they report delivery, and a delivery receipt can come back even when the message ultimately doesn't land on the device. ## Common use case Use `message.delivered` to update delivery status in your own database or to trigger follow-on workflows that depend on confirmed delivery. # message.failed Source: https://docs.surge.app/api-reference/webhooks/message/failed webhook messageFailed Fires when an outbound message could not be delivered. Includes a failure_reason code. Fires when an outbound message fails to deliver. The `failure_reason` field tells you why. Some failures are permanent (opted-out recipient, invalid number); others are transient and you may retry them. ## failure\_reason codes See [Error Reference, Messaging-layer errors](../../errors#messaging-layer-errors) for the full list of `failure_reason` values and remediation guidance. Common codes: | Code | Meaning | | --------------------------- | -------------------------------------------------------- | | `opted_out` | The recipient has opted out; stop sending to this number | | `invalid_mobile_number` | The number is not a valid mobile number | | `message_filtered` | Carrier or Surge content policy blocked the message | | `daily_message_cap_reached` | Campaign volume cap exceeded for the day | | `carrier_error` | Transient carrier network error | # message.received Source: https://docs.surge.app/api-reference/webhooks/message/received webhook messageReceived Fires when an inbound message arrives from a contact to one of your phone numbers. Fires when a contact sends a message to one of your phone numbers. This is the primary event for building two-way messaging flows. If this is the first message in a new thread, `conversation.created` fires alongside it. ## Common use case Use `message.received` to drive inbound message handling, auto-reply bots, support ticket creation, or forwarding to a human agent. To reply, call `POST /accounts/{account_id}/messages` with the same `conversation.id`. # message.sent Source: https://docs.surge.app/api-reference/webhooks/message/sent webhook messageSent Fires when Surge accepts an outbound message and passes it to the carrier for delivery. Fires when Surge accepts an outbound message and sends it to the carrier network. This event fires before delivery is confirmed. Listen to `message.delivered` for the carrier's delivery receipt. ## Common use case Use `message.sent` to update your own records when a send is confirmed. Pair it with `message.delivered` to track the full delivery lifecycle. # phone_number.attached_to_campaign Source: https://docs.surge.app/api-reference/webhooks/phone_number/attached_to_campaign webhook phoneNumberAttachedToCampaign Fires when a phone number is successfully attached to a campaign and is ready for production messaging. Fires when a phone number is successfully attached to a specific campaign. After this event, the number is ready to send production-volume messages under that campaign's registered use cases. ## Common use case Use `phone_number.attached_to_campaign` to confirm that a number is ready for production sends, for example, to flip a status flag in your own database or to notify the end customer that their number is active. # phone_number.imported Source: https://docs.surge.app/api-reference/webhooks/phone_number/imported webhook phoneNumberImported Fires when a phone number is imported into Surge from an external provider. Fires when a phone number is imported into Surge from an external provider, such as through porting or hosting. The event includes the number's ID, E.164 format, type, and any campaign attachment details available at import time. Imported numbers are not necessarily ready for production messaging yet. If a campaign is associated with the number, check `data.campaign.attachment_status`. When attachment completes, Surge fires [`phone_number.attached_to_campaign`](/api-reference/webhooks/phone_number/attached_to_campaign) instead. ## Common use case Use `phone_number.imported` to update your own records when a ported or hosted number becomes available in Surge, for example, to notify a customer that their import finished or to start tracking campaign attachment for that number. # recording.completed Source: https://docs.surge.app/api-reference/webhooks/recording/completed webhook recordingCompleted Fires when a non-voicemail call recording finishes processing and is available for download. Fires when a non-voicemail call recording finishes processing and is available for download. ## Common use case After receiving `recording.completed`, use `GET /recordings/{recording_id}/file` to download the audio file for storage, transcription, or quality review. # Signature Verification Source: https://docs.surge.app/api-reference/webhooks/signature-validation Verify Surge webhook signatures using Standard Webhooks HMAC-SHA256 to authenticate incoming requests. Surge signs every outbound webhook request following the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Verifying the signature confirms that the request came from Surge and wasn't tampered with in transit. ## The three headers Surge sets these headers on every webhook POST: | Header | Description | | ------------------- | ----------------------------------------------------------------- | | `webhook-id` | Unique ID for this webhook delivery attempt | | `webhook-timestamp` | Unix timestamp (seconds) when the event was sent | | `webhook-signature` | HMAC-SHA256 signatures, comma-separated, each prefixed with `v1,` | The `webhook-signature` header may contain multiple `v1,` values separated by spaces if your project has multiple signing secrets (during key rotation). Accept the request if any signature verifies successfully. ## Signing algorithm Surge computes the signature as: ``` HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw-body}") ``` The secret is base64-encoded and prefixed with `whsec_` in the dashboard. Strip the prefix and base64-decode it before using it as the HMAC key. The signed message is the string from the `webhook-id` request header, a literal `.`, the integer from the `webhook-timestamp` request header, a literal `.`, and then the raw request body, all concatenated without any separating whitespace beyond the `.` characters. ## Replay protection The `webhook-timestamp` lets you reject replays. Surge recommends rejecting requests where `webhook-timestamp` is more than five minutes old. Most Standard Webhooks library implementations enforce this tolerance window automatically. ## Verification examples ```python Python theme={null} from standardwebhooks import Webhook import os wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"]) try: payload = wh.verify(request.body, request.headers) # payload is the parsed event dict except Exception: return Response(status=400) ``` ```typescript TypeScript theme={null} import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.SURGE_WEBHOOK_SECRET!); try { const payload = wh.verify(rawBody, req.headers); // payload is the parsed event object } catch { res.status(400).end(); return; } ``` ```ruby Ruby theme={null} require "standardwebhooks" wh = StandardWebhooks::Webhook.new(ENV["SURGE_WEBHOOK_SECRET"]) begin payload = wh.verify(request.raw_post, request.headers) rescue StandardWebhooks::WebhookVerificationError head :bad_request and return end ``` ```elixir Elixir theme={null} # The `standardwebhooks` library isn't available for Elixir yet. # Verify the HMAC manually: <<<<<<< CURRENT defp verify_surge_signature(conn, secret) do webhook_id = List.first(get_req_header(conn, "webhook-id")) timestamp = List.first(get_req_header(conn, "webhook-timestamp")) signature = List.first(get_req_header(conn, "webhook-signature")) body = conn.assigns[:raw_body] # Strip the "whsec_" prefix and base64-decode the secret "whsec_" <> encoded = secret decoded_secret = Base.decode64!(encoded, padding: false) # Build the signed payload: "{webhook-id}.{webhook-timestamp}.{body}" payload = "#{webhook_id}.#{timestamp}.#{body}" expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64() # Plug.Crypto.secure_compare/2 is a constant-time comparison. Plug.Crypto.secure_compare(signature, "v1,#{expected}") end ======= @tolerance_seconds 300 defp verify_surge_signature(conn, secret) do webhook_id = List.first(get_req_header(conn, "webhook-id")) timestamp = List.first(get_req_header(conn, "webhook-timestamp")) signature_hdr = List.first(get_req_header(conn, "webhook-signature")) body = conn.assigns[:raw_body] # Reject stale timestamps to prevent replay attacks (codex P1) with {ts, ""} <- Integer.parse(timestamp || ""), true <- abs(System.system_time(:second) - ts) <= @tolerance_seconds do # Strip the "whsec_" prefix and base64-decode the secret "whsec_" <> encoded = secret decoded_secret = Base.decode64!(encoded, padding: false) # Build the signed payload: "{webhook-id}.{webhook-timestamp}.{body}" payload = "#{webhook_id}.#{timestamp}.#{body}" expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64() # The header may hold several space-separated "v1," entries # during key rotation — accept if any matches (codex P2). signature_hdr |> String.split(" ", trim: true) |> Enum.any?(&Plug.Crypto.secure_compare(&1, "v1,#{expected}")) else _ -> false end end >>>>>>> SUGGESTED (#11 codex P1 replay-protection + #12 codex P2 key-rotation multi-sig, 2026-07-17 — DRAFTED, one block fixes both) ``` ## Finding your webhook secret Your signing secret is shown in the dashboard on the top-level **Webhooks** page, next to each endpoint URL. It looks like `whsec_`. ## Deprecated header Earlier Surge versions used `surge-signature` instead of `webhook-signature`. If you are still reading `surge-signature`, migrate to `webhook-signature`. See [Deprecation Notices](../../resources/deprecations). # voicemail.received Source: https://docs.surge.app/api-reference/webhooks/voicemail/received webhook voicemailReceived Fires when a voicemail recording finishes processing and is available for download. Fires when a voicemail recording finishes processing. Voicemails are a distinct recording type from regular call recordings — they are captured when a call goes unanswered and the caller leaves a message. ## Common use case Use `voicemail.received` to notify staff of missed calls, trigger an SMS callback to the caller, or feed recordings into a transcription pipeline. Fetch the audio using `GET /recordings/{recording_id}/file` with the `recording_id` from the payload. # RCS and WhatsApp Source: https://docs.surge.app/channels-roadmap/rcs-and-whatsapp RCS and WhatsApp are coming to Surge. How to build SMS workflows today so your code extends to new channels without rewrites. SMS is Surge's first channel. RCS and WhatsApp are next. ## RCS (Rich Communication Services) RCS is the successor to SMS. It supports images, carousels, read receipts, typing indicators, and branded sender profiles — without requiring a separate app install. It works over the carrier network like SMS but looks more like a messaging app. Surge's RCS support will use the same API surface as SMS: the same `POST /accounts/{account_id}/messages` endpoint, the same contact and conversation model, and the same webhook events. Messages that can't be delivered as RCS (because the recipient's device or carrier doesn't support it) will fall back to SMS automatically. You won't need to write any fallback logic. ## WhatsApp WhatsApp support will follow a similar pattern: same API surface, with WhatsApp-specific features (templates, rich media) exposed as additional parameters on the existing message create endpoint. ## What to do now If you're building messaging workflows today that you'll eventually want to extend to RCS or WhatsApp, the patterns to use are: * **Contacts and conversations**: the contact and conversation model will extend to cover all channels. Messages today in a conversation thread will be associated with the right contact when new channels come online. * **Webhook events**: `message.received`, `message.sent`, and `message.delivered` will work identically across channels. Your handler code won't need to change. * **Sender identity**: RCS uses carrier-managed sender IDs rather than phone numbers, so the underlying sender for an RCS-eligible message won't be the same phone number you send SMS from. Your application code stays the same — Surge selects the right sender for the channel. There's nothing to configure ahead of time. The channel extension will happen at the API layer. ## Staying informed Subscribe to the changelog at [surge.app/changelog](https://surge.app/changelog) (or see [Release Notes](../resources/release-notes)) for release announcements. Deprecation notices are tracked at [Deprecation Notices](../resources/deprecations). # Voice Source: https://docs.surge.app/channels-roadmap/voice Voice calls, recording, voicemail, and AI voice agent integrations available now; more features and full GA coming soon. Surge supports voice calls using the same phone numbers and accounts used for SMS. Outbound calls, call recording, voicemail, and one-click AI voice agent integrations are available now. More features and full general availability are on the horizon. ## Available now * Outbound calls via `POST /accounts/{account_id}/calls` * Call recording (`GET /accounts/{account_id}/recordings`) * Voicemail * One-click AI voice agent integrations (ElevenLabs, Retell, Vapi) * STIR/SHAKEN call integrity * Webhook events: `call.ended`, `recording.completed`, `voicemail.received` ## In development * HIPAA-compliant voice (tied to the Custom plan; in development). See [HIPAA Compliance](../resources/hipaa) for current status. ## Voice GA Voice is the next major area of work after HIPAA compliance ships. The two efforts are sequenced, not coupled — Voice itself isn't HIPAA-specific. # Consent Flows: How Users Opt In Source: https://docs.surge.app/concepts/consent-flows Valid opt-in methods, what makes a consent flow carrier-compliant, and how to write the consent_flow description. A consent flow is the mechanism by which someone explicitly agrees to receive text messages from you. The TCPA and the carriers require documented, explicit consent before you send **any** SMS — not just marketing or promotional traffic. Getting this right is the most common source of campaign rejection. ## Keep an audit trail You're responsible for proving consent if a carrier audits your campaign or you face a TCPA complaint. According to CTIA guidelines, for every phone number you message, retain: * **Exact disclosure text** they saw at the time of opt-in * **Specific campaign** for which consent was provided * **Phone number** for which consent was granted * **Method** of consent (web form, keyword, paper form, verbal) * Any **identifiers** for the individual who provided consent (username, email, etc) * **Timestamp** of when consent was given Most platforms store this as a row per opt-in event in their own database. Surge does not store this for you. ## Why consent matters Carriers treat undocumented consent as no consent. Reviewers will only approve campaigns with a clear opt-in mechanism. After approval, high opt out rates and spam reports lead the carriers to doubt consent was received, and lead to heavy filtering of your messages. Good consent practices reduce spam complaints to keep your deliverability healthy. ## Types of opt-in | Method | Description | When to use | | -------------- | --------------------------------------------------------------------------------- | ----------------------------------- | | **Web form** | User enters phone number on a web page, typically with a visible consent checkbox | Checkout, signup, contact forms | | **Keyword** | User texts a keyword (e.g., JOIN) to your number | Physical signage, social media CTA | | **Paper form** | User signs a physical form | In-person service businesses | | **Verbal** | User provides consent verbally (with your documentation of it) | Call center, in-person interactions | ## What makes a consent flow valid A valid web form consent flow has three components: 1. **A phone number input**: users enter the number they want messages sent to 2. **An explicit, unchecked checkbox**: labeled something like "I agree to receive text messages from Acme Corp" 3. **Required disclosures** shown near the checkbox (see [Required Disclosures](./required-disclosures)) **SMS consent must be separate from general terms acceptance.** You cannot use "I agree to the terms of service" as your consent mechanism for text messages. Carriers require an explicit, optional opt-in. ### "Optional" is non-negotiable The opt-in must be genuinely optional: the user must be able to complete the form or flow **without** providing SMS consent. This is one of the most common causes of campaign rejection. What "optional" looks like in practice: * **Unchecked checkbox** on a signup form — the form submits whether the box is ticked or not * **A secondary option** alongside SMS, such as a "More options" button offering a phone call for verification codes instead of an SMS code * **A "No thanks" button or close (×)** on a dedicated SMS consent prompt Anything that forces the user to consent to SMS in order to use your product (a required checkbox, a flow that won't proceed without opt-in) will be rejected. ## Writing your `consent_flow` description The `consent_flow` field in your campaign registration is a plain-English description of your opt-in mechanism. It should be specific enough that a reviewer could find the opt-in on your website and verify it matches your description. What to include: * Where the opt-in happens (URL, form type) * The exact wording of the checkbox or prompt * Whether the checkbox is pre-checked or unchecked by default (must be unchecked) * What disclosures are shown and where "Users opt in on our website" is acceptable **only** if you include a direct link to the page where the opt-in lives, and reviewers can find the opt-in mechanism on that page. If you provide a screenshot in place of a link, explain why — usually because the form sits behind authentication. A specific description like the example below passes review: > "Users enter their phone number and provide consent during the account signup process at acme.com/signup." ## Keyword opt-in For keyword-based opt-ins, the consent flow description should explain where the keyword appears (physical sign, website, etc.) and what the keyword is. It can be helpful to include a photo or mockup of the sign or website where the phone number and keyword are advertised. > "Users consent by texting JOIN to +18015559876 after seeing our in-store signage. Signs look like this: [https://drive.google.com/file/d/eXaMpLeFiLeId/view?usp=sharing](https://drive.google.com/file/d/eXaMpLeFiLeId/view?usp=sharing)" ## Double opt-in Some high-stakes use cases (medical, financial) benefit from double opt-in, where after the user submits their number, they receive a confirmation text asking them to reply YES. This creates an additional record of consent. It's not required by TCR but can reduce disputes. ## What happens when someone opts out When a contact texts STOP, Surge records the opt-out and fires a [`contact.opted_out`](../receiving/events#contact-opted-out) webhook event. Sending to an opted-out contact returns an `opted_out` error. You cannot legally re-subscribe someone who has opted out without their explicit new consent (typically by having them text START). The HELP and START keywords are handled automatically by Surge. When a contact texts HELP, they receive a help message configured on the phone number. When they text START, they're re-subscribed. # Deliverability Source: https://docs.surge.app/concepts/deliverability T-Mobile volume caps, Verizon batching delays, Surge's content policy layer, and how to monitor message delivery health. Deliverability is the percentage of your sent messages that actually reach the recipient's handset. This section covers the factors that affect it and what you can do about them. ## T-Mobile volume caps T-Mobile enforces daily volume caps per campaign based on the volume tier you registered: | Campaign volume | T-Mobile limit | | --------------- | --------------------------------------------------------------- | | `low` | 2,000 SMS segments per day | | `high` | Up to 200,000 SMS segments per day (subject to TCR trust score) | Messages that exceed the cap are queued or filtered, not delivered. If you need to send more than 2,000 segments per day to T-Mobile, register your campaign at `high` volume. The cap only counts messages delivered to T-Mobile subscribers. It doesn't restrict which carriers you can reach: one 10DLC registration lets you send to recipients on all major US carriers, including AT\&T, T-Mobile, and Verizon. T-Mobile is called out here because it's the carrier that enforces this per-campaign daily cap. "SMS segments" are the carrier billing unit, not messages. A single message with 200 characters costs 2 segments (161–306 characters in GSM-7). See [SMS Segments & Encoding](./sms-segments). ## Surge's content policy layer Surge runs a content-policy check on outbound messages before they reach the carrier. This catches links, patterns, and content that carriers commonly filter — before the message is sent and marked as failed. If your message is blocked by the content policy, you'll see a `message_filtered` error in the `message.failed` webhook event. Common reasons: * **Unregistered link shorteners.** Third-party link shorteners (bit.ly, tinyurl.com, etc.) are frequently flagged by carrier spam filters. Surge's built-in [link shortening](/sending/send-one#link-shortening) will have better results, but for the best results use your own branded domain. * **Content not matching your campaign.** If your campaign is registered for `customer_care` but your messages contain marketing language and promotional links, the content mismatch will be flagged. * **Age-gated or lending content without the flag.** If your messages contain age-gated content or direct lending references but you didn't include `age_gated` or `direct_lending` in your campaign's `includes` array, those messages will be filtered. ## The `includes` flags The `includes` array in your campaign registration tells carriers what content types your messages contain. Being accurate here directly affects deliverability: | Flag | When required | | ---------------- | ------------------------------------------------------------------------------------ | | `links` | Your messages contain any URLs | | `phone_numbers` | Your messages contain phone numbers | | `age_gated` | Your messages contain age-restricted content (typically alcohol) | | `direct_lending` | Your messages relate to lending or financial products (loans, mortgage offers, etc.) | Missing a flag when the content is present is a deliverability problem, not just a registration problem. Carriers can filter messages that don't match the registered content profile. See [Canonical Schema Reference](../registration/schema-reference#includes) for the full list of valid `includes` values and their descriptions. ## Carrier fees Carriers charge per-message fees that vary by destination network. These fees are passed through in your Surge billing and visible in your usage reports. Carriers sometimes give discounts as a charity campaign. ## Monitoring deliverability Watch the `message.delivered` and `message.failed` webhook events. A sustained spike in `message.failed` events with `message_filtered` as the reason is a signal to review your content against your campaign registration. Blast-level delivery metrics (aggregate deliverability rate, opt-out rate per send) are available in the dashboard under **Blasts**. # Required Disclosures and Opt-In Templates Source: https://docs.surge.app/concepts/required-disclosures The three required SMS disclosures, where to place them, and copy-ready opt-in templates for common use cases. Carriers require specific disclosures at the point of SMS opt-in. This page lists what's required, where to put it, and templates you can adapt for common use cases. ## The three required disclosures Every SMS opt-in point must display: 1. **Message frequency**: e.g., "Message frequency varies" or "You'll receive up to 4 messages per month" 2. **Cost notice**: exactly "Msg\&data rates may apply" or "Msg\&data rates apply" 3. **Opt-out instructions**: exactly "Reply STOP to opt out" (STOP is the required keyword) 4. **Help instructions**: exactly "Reply HELP for help" In addition to the above, the following are required as well: * Identify the business responsible for the messages * Explain the purpose of the messages (e.g. account notifications, promotional messages, etc.) * Link to privacy policy * Link to terms of service ## Where to put disclosures Disclosures must appear **at the point of opt-in**: next to or immediately below the phone number field and consent checkbox. They should be readable without scrolling. Burying them in a terms of service page or a footer is insufficient. The font size should be legible (not 8px gray text). Where possible, reviewers visit your actual website, not just screenshots. ## Canonical compliance message When you send a first message to a new subscriber, include the full opt-in confirmation: > "You are now opted in to messages from Acme Corp. Frequency varies. Msg\&data rates apply. Reply STOP to opt out." Replace "Acme Corp" with your actual brand name, not a placeholder. ## Templates by use case ### Marketing **Opt-in checkbox label:** > "I agree to receive promotional text messages from Acme Corp" **Disclosure text beneath the checkbox:** > "Message frequency varies. Msg\&data rates apply. Reply STOP to opt out. View our [Privacy Policy](https://acme.com/privacy)." **Confirmation message:** > "Acme Corp: You're in! Expect exclusive deals and updates. Msg freq varies. Msg\&data rates apply. Reply STOP to opt out." Example web form with separate unchecked checkboxes for transactional and marketing text messages *** ### Transactional (account notifications, order updates) **Opt-in checkbox label:** > "Send me text message updates about my orders and account" **Disclosure:** > "You may receive messages about order status, shipping, and account activity. Message frequency varies. Msg\&data rates apply. Reply STOP to opt out." **Confirmation message:** > "Acme Corp: You'll now receive order and account updates by text. Reply STOP to opt out. Msg\&data rates apply." Example dedicated web form for SMS account notifications, with consent granted by submitting the form Example web form where SMS signup shares a form with other fields, using an explicit unchecked checkbox *** ### Two-factor authentication **Opt-in checkbox label:** > "Use my phone number to receive one-time verification codes" **Disclosure:** > "We'll send a verification code each time you sign in. Message frequency: one per login. Msg\&data rates apply. Reply STOP to opt out." Example dedicated web form for phone verification codes Note: For security-only OTP (where the user only receives verification codes, not marketing), some of these disclosures are less critical, but including them is still best practice. *** ### Customer care **Opt-in at ticket submission:** > "Text me updates about my support request" **Disclosure:** > "You'll receive updates about your support case by text. Frequency depends on case activity. Msg\&data rates apply. Reply STOP to opt out." *** ## Platform vs direct customer templates If you're a SaaS platform registering on behalf of clients, the brand name in every message should be the **client's brand**, not your platform's brand. The opt-in checkbox label, disclosure text, and confirmation message should all name the client. > "I agree to receive text messages from Smith Realty" — not — > "I agree to receive text messages from YourPlatform on behalf of Smith Realty" Reviewers flag brand-in-disclosure mismatches. The message recipient should see one consistent brand name throughout: on the website where they opted in, in the SMS messages they receive, and in the registration you submitted. Example platform-branded web form for account notifications, with 'Powered by PLATFORM' attribution Example platform-branded web form with separate checkboxes for account notifications and marketing Example platform-branded web form for customer care text messages Example platform-branded web form for customer care with an additional marketing checkbox ## Privacy policy requirements Your privacy policy should ideally state that you collect phone numbers for SMS communication and describe how you use them. The first section should not imply that agreeing to the policy constitutes SMS consent — that's a common rejection cause. The main thing the carriers look for in the privacy policy is making sure that SMS consent won't be shared with third parties for things like lead generation and affiliate marketing. Whenever any data will be shared with third parties, we recommend adding the following line to the privacy policy to guarantee this: "All the above categories exclude text messaging originator opt-in data and consent; this information will not be shared with any third parties." Minimum language to include in your privacy policy: > "We collect your phone number to send you \[describe use cases]. You may opt out at any time by replying STOP to any text message from us." ### Sample privacy policy template This is the SMS consent privacy policy template Surge provides to customers as a starting point. Customize it to fit your needs and make sure it accurately reflects your business practices. You can also have it live as a separate "SMS Privacy Policy" that links from your primary policy, so the SMS-specific language is easy for reviewers to find. ```markdown theme={null} # PRIVACY POLICY Effective Date: {{effective_date}} We value your privacy and make every effort to respect your wishes and personal information. Please read this policy carefully to understand how we collect, use, and manage your phone numbers. ## COLLECTION OF PHONE NUMBERS We collect your phone numbers only when you voluntarily provide them to us, for example, during transactions, inquiries, or when you sign up to receive notification messages. You can opt in to receive these SMS messages by providing your phone number. ## USE OF PHONE NUMBERS FOR SMS SMS messaging charges may be applied by your carrier. We will only share your phone number with our SMS provider, subject to their privacy policy. ## OPTING OUT OF MESSAGES If at any time you wish to stop receiving messages from us, you can opt out by texting STOP. ## PRIVACY OF PHONE NUMBERS Once you have opted out, we will not send you any more SMS messages, nor will we sell or transfer your phone number to another party. ## CHANGES TO THIS POLICY We may periodically update this policy. We will notify you about significant changes in the way we treat your information by placing a prominent notice on our site. We thank you for your understanding and cooperation. If you have any questions or concerns about this policy, please feel free to contact us via the form on our site. ``` # SMS Segments & Encoding Source: https://docs.surge.app/concepts/sms-segments How SMS segments and character encodings work, why emoji messages use more segments, and practical cost advice. When you send a text message, carriers count it in segments, not characters. Understanding segments matters because they determine both cost and deliverability, and because the number of characters per segment changes depending on what you put in the message. ## The 140-byte rule Every SMS segment is 140 bytes. How many characters fit into those 140 bytes depends on the character encoding: | Encoding | Characters per segment | Concatenated (multi-part) | | --------- | ---------------------- | ------------------------- | | **GSM-7** | 160 per segment | 153 per segment | | **UCS-2** | 70 per segment | 67 per segment | When a message spans multiple segments, carriers use a small portion of each segment for a header that lets the handset reassemble the parts in order. That's why concatenated messages have slightly fewer usable characters per segment (153 and 67 instead of 160 and 70). ## GSM-7 vs UCS-2 **GSM-7** is the standard encoding for basic Latin characters. It covers the full English alphabet, digits, and common punctuation. If every character in your message is in the GSM-7 character set, the 160-character-per-segment limit applies. **UCS-2** activates automatically if your message contains any character outside GSM-7, including emoji, accented characters (like é, ñ, ü), curly quotes (“ ”), ellipses (…), and many symbols. When UCS-2 activates, the limit drops to 70 characters per segment. This is the most common surprise for developers — pasting text from a word processor or rich text editor often introduces curly quotes or em dashes that silently switch the message to UCS-2 and cut the character limit by more than half. ### Characters that trigger UCS-2 Common culprits: * `“` and `”` (curly double quotes, vs standard `"`) * `‘` and `’` (curly single quotes, vs standard `'`) * `…` (ellipsis, vs three periods `...`) * `—` (em dash, vs hyphen `-`) * Emoji of any kind: 😀 🎉 ✅ * A small set of accented characters outside GSM-7 — the most common is `ç`. Most other Latin accents (`é`, `à`, `ü`, `ñ`) are in GSM-7 and do **not** trigger UCS-2 on their own. If you're interpolating user-provided content into messages, normalize the input to GSM-7-safe characters before sending. ## Counting segments A standard English message under 160 characters is one segment. A 300-character English message is two segments. Emoji are a common gotcha: each emoji counts as **two characters** under UCS-2, not one. So a message that looks like 69 characters plus a single emoji is really 71 characters of UCS-2, which exceeds the 70-character single-segment limit and spans two segments. To count segments before sending, use a client-side SMS segment calculator. This helps you estimate cost and flag unexpectedly long messages before they go out. ## Segments as the billing unit Surge bills per segment, not per message. A blast of 1,000 two-segment messages costs twice as much as 1,000 one-segment messages. Campaign rate limits (like 2,000 SMS segments per day to T-Mobile and 3.75 segments per second) are also counted in segments. Keep this in mind when estimating costs and when setting campaign volume levels during registration. ## Segment count and automatic protocol selection Segment count affects more than billing. It can determine whether your message is sent as SMS or MMS: * **10 segments is the SMS cap.** Anything longer is sent as MMS automatically, in countries where MMS is available. This avoids the fragmented experience of long concatenated SMS on the recipient's device. * **3 or more segments + Auto-MMS enabled** → Surge converts the message to a single MMS instead of sending multiple SMS segments. Beyond 3 segments, MMS is cheaper per message than SMS, so Auto-MMS reduces cost for platforms that send long messages routinely. Contact support if you'd like to disable Auto-MMS for your platform. In both cases, if the recipient's carrier doesn't support MMS, Surge falls back to SMS and delivers the full concatenated message. See [Send to One Person](../sending/send-one#automatic-sms-to-mms-conversion) for details. ## Practical advice * Keep routine messages under 160 characters to stay within a single segment * Use straight quotes (`"` and `'`) instead of curly quotes in templates * Use three periods (`...`) instead of the ellipsis character (`…`) * If you support emoji in user-facing message templates, budget for UCS-2 costs * Test any message template with a client-side segment counter before sending at scale # The Campaign Registry (TCR) and Becoming a CSP Source: https://docs.surge.app/concepts/tcr What TCR is, how Surge submits your registrations as your CSP, and when becoming a CSP yourself makes sense. The Campaign Registry (TCR) is an industry body created by mobile carriers to combat spam. Before your messages can be delivered at scale via US long codes (10-digit local phone numbers), the brand sending them must be registered with TCR and the use case (campaign) must be approved. The registry gives carriers a way to verify: *who is behind this phone number, and what are they sending?* Messages from unregistered or suspicious sources are increasingly filtered or blocked. ## How Surge fits in Surge is registered with TCR as a **Campaign Service Provider (CSP)**: an intermediary that helps its customers register brands and campaigns. When you submit a campaign through Surge, Surge submits it to TCR on your behalf. From a practical standpoint, this means: * You manage everything through the Surge API and dashboard * Surge handles the TCR submission, status tracking, and communication with carriers * Your billing for TCR registration fees goes through Surge alongside your messaging charges * If there are carrier questions or changes needed, Surge routes that feedback to you The alternative (registering as a CSP yourself) is described below. Surge generally recommends against it for most customers, but we're happy to help you decide if it's the right move for you. ## Becoming a CSP yourself If you're building a platform where hundreds of your customers each send messages under their own brand, you may consider registering as a CSP directly with TCR. **What it means:** You apply to TCR to become an independent CSP. After approval, you submit your customers' brand and campaign registrations directly to TCR rather than through Surge. You take on responsibility for compliance, TCR billing, and any issues with your customers' campaigns. **When it might make sense:** * You want the ability to register campaigns with multiple platforms through the same API, or to be able to transfer campaigns between platforms * You have hundreds or thousands of distinct registered brands * You want to consolidate TCR fees and have a direct billing relationship with TCR * Your legal or compliance team requires direct regulatory responsibility **Why Surge generally recommends against it:** Going through Surge as your CSP is simpler in almost every respect: * **API and dashboard integration.** When you register through Surge, registration is part of the same API flow as messaging. Going direct to TCR means a separate integration with the TCR API and managing two systems. * **Consolidated billing.** With Surge as your CSP, all your costs appear in one place. As your own CSP, you pay Surge for messaging and TCR separately for registration fees. * **Support.** Surge will give you the same campaign review feedback and status updates either way, but as an independent CSP, we can't support you with brand verification the way we can when you register directly through us. If you're considering the CSP path, contact Surge support before you start. There are specific scenarios where it's the right choice, and support can help you evaluate whether yours is one of them. # Conversation Component Source: https://docs.surge.app/embeddable/conversation Mount the Conversation component to display a single message thread with send capability for the current User. The Conversation component shows a single message thread and a send field. Use it to embed a specific conversation in your application, useful when users navigate from your own inbox UI or when you want to open a specific thread. Conversation component showing a single message thread with a composer at the bottom ## Installation The conversation component requires one of the following: a Surge Conversation ID, a Surge Contact ID, or a phone number. The conversation ID is the preferred method and will be the most performant, since the other methods redirect to the conversation ID when one exists, so the iframe may take longer to load. ### With a conversation ID ```html theme={null} ``` Both `{SURGE_USER_ID}` and `{ACCOUNT_TOKEN}` are required parameters. You can find them in the Surge dashboard. The `{CONVERSATION_ID}` parameter is also required and should be the ID of the conversation to display. ### With a contact ID ```html theme={null} ``` The `{CONTACT_ID}` is required in this instance and should be the ID of the contact whose conversation should be displayed. ### With a phone number ```html theme={null} ``` The `{PHONE_NUMBER}` is required and is the phone number for the person whose conversation should be displayed, or to whom messages should be sent if no conversation exists yet. `{FIRST_NAME}` and `{LAST_NAME}` are optional but encouraged, since they let the contact's information display properly in the inbox; if no contact or conversation exists yet for the given phone number, these are attached to the contact when a message is sent and the contact is created. # Inbox component Source: https://docs.surge.app/embeddable/inbox Embed the Inbox component to show a real-time conversation list scoped to the current User. The Inbox component shows a list of conversations for the current User. It handles loading, pagination, and real-time updates automatically. Inbox component rendering a list of conversations with unread indicators and the most recent message snippet ## Installation The simplest version of the inbox component can be embedded with an iframe: ```html theme={null} ``` Both `{SURGE_USER_ID}` and `{ACCOUNT_TOKEN}` are required for this account-token form. `{PHONE_NUMBER_ID}` is optional; include it when you want the inbox to show only conversations associated with a specific phone number. If you use a signed User JWT from your server, pass that JWT as `token` and omit `user_id`; the JWT identifies the User. See [Embeddable UI components](./index#authentication) for the two authentication options. ## Events The Inbox component emits `postMessage` events that you can listen to from the parent window. | Event | When it fires | | ------------------------- | ---------------------------------- | | `conversation-selected` | The user selects a conversation | | `conversation-deselected` | The active conversation is cleared | | `filter-changed` | The inbox filter changes | Listen for events from `https://embed.surge.app`: ```javascript theme={null} window.addEventListener("message", (event) => { if (event.origin !== "https://embed.surge.app") return; switch (event.data?.type) { case "conversation-selected": console.log("Conversation selected", event.data); break; case "conversation-deselected": console.log("Conversation deselected", event.data); break; case "filter-changed": console.log("Filter changed", event.data); break; } }); ``` See the [Inbox component reference](https://docs.surge.app/ui/components/inbox#events) for the full event payload shapes. # Embeddable UI Components Source: https://docs.surge.app/embeddable/index Add a messaging inbox, conversation view, and dialpad to your app using Surge's embeddable UI components and JWT auth. Surge provides ready-to-use UI components you can embed directly into your web application. They connect to Surge's messaging backend and give your users an inbox, conversation view, and unread count — without building any of that yourself. Components are served from `embed.surge.app`. ## Available components * **Inbox**: a list of all conversations for the current user, sortable and filterable * **Conversation**: a single threaded conversation with send capability * **Unread count**: a badge showing how many unread conversations the user has * **Phone (dialpad)**: a voice dialer for outbound calls (voice-enabled accounts) ## Authentication Each component mount requires a signed JWT. The JWT identifies which Surge account and user the component is showing data for. **Publishable tokens are deprecated.** If you're currently using a publishable token, migrate to JWT authentication. See [Deprecation Notices](../resources/deprecations). ### Two ways to get a JWT **Option 1: use the token endpoint (simpler)** Call the user token endpoint from your server, then pass the resulting token to the component: ```python Python theme={null} from surge import Surge surge = Surge() response = surge.users.create_token(user_id="{user_id}") token = response.token ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const response = await surge.users.createToken("{user_id}"); const token = response.token; ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new response = surge.users.create_token("{user_id}") token = response.token ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) # create_token/3 requires an empty params map and returns the JWT directly {:ok, token} = Surge.Users.create_token(client, "{user_id}", %{}) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/users/{user_id}/tokens \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" ``` ```json theme={null} { "token": "eyJhbGciOiJFZERTQSIsImtpZCI6InNnbl8wMWouLi4ifQ..." } ``` The token is short-lived. Generate a fresh one on each page load or use a refresh mechanism. **Option 2: create and sign your own JWT** If you need more control over JWT claims (expiration, scopes), generate a signing key in the dashboard (**Settings → Signing Keys**) and sign your own JWTs. The key is an Ed25519 key pair: your private key stays on your server, and Surge uses the public key to verify tokens. ```python theme={null} import jwt # PyJWT from datetime import datetime, timedelta payload = { "sub": surge_user_id, "iat": datetime.utcnow(), "exp": datetime.utcnow() + timedelta(hours=1), } token = jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": key_id}) ``` ## Mounting a component Each component is embedded as an iframe served from `embed.surge.app`, with the token passed as a query parameter: ```html theme={null} ``` See the component-specific pages for each component's URL, parameters, and events. ### Rotating signed tokens Because signed tokens are short-lived they may expire while users are still interacting with the embedded UI components authenticated with the expiring tokens. Connections will remain authenticated after their token has expired, but any reconnects will trigger re-authentication. We recommend rotating tokens just before they expire to ensure your components will continue to work. #### Token expiring warning When an embedded UI component is authenticated with a token that is about to expire, it will emit an event warning of the pending expiration. This event will have the the type "token\_expiring": ```json theme={null} { "source": "surge_inbox", "type": "token_expiring", "data": { "seconds_remaining": 15 } } ``` #### Updating a component's token Re-rendering the iframe with a new token in the URL could cause the component to lose necessary state, so embedded components also support an "update\_token" message back into the iframe. ```json theme={null} { "type": "update_token", "data": { "token": "eyJhbGciOiJFZERTQSIsImtpZCI6InNnbl8wMWp5bWowZ3AwZjNidmJmMmpyazRoYnd0ayIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTA4ODkyMDgsInN1YiI6InVzcl8wMWp5bWdkZnJwZWMyYXNjNW0wejNhNmZyOSJ9.zKayo3EDrUm1Hw8URrofuYwajgyTu6dH2H0FEuRExprP1IV66FHa8wC3SfdzV7sR3AjDGAwkuAXztScq6rBnBw" } } ``` #### Example Here's an example snippet of some JavaScript in your app for rotating tokens before they expire. In this example `refreshSurgeToken` is a made-up function that makes a request to your server for a new token. ```js theme={null} const surgeInbox = document.querySelector('iframe#surge-inbox'); window.addEventListener('message', async ({ data, origin }) => { if (origin == 'https://embed.surge.app') { const { type } = data; if (type == 'token_expiring') { const { token } = await refreshSurgeToken(); const message = { type: 'update_token', data: { token }}; surgeInbox.contentWindow.postMessage(message, origin); } } }); ``` ## Next steps How Users are provisioned and attributed to messages. URL parameters and events for the conversation list. Mounting a single thread inside your own page. Badge integration for navigation chrome. # Unread Count Component Source: https://docs.surge.app/embeddable/unread-count Embed a live unread conversation count badge that updates in real time as new messages arrive. The Unread Count component renders a badge showing the number of unread messages for the current User. Use it in navigation elements, icon buttons, or any element where you want to surface unread state. Unread Count badge rendered next to a navigation icon, showing a numeric unread total ## Mount the component ```html theme={null} ``` The component updates in real time when new messages arrive, and hides itself when there are no unread messages, so you can keep the iframe permanently in your page. ## Query parameters | Parameter | Required | Description | | ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `token` | Yes | A signed JWT for the current User. If you pass an account token instead, also pass `user_id`. | | `user_id` | Only with account tokens | The Surge User ID. Omit when `token` is a signed User JWT — the JWT identifies the User. | | `conversation_id` | No | Scope the count to a single conversation. When omitted, the count covers all conversations the User has access to. | ## Reading the count in your own code If you want to render the count yourself instead of using the component, listen for the `count-updated` event the iframe emits via `postMessage` whenever the User reads a conversation or receives a new message: ```javascript theme={null} window.addEventListener("message", (event) => { if (event.origin !== "https://embed.surge.app") return; if (event.data?.source !== "surge-unread-count") return; if (event.data.type === "count-updated") { // Update your own UI element const count = event.data.count; document.getElementById("my-badge").textContent = count > 0 ? String(count) : ""; } }); ``` The event payload looks like this: ```json theme={null} { "source": "surge-unread-count", "type": "count-updated", "count": 3 } ``` # Working with Users Source: https://docs.surge.app/embeddable/users Provision Users via the API, generate short-lived JWTs for embedded components, and attribute messages to users. ## What a User is A User is a person in your application who sends and receives messages through Surge's embeddable UI components. Users are provisioned via the Surge API and belong to a specific account. They are separate from people who log in to `hq.surge.app`. If you're building a SaaS platform where your customers interact with Surge-powered messaging, Users are typically your customers' employees, not your customers' own customers. Users are used in two ways: 1. **Embedded UI**: the inbox and conversation components authenticate as a specific User, showing that user's conversations and sending messages on their behalf 2. **Attribution**: messages and calls can be tied to a User, so you can trace activity back to the individual Users do not have Surge passwords or sessions. Authentication for embedded components goes through a short-lived JWT that your server generates. ## Provision a User Create a User via the API before you mount any components for them: ```python Python theme={null} from surge import Surge surge = Surge() user = surge.users.create( account_id="{account_id}", first_name="Jordan", last_name="Patel", metadata={"internal_user_id": "u_9887"}, ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const user = await surge.users.create("{account_id}", { first_name: "Jordan", last_name: "Patel", metadata: { internal_user_id: "u_9887" }, }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new user = surge.users.create( "{account_id}", first_name: "Jordan", last_name: "Patel", metadata: { "internal_user_id" => "u_9887" } ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, user} = Surge.Users.create(client, "{account_id}", %{ first_name: "Jordan", last_name: "Patel", metadata: %{internal_user_id: "u_9887"} }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/users \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Jordan", "last_name": "Patel", "metadata": { "internal_user_id": "u_9887" } }' ``` ```json theme={null} { "id": "usr_01jrzhe8d9enptypyx360pcmxu", "first_name": "Jordan", "last_name": "Patel", "metadata": { "internal_user_id": "u_9887" } } ``` Store the returned `id` alongside the user's record in your database. You'll use it to generate component tokens and attribute messages. ## Generate a component token Your server generates a short-lived JWT for each user session. The JWT scopes the embedded component to that specific user's data. The simplest path is the token endpoint: ```python Python theme={null} response = surge.users.create_token(user_id="{user_id}") token = response.token ``` ```typescript TypeScript theme={null} const response = await surge.users.createToken("{user_id}"); const token = response.token; ``` ```ruby Ruby theme={null} response = surge.users.create_token("{user_id}") token = response.token ``` ```elixir Elixir theme={null} {:ok, token} = Surge.Users.create_token(client, "{user_id}", %{}) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/users/{user_id}/tokens \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "token": "eyJhbGciOiJFZERTQSIsImtpZCI6InNnbl8wMWouLi4ifQ..." } ``` Return this token to your frontend and use it to mount the component. Generate a new token on each page load because tokens are short-lived. ## Typical vertical SaaS flow 1. When one of your customer's employees is added to your SaaS product, create a Surge User via `POST /accounts/{account_id}/users`. Store the `id`. 2. When that employee loads a page in your app that includes embedded messaging, your server calls `POST /users/{user_id}/tokens` to get a fresh JWT. 3. Your frontend receives the JWT and mounts the Surge Inbox or Conversation component with it. 4. The component shows only that user's conversations and sends messages attributed to them. ```python theme={null} # On page load, server-side @app.get("/api/surge-token") async def get_surge_token(current_user: User): response = surge.users.create_token(user_id=current_user.surge_user_id) return {"token": response.token} ``` ```html theme={null} ``` ## List and manage Users ```python Python theme={null} # List users in an account for user in surge.users.list(account_id="{account_id}"): print(user.id) # Update a user surge.users.update("{user_id}", first_name="Jordan M.") # Delete a user surge.users.delete("{user_id}") ``` ```typescript TypeScript theme={null} // List users in an account for await (const user of await surge.users.list("{account_id}")) { console.log(user.id); } // Update a user await surge.users.update("{user_id}", { first_name: "Jordan M." }); // Delete a user await surge.users.delete("{user_id}"); ``` ```ruby Ruby theme={null} # List users in an account page = surge.users.list("{account_id}") page.data.each { |u| puts u.id } # Update a user surge.users.update("{user_id}", first_name: "Jordan M.") # Delete a user surge.users.delete("{user_id}") ``` ```elixir Elixir theme={null} # Surge.Users.list isn't typed in the Elixir SDK yet — use the raw client. {:ok, page} = Surge.Client.request(client, :get, "/accounts/{account_id}/users") Enum.each(page["data"], fn u -> IO.puts(u["id"]) end) # Update a user {:ok, _} = Surge.Users.update(client, "{user_id}", %{first_name: "Jordan M."}) # Delete a user {:ok, _} = Surge.Users.delete(client, "{user_id}") ``` ```bash cURL theme={null} # List users in an account curl "https://api.surge.app/accounts/{account_id}/users" \ -H "Authorization: Bearer YOUR_API_KEY" # Update a user curl -X PATCH https://api.surge.app/users/{user_id} \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Jordan M." }' # Delete a user curl -X DELETE https://api.surge.app/users/{user_id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` Deleting a User removes their Surge record but does not delete message history. ## Next steps Mount a conversation list for the current User. Mount a single threaded conversation view. Show a live unread badge in your navigation. # How Surge is Organized Source: https://docs.surge.app/overview/how-surge-is-organized The Project, Account, and User hierarchy, what each level owns, and how the API is structured. Surge organizes everything into three levels: Project, Account, and User. Understanding this hierarchy will help you make sense of the API, because most endpoints are scoped to one of these levels. Surge hierarchy: Project contains Account, which contains User **Project** is your company or product. You get one when you sign up. Your API keys, billing configuration, webhook endpoints, and settings all live at the project level. This means a single API key can manage everything underneath it. **Account** is the business entity that sends messages. It holds your campaigns, phone numbers, contacts, conversations, and messages. If you're a single developer or a small team, you'll have one account and that's all you need. If you're a SaaS platform registering brands for your own customers, you create one account per customer to keep compliance, billing, and message history isolated. See [One Account per Customer](../registration/one-account-per-customer) for that pattern. **User** is optional. You only need Users if you're building with Surge's embedded UI components (inbox, conversation view, dialpad). A User represents a person inside an account who sends and receives messages through those components. If you're calling the API directly from your backend, you can skip Users entirely. Before your account can send messages at scale, you'll need to register a campaign through The Campaign Registry (TCR) or, for toll-free numbers, through a separate verification process. [Register Your Business](../registration/index) covers both paths. ## Two kinds of "user" This is the most common source of confusion for new developers, so it's worth being explicit. | | Dashboard User | User | | --------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **What they are** | A person who logs in to `hq.surge.app` to manage API keys, billing, and webhooks | A person provisioned via the API, shown inside embedded UI components | | **Who creates them** | They sign up or receive an invitation | You create them via `POST /accounts/{account_id}/users` | | **Where they appear** | The Surge dashboard | Inside embeddable inbox and conversation components | | **Scope** | Can belong to multiple projects | Scoped to a single account | ## The API in brief Surge is a REST API. All requests and responses are JSON. Authenticate with a bearer token: ```http theme={null} Authorization: Bearer sk_live_... ``` Account-scoped endpoints take the account ID in the request path. Because your API key is project-scoped, this is how you target a specific account: ``` POST /accounts/acct_01jrzhe8d9enptypyx360pcmxj/messages ``` The base URL is `https://api.surge.app`. Official SDKs are available for [Python, TypeScript, Ruby, and Elixir](../sdks/index) with pagination helpers, error handling, and webhook signature verification built in. # What is Surge Source: https://docs.surge.app/overview/what-is-surge An introduction to Surge, the SMS and voice API for developers. Surge is an SMS and voice API that handles carrier registration for you. If you've used Twilio, Sinch, or Vonage, you know the pain: weeks of waiting for carrier approval, XML-based voice configuration, and support tickets that go nowhere. Surge gives you a demo number to test with immediately, and production registration takes hours instead of weeks. ## Why Surge? * **24-to-48-hour carrier registration** with a guaranteed 72-hour response. Other providers typically take two to four weeks. * **JSON everywhere.** REST API, voice call configuration, webhooks. No XML, no TwiML, no legacy protocols. * **One-click voice agent integrations** with ElevenLabs, Retell, and Vapi. * **Embeddable UI components** you drop into your app: inbox, conversation, dialpad, unread count. * **SDKs for Python, TypeScript, Ruby, and Elixir** with full API parity. * **Responsive support** when registrations get flagged or carriers block messages. ## Who Surge is for **Developers adding messaging to a product.** Appointment reminders, order notifications, 2FA, conversational SMS agents. Surge handles TCR paperwork and carrier registration so you don't build compliance infrastructure. **Vertical SaaS platforms.** Create one Account per customer, register each brand separately, and keep compliance, billing, and message history isolated. See [One Account per Customer](../registration/one-account-per-customer) for the full pattern. ## What you can build Send one-to-one messages, schedule sends, or blast to large lists. Track ongoing threads with the Contacts and Conversations APIs. Deliver six-digit OTP codes for login flows and onboarding. Call `POST /verifications` to send a code and `POST /verifications/{verification_id}/checks` to verify it. Make outbound calls with recording and voicemail. Integrate with AI voice agents from ElevenLabs, Retell, or Vapi for agent-driven workflows. Add an inbox, conversation view, phone dialpad, or unread count badge to any web application. Your users send and receive messages without you building a messaging UI. ## Try it now Send a test message with a single API call. No registration or phone number purchase required: ```python Python theme={null} from surge import Surge surge = Surge() # reads SURGE_API_KEY from environment message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Hello from Surge!", ) print(message.id) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); // reads SURGE_API_KEY from environment const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Hello from Surge!", }); console.log(message.id); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new # reads SURGE_API_KEY from environment message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Hello from Surge!" ) puts message.id ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, message} = Surge.Messages.create( client, "{account_id}", %{to: "+18015551234", body: "Hello from Surge!"} ) IO.puts(message.id) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "+18015551234", "body": "Hello from Surge!"}' ``` The [quickstart](../quickstart) walks through this step by step, including how to get your API key and account ID. ## Next steps See Surge working end to end with a demo number. Nothing to register or purchase yet. The Project, Account, and User hierarchy and the API conventions that flow from it. What carriers require and how to submit it through Surge when you're ready for production. Python, TypeScript, Ruby, and Elixir, each with pagination, retries, and webhook verification. # Attaching a Number to a Campaign Source: https://docs.surge.app/phone-numbers/attaching How Surge automatically attaches phone numbers to active campaigns and when to listen for the attachment event. Before a phone number can send production traffic, it must be attached to an active campaign. The campaign tells carriers who's behind the number and what use case it's registered for. ## How attachment works Surge handles campaign attachment automatically. There's no separate API call to make. Attachment is triggered in two situations: **When you purchase a number while a campaign is already active.** Surge attaches the number to the active campaign as part of the purchase transaction. The phone number response will show the campaign ID: ```json theme={null} { "id": "pn_01jrzhe8d9enptypyx360pcmxm", "number": "+18015559876", "type": "local", "campaign_id": "cpn_01jrzhe8d9enptypyx360pcmxl" } ``` **When a campaign becomes active while numbers already exist.** Once Surge approves a campaign and moves it to `active` status, any unattached phone numbers on the account are attached to the campaign automatically. ## Knowing when attachment completes Attachment happens asynchronously after campaign approval. Once complete, Surge fires the `phone_number.attached_to_campaign` webhook event: ```json theme={null} { "type": "phone_number.attached_to_campaign", "data": { "id": "pn_01jrzhe8d9enptypyx360pcmxm", "number": "+18015559876", "campaign_id": "cpn_01jrzhe8d9enptypyx360pcmxl" } } ``` Wait for this event before sending production messages from the number. You can also check whether a number is attached by listing your phone numbers (`GET /accounts/{account_id}/phone_numbers`) and inspecting the `campaign_id` field. ## The typical sequence 1. Create an account, submit a campaign (`POST /accounts/{account_id}/campaigns`) 2. Purchase one or more phone numbers (`POST /accounts/{account_id}/phone_numbers`), `campaign_id` is `null` at this point if the campaign isn't yet active 3. Campaign moves through `pending` → `in_review` → `active` 4. Surge auto-attaches all unattached numbers on the account to the newly active campaign 5. `phone_number.attached_to_campaign` fires for each number — numbers are now ready for production traffic If you purchase a number after the campaign is already active, steps 4 and 5 happen during the purchase in step 2. ## Multiple numbers on one campaign A campaign can have multiple phone numbers attached. This is common when you need to distribute sending volume or provide dedicated numbers for different teams or use cases under the same registered campaign. Sole proprietor campaigns are limited to a single phone number per campaign. ## Why there's no manual attachment endpoint Carriers require that every number be registered under a specific campaign. Surge enforces this by managing attachment automatically during campaign approval, ensuring numbers are never left in a permanently unattached state after a campaign becomes active. The webhook is the intended signal for readiness — listen for [`phone_number.attached_to_campaign`](../receiving/events#phone-number-attached-to-campaign) rather than polling. # Importing a Number Source: https://docs.surge.app/phone-numbers/importing Port an existing phone number into Surge or host an external number to route its messaging traffic through Surge. If you have an existing phone number with another provider, you have two options for bringing it into Surge: **porting** (transferring ownership) or **hosting** (keeping voice ownership with your current provider while routing messaging traffic through Surge). ## Porting Porting transfers your number from another carrier or provider into Surge's ownership. After porting, Surge owns and manages the number directly: you pay Surge for the number, and your previous provider's contract for that number ends. **When to port:** * You want to consolidate all messaging under a single provider * Your previous provider relationship is ending * You want full Surge management including TCR campaign registration **What to prepare:** * Your current provider account information * Proof of ownership (typically the latest bill or account statement) * The specific phone number(s) you want to port **How to start** Number porting requests can be created in the dashboard at [hq.surge.app](https://hq.surge.app): Import button in the top right corner of the "Phone numbers" page It will walk you through what information you need to provide: An example import form, showing fields to fill in, such as Account type, Business name, and Street address **Expected timeline:** Number porting typically takes 1-2 weeks depending on your current provider's responsiveness. ## Hosting Hosting lets you keep the number registered with its current provider while routing its messaging traffic through Surge. The number stays in your current provider's account; you configure it to forward inbound messages and calls to Surge. **When to use hosting:** * You want to try Surge without committing to a full port * Your number has contractual requirements that prevent porting * You're using the same number for purposes outside of SMS **How to start** Contact Surge support at `support@surge.app` with: * The phone number(s) you want to import * Whether you want to port or host * Your current provider name Support will walk you through the specific requirements for your situation. ## Knowing when import completes When Surge finishes importing a number, it fires the `phone_number.imported` webhook event: ```json theme={null} { "type": "phone_number.imported", "account_id": "acct_01japd271aeatb7txrzr2xj8sg", "timestamp": "2024-10-21T23:29:41Z", "data": { "id": "pn_01jsjwe4d9fx3tpymgtg958d9w", "name": "(801) 555-1234", "number": "+18015551234", "type": "local", "campaign": { "id": "cpn_01jjnn7s0zfx5tdcsxjfy93et2", "attachment_status": "attachment_pending" }, "campaign_id": "cpn_01jjnn7s0zfx5tdcsxjfy93et2" } } ``` # International Numbers Source: https://docs.surge.app/phone-numbers/international US and Canada numbers are available via API. Numbers in other countries are available through support on request. Surge's API supports purchasing and managing phone numbers in the United States and Canada. Numbers in other countries are available on a case-by-case basis through support. ## US and Canada Local numbers for US and Canadian area codes are available via the API: ```python Python theme={null} from surge import Surge surge = Surge() number = surge.phone_numbers.purchase( account_id="{account_id}", type="local", area_code="416", ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const number = await surge.phoneNumbers.purchase("{account_id}", { type: "local", area_code: "416", }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new number = surge.phone_numbers.purchase( "{account_id}", type: "local", area_code: "416" ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, number} = Surge.PhoneNumbers.purchase(client, "{account_id}", %{ type: "local", area_code: "416" }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/phone_numbers \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "local", "area_code": "416" }' ``` Canadian numbers follow the same 10-digit NANP format as US numbers. ## Other countries For phone numbers outside the US and Canada, contact Surge support. International numbers are reviewed individually and availability varies by country. What to include in your support request: * The country and region you need a number in * Your use case (SMS, voice, or both) * Expected monthly message volume * Your existing business registration in that country (if applicable) ## Regulatory considerations Carrier regulations differ significantly by country. SMS spam laws, opt-in requirements, and registration requirements outside North America vary, and in some markets they're stricter than the US A2P 10DLC framework. Surge's support team can advise on what's required for your target country before you begin the purchasing process. # Purchasing a Number Source: https://docs.surge.app/phone-numbers/purchasing Purchase local, toll-free, or short code phone numbers via API and understand per-plan phone number limits. Surge supports three number types. Local and toll-free numbers are available via API. Short codes require a support request. ## Prerequisites * **Active account and API key**: Both are required to call any phone number endpoint. * **Plan that allows the number count you want**: Hobby is capped at 1, Starter at 25, Growth and Custom are unlimited. * **A campaign to attach to (eventually)**: Numbers can be purchased before a campaign is active, but they can't send production traffic until they're attached. See [Attaching to a Campaign](./attaching). ## Number types | Type | Purchase path | Use case | | -------------- | ------------- | ------------------------------------------------ | | **Local** | API | Standard SMS/MMS, most campaigns | | **Toll-free** | API | High-volume transactional, customer care | | **Short code** | Support | Very high-volume or marketing to large audiences | The dashboard at [hq.surge.app](https://hq.surge.app) walks you through the same purchase via UI, useful for one-off numbers or for confirming pricing and approval timelines: Dashboard "Select number type" page comparing toll-free vs local with prices, setup fees, and registration approval times After choosing the type, the dashboard shows currently-available numbers with city/state for local numbers: Dashboard local-number search results with phone numbers and city/state ### Numbers selected before your subscription is active If you select a number in the dashboard before your subscription is active, Surge reserves it instead of purchasing it immediately. The dashboard lists it under **Pending phone numbers** with the note "These numbers will be purchased once your subscription is active." Surge purchases all pending numbers automatically when your subscription becomes active. You don't need to select the numbers again. If a pending number still hasn't been purchased after your subscription activates, contact support. ## Purchase a local number Pass `type: "local"` and an `area_code` to find an available number in that area: ```python Python theme={null} from surge import Surge surge = Surge() number = surge.phone_numbers.purchase( account_id="{account_id}", type="local", area_code="801", ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const number = await surge.phoneNumbers.purchase("{account_id}", { type: "local", area_code: "801", }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new number = surge.phone_numbers.purchase( "{account_id}", type: "local", area_code: "801" ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, number} = Surge.PhoneNumbers.purchase(client, "{account_id}", %{ type: "local", area_code: "801" }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/phone_numbers \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "local", "area_code": "801" }' ``` ```json theme={null} { "id": "pn_01jrzhe8d9enptypyx360pcmxm", "number": "+18015559876", "type": "local", "campaign_id": null } ``` If no numbers are available for the area code you specified, the API returns a `no_matching_numbers` error. Try a nearby area code or omit the `area_code` to get any available number. If your account has reached its phone number limit, the API returns a `phone_number_limit` error. Limits by plan: | Plan | Phone number limit | | ------- | ------------------ | | Hobby | 1 | | Starter | 25 | | Growth | Unlimited | | Custom | Unlimited | Contact support to upgrade your plan if you need more numbers. ## Purchase a toll-free number ```python Python theme={null} number = surge.phone_numbers.purchase( account_id="{account_id}", type="toll_free", ) ``` ```typescript TypeScript theme={null} const number = await surge.phoneNumbers.purchase("{account_id}", { type: "toll_free", }); ``` ```ruby Ruby theme={null} number = surge.phone_numbers.purchase( "{account_id}", type: "toll_free" ) ``` ```elixir Elixir theme={null} {:ok, number} = Surge.PhoneNumbers.purchase(client, "{account_id}", %{type: "toll_free"}) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/phone_numbers \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "toll_free" }' ``` Toll-free numbers need to go through verification before they can send production traffic. See [Toll-Free Verification](../registration/toll-free). ## List existing numbers Retrieve all phone numbers on an account: ```python Python theme={null} for number in surge.phone_numbers.list(account_id="{account_id}"): print(number.id, number.number) ``` ```typescript TypeScript theme={null} for await (const number of await surge.phoneNumbers.list("{account_id}")) { console.log(number.id, number.number); } ``` ```ruby Ruby theme={null} page = surge.phone_numbers.list("{account_id}") page.data.each { |n| puts "#{n.id} #{n.number}" } ``` ```elixir Elixir theme={null} # Surge.PhoneNumbers.list isn't typed in the Elixir SDK yet — use the raw client. {:ok, page} = Surge.Client.request(client, :get, "/accounts/{account_id}/phone_numbers") Enum.each(page["data"], fn n -> IO.puts("#{n["id"]} #{n["number"]}") end) ``` ```bash cURL theme={null} curl https://api.surge.app/accounts/{account_id}/phone_numbers \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "data": [ { "id": "pn_01jrzhe8d9enptypyx360pcmxm", "number": "+18015559876", "type": "local", "campaign_id": "cpn_01jrzhe8d9enptypyx360pcmxl" } ], "pagination": { "next_cursor": null, "previous_cursor": null } } ``` ## Attaching to a campaign A freshly purchased number has `campaign_id: null`. Surge attaches phone numbers to campaigns automatically. You don't call a separate endpoint. Two things trigger attachment: * **Purchase while a campaign is active**: Surge attaches the number immediately. * **Campaign becomes active after purchase**: Surge attaches all existing unattached numbers on the account when the campaign is approved. When attachment completes, Surge fires a `phone_number.attached_to_campaign` webhook event. Once you receive that event, the number can send production traffic. See [Attaching to a Campaign](./attaching). ## Short codes Short codes (5-6 digit numbers like `12345`) are available to all customers via a support request. They support very high message volumes and are the standard for large-scale marketing programs. Contact Surge support to start a short code request. # Send Your First Message Source: https://docs.surge.app/quickstart Send your first SMS in under five minutes using Surge's demo number. No registration or phone number purchase required. Send an SMS in under five minutes using Surge's demo number. No carrier registration, no phone number purchase required. ## Prerequisites * **Surge account**: Sign up at [hq.surge.app](https://hq.surge.app). * **API key**: Find it under **API Keys** in the left sidebar of the dashboard. * **Account ID**: Shown at the top of the dashboard, format `acct_01j...`. * **A phone number you can receive texts on**: Used as the `to` value in your first send. ## 1. Send a message Every outbound message goes through the same endpoint. Replace the account ID, API key, and `to` number with your own values. ```python Python theme={null} from surge import Surge surge = Surge() # uses SURGE_API_KEY env var message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Hello from Surge!", ) print(message.id) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); // uses SURGE_API_KEY env var const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Hello from Surge!", }); console.log(message.id); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new # uses SURGE_API_KEY env var message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Hello from Surge!" ) puts message.id ``` ```elixir Elixir theme={null} client = Surge.Client.new("YOUR_API_KEY") {:ok, message} = Surge.Messages.create( client, "{account_id}", %{to: "+18015551234", body: "Hello from Surge!"} ) IO.puts(message.id) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Hello from Surge!" }' ``` You don't need a `from` number. New accounts send from a demo number automatically. ## 2. Check the response A successful request returns the message object: ```json theme={null} { "id": "msg_01kqbhwra9egg8sdcsp9veg391", "body": "Hello from Surge!", "blast_id": null, "attachments": [], "metadata": {}, "conversation": { "id": "cnv_01kpc9b1jgepxazwjya3scxse4", "contact": { "id": "ctc_01kpc9b1jefg28pfpvf96t9ce6", "phone_number": "+18015551234", "first_name": null, "last_name": null, "email": null, "metadata": {} }, "phone_number": { "id": "pn_01kpc9b1jaeaht0an56f6r4p7v", "name": "Demo phone number", "type": "demo", "number": "+13854252553" } } } ``` Within a few seconds, the phone number you specified in `to` receives a text. If nothing arrives after 60 seconds, check that the number is in E.164 format: starting with `+` and country code, like `+18015551234`. ## 3. Watch for the demo limit The demo number has a cap of 25 outbound messages per account. When you hit the limit, the API returns a `403` with: ```json theme={null} { "error": { "type": "demo_message_limit", "message": "Demo messaging limit has been reached." } } ``` The demo number is SMS-only. It can't place voice calls or be used with voice-agent integrations (ElevenLabs, Retell, Vapi). To try voice or connect an AI voice agent, [register your business](./registration/index) and purchase a live number. ## Next steps Two things unlock production sending: register your business and purchase a phone number. Pick an SDK while you wait for review to clear. Get carrier approval for your brand and campaign. Pagination, error handling, and webhook signature verification out of the box. Buy a local or toll-free number and let Surge attach it to your campaign. Verify signatures and handle inbound messages, deliveries, and opt-outs. # Webhook Events Source: https://docs.surge.app/receiving/events A categorized index of webhook event types with links to each event's detailed reference page. Surge sends 14 event types. All events arrive at your configured webhook endpoint as HTTP POST requests with a JSON body. See [Receiving Messages & Webhooks](/receiving/index) for endpoint configuration and signature verification. See [Webhooks Overview](/api-reference/webhooks/intro) for the shared event envelope and retry behavior. ## Message events | Event | When it fires | | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | | [`message.sent`](/api-reference/webhooks/message/sent) | Surge accepts an outbound message and passes it to the carrier network. | | [`message.delivered`](/api-reference/webhooks/message/delivered) | The carrier confirms delivery to the recipient's handset. | | [`message.failed`](/api-reference/webhooks/message/failed) | An outbound message cannot be delivered. | | [`message.received`](/api-reference/webhooks/message/received) | An inbound message arrives from a contact to one of your phone numbers. | ## Contact events | Event | When it fires | | ---------------------------------------------------------------- | -------------------------------------------------------------------------- | | [`contact.opted_in`](/api-reference/webhooks/contact/opted_in) | A contact opts back in by sending a keyword like START, YES, or UNSTOP. | | [`contact.opted_out`](/api-reference/webhooks/contact/opted_out) | A contact opts out by sending a keyword like STOP, CANCEL, or UNSUBSCRIBE. | ## Conversation events | Event | When it fires | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`conversation.created`](/api-reference/webhooks/conversation/created) | A new conversation thread is created, usually on the first inbound message from a contact. | ## Campaign events | Event | When it fires | | ---------------------------------------------------------------- | -------------------------------------------------------- | | [`campaign.approved`](/api-reference/webhooks/campaign/approved) | A campaign transitions to active after carrier approval. | ## Phone number events | Event | When it fires | | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | [`phone_number.imported`](/api-reference/webhooks/phone_number/imported) | A phone number is imported into Surge from an external provider. | | [`phone_number.attached_to_campaign`](/api-reference/webhooks/phone_number/attached_to_campaign) | A phone number is successfully attached to a campaign and is ready for production messaging. | ## Link events | Event | When it fires | | -------------------------------------------------------- | ------------------------------------------------------------------ | | [`link.followed`](/api-reference/webhooks/link/followed) | A contact clicks a shortened link in a message for the first time. | ## Voice events | Event | When it fires | | -------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [`call.ended`](/api-reference/webhooks/call/ended) | A call ends, regardless of direction or how it was terminated. | | [`recording.completed`](/api-reference/webhooks/recording/completed) | A non-voicemail call recording finishes processing and is available for download. | | [`voicemail.received`](/api-reference/webhooks/voicemail/received) | A voicemail recording finishes processing and is available for download. | # Receiving Messages & Webhooks Source: https://docs.surge.app/receiving/index Configure a webhook endpoint, verify Surge webhook signatures, and handle incoming events in Python, TypeScript, Ruby, and Elixir. Surge sends HTTP POST requests to a URL you control whenever something happens: a message arrives, a campaign is approved, a contact opts out. You configure the URL in the dashboard and Surge takes care of delivery, retries, and ordering. ## 1. Configure your webhook endpoint Go to **Settings → Webhooks** in the dashboard at `hq.surge.app`. Add the URL where you want to receive events. Surge sends all event types to a single endpoint (you filter by `type` in your handler). Your endpoint must: * Be publicly accessible over `https://` (or `http://`, but HTTPS is strongly recommended) * Resolve to a public IP address * Return a `2xx` status code within the request timeout * Handle duplicate deliveries (Surge retries failed attempts, so your handler should be idempotent) **Surge does not follow redirects.** Only `2xx` responses count as successful delivery. Any `3xx` (including `301`, `302`, `307`, `308`) is treated as a failure and retried against the same URL. Configure the webhook URL to point directly at the final endpoint. Common causes of accidental redirects: trailing-slash rewrites, `http://` → `https://` upgrades, `www` normalization, and framework-level route rewrites. You can confirm your endpoint returns `2xx` by checking the delivery attempts under **Webhooks** in the dashboard. **Webhook URL restrictions.** Surge blocks webhook URLs that resolve to private IP ranges (`10.x.x.x`, `172.16.x.x`, `192.168.x.x`, `127.x.x.x`) or reserved TLDs (`.local`, `.internal`, `.localhost`, `.test`) to prevent server-side request forgery. This means `http://localhost:4000/webhooks` won't work — use a tunneling service like ngrok or Cloudflare Tunnel in development. ## 2. Verify the signature Every webhook request includes a `webhook-signature` header. Verifying it confirms the request came from Surge and wasn't tampered with in transit. Surge signs requests using HMAC-SHA256 following the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Use the `standardwebhooks` library in Python, TypeScript, or Ruby, or compute the signature directly in any language. Surge sets three headers on every request: `webhook-id`, `webhook-timestamp`, and `webhook-signature`. The library handles them for you in Python / TypeScript / Ruby; you read them directly when computing the signature manually (Elixir example below). ```python Python theme={null} from standardwebhooks import Webhook wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"]) try: payload = wh.verify(request.body, request.headers) # payload is the parsed event dict except Exception: return Response(status=400) ``` ```typescript TypeScript theme={null} import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.SURGE_WEBHOOK_SECRET!); try { const payload = wh.verify(rawBody, req.headers); // payload is the parsed event object } catch { res.status(400).end(); return; } ``` ```ruby Ruby theme={null} require "standardwebhooks" wh = StandardWebhooks::Webhook.new(ENV["SURGE_WEBHOOK_SECRET"]) begin payload = wh.verify(request.raw_post, request.headers) rescue StandardWebhooks::WebhookVerificationError head :bad_request and return end ``` ```elixir Elixir theme={null} defp verify_surge_signature(conn, secret) do webhook_id = get_req_header(conn, "webhook-id") |> List.first() timestamp = get_req_header(conn, "webhook-timestamp") |> List.first() signature = get_req_header(conn, "webhook-signature") |> List.first() body = conn.assigns[:raw_body] # Secret has a "whsec_" prefix; strip it and decode the base64 payload "whsec_" <> encoded = secret decoded_secret = Base.decode64!(encoded, padding: false) # Standard Webhooks payload format: "{webhook-id}.{webhook-timestamp}.{body}" payload = "#{webhook_id}.#{timestamp}.#{body}" expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64() Plug.Crypto.secure_compare(signature, "v1,#{expected}") end ``` ### Where to find your webhook secret Your signing secret is in **Settings → Webhooks** in the dashboard, next to your webhook endpoint URL. ## 3. Handle events A webhook event has this shape: ```json theme={null} { "type": "message.received", "data": { "id": "msg_01jav96823f9x9054d6gyzpp16", "blast_id": null, "body": "Hello there", "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", "phone_number": "+18015551234" } } } } ``` A minimal handler checks `type` and dispatches: ```python theme={null} @app.post("/webhooks/surge") async def surge_webhook(request: Request): payload = wh.verify(await request.body(), request.headers) match payload["type"]: case "message.received": await handle_inbound_message(payload["data"]) case "contact.opted_out": await handle_opt_out(payload["data"]) case _: pass # ignore events you don't need return Response(status_code=200) ``` Return `200` quickly. If your processing takes time, enqueue the work and return immediately — Surge will retry if your handler times out. ## Replay protection The Standard Webhooks spec includes a timestamp in the signature payload. Surge rejects replays outside a tolerance window. If you're testing with recorded payloads, make sure your test framework handles timestamp validation or disables it appropriately. ## Older signature header Earlier versions of Surge used `surge-signature` as the header name. This header is deprecated and being phased out. If your handler still reads `surge-signature`, migrate to `webhook-signature`. See [Deprecation Notices](../resources/deprecations). ## Next steps Every event type, its payload shape, and when it fires. Reply to inbound messages and keep conversation state in sync. Reference for the Standard Webhooks signature scheme Surge uses. Migrating from the older `surge-signature` header. # Two-Way Messaging Patterns Source: https://docs.surge.app/receiving/two-way Receive inbound messages, reply in the same thread, handle opt-outs inline, and avoid duplicate event processing. 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. ```json theme={null} { "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`](./events#conversationcreated) 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. ```python theme={null} @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/{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. ```python theme={null} 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. Tracking the state in your own system is still good practice 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. ```python theme={null} 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. # Register via API Source: https://docs.surge.app/registration/api-walkthrough Step-by-step: create an account, submit a campaign, purchase a phone number, and reach production via the Surge API. This walkthrough takes you from a fresh Surge project to a fully registered campaign with a dedicated phone number. If you submit all required fields in step 1, you can skip steps 2 and 3 entirely. **Sole proprietor without an EIN?** This walkthrough assumes you have a tax ID (EIN or CBN). If you don't, follow the [Sole Proprietor Path](./sole-proprietor) — different fields, no EIN required, and dashboard-first. ## Prerequisites * **Surge project and API key**: Create the project at [hq.surge.app](https://hq.surge.app) and copy a key from **API Keys**. * **Business tax ID**: Your EIN (US) or CBN (Canada). * **Privacy policy and terms of service URLs**: Both must be live and publicly indexable when reviewers visit them. ## 1. Create an account An account represents a brand in carrier terms. Create one with your business details: ```python Python theme={null} from surge import Surge surge = Surge() account = surge.accounts.create( name="Acme Corp", organization={ "type": "private_corporation", "registered_name": "Acme Corp Inc.", "identifier_type": "ein", "identifier": "12-3456789", "website": "https://acme.com", "industry": "technology", "regions_of_operation": ["usa_and_canada"], "email": "hello@acme.com", "address": { "line1": "123 Main St", "locality": "Salt Lake City", "region": "UT", "postal_code": "84101", "country": "US", }, "contact": { "first_name": "Jane", "last_name": "Smith", "email": "jane@acme.com", "phone_number": "+18015551234", "title": "ceo", }, }, ) print(account.id) # acct_01j... ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const account = await surge.accounts.create({ name: "Acme Corp", organization: { type: "private_corporation", registered_name: "Acme Corp Inc.", identifier_type: "ein", identifier: "12-3456789", website: "https://acme.com", industry: "technology", regions_of_operation: ["usa_and_canada"], email: "hello@acme.com", address: { line1: "123 Main St", locality: "Salt Lake City", region: "UT", postal_code: "84101", country: "US", }, contact: { first_name: "Jane", last_name: "Smith", email: "jane@acme.com", phone_number: "+18015551234", title: "ceo", }, }, }); console.log(account.id); // acct_01j... ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "organization": { "type": "private_corporation", "registered_name": "Acme Corp Inc.", "identifier_type": "ein", "identifier": "12-3456789", "website": "https://acme.com", "industry": "technology", "regions_of_operation": ["usa_and_canada"], "email": "hello@acme.com", "address": { "line1": "123 Main St", "locality": "Salt Lake City", "region": "UT", "postal_code": "84101", "country": "US" }, "contact": { "first_name": "Jane", "last_name": "Smith", "email": "jane@acme.com", "phone_number": "+18015551234", "title": "ceo" } } }' ``` The response includes your new account ID. Save it; all subsequent requests use it. ```json theme={null} { "id": "acct_01jrzhe8d9enptypyx360pcmxj", "name": "Acme Corp", "brand_name": null, "time_zone": null, "organization": { "type": "private_corporation", "registered_name": "Acme Corp Inc.", "identifier_type": "ein", "identifier": "12-3456789", "website": "https://acme.com", "industry": "technology", "regions_of_operation": ["usa_and_canada"], "email": "hello@acme.com", "address": { "line1": "123 Main St", "locality": "Salt Lake City", "region": "UT", "postal_code": "84101", "country": "US" }, "contact": { "first_name": "Jane", "last_name": "Smith", "email": "jane@acme.com", "phone_number": "+18015551234", "title": "ceo" } } } ``` If you're running this for each of your own customers, create one account per customer. See [One Account per Customer](./one-account-per-customer) for the reasoning. ## Optional: 2. Check what's missing The status endpoint tells you whether your account is ready to register a campaign and what fields are still needed: ```python Python theme={null} status = surge.accounts.retrieve_status( "acct_01jrzhe8d9enptypyx360pcmxj", capabilities=["local_messaging"], ) print(status.capabilities["local_messaging"].status) print(status.capabilities["local_messaging"].fields_needed) ``` ```typescript TypeScript theme={null} const status = await surge.accounts.retrieveStatus( "acct_01jrzhe8d9enptypyx360pcmxj", { capabilities: ["local_messaging"] }, ); console.log(status.capabilities.local_messaging.status); console.log(status.capabilities.local_messaging.fields_needed); ``` ```bash cURL theme={null} curl "https://api.surge.app/accounts/ACCOUNT_ID/status?capabilities=local_messaging" \ -H "Authorization: Bearer YOUR_API_KEY" ``` The response includes a `fields_needed` array listing any missing or invalid fields: ```json theme={null} { "capabilities": { "local_messaging": { "status": "incomplete", "fields_needed": ["organization.contact.phone_number", "organization.address.line1"], "errors": [] } } } ``` An empty `fields_needed` array and a `"ready"` status means you can proceed to create a campaign. ## Optional: 3. Fill the gaps If `fields_needed` is non-empty, patch the account to add the missing information. The field names in the response are dot-delimited paths into the account object: ```python Python theme={null} surge.accounts.update( "acct_01jrzhe8d9enptypyx360pcmxj", organization={ "contact": {"phone_number": "+18015551234"}, "address": {"line1": "123 Main St"}, }, ) ``` ```typescript TypeScript theme={null} await surge.accounts.update("acct_01jrzhe8d9enptypyx360pcmxj", { organization: { contact: { phone_number: "+18015551234" }, address: { line1: "123 Main St" }, }, }); ``` ```bash cURL theme={null} curl -X PATCH https://api.surge.app/accounts/ACCOUNT_ID \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "organization": { "contact": { "phone_number": "+18015551234" }, "address": { "line1": "123 Main St" } } }' ``` Repeat steps 2 and 3 until `capabilities.local_messaging.status` is `"ready"`. ## 4. Create a campaign A campaign tells carriers what you'll send and how users opted in. Every field here is read by human reviewers, so write for clarity. ```python Python theme={null} campaign = surge.campaigns.create( "acct_01jrzhe8d9enptypyx360pcmxj", description="Transactional and marketing messages for Acme Corp customers who have opted in via our checkout page.", use_cases=["marketing"], includes=["links"], consent_flow='Users enter their phone number on our checkout page. A checkbox labeled "I agree to receive text messages from Acme Corp" is present and unchecked by default. Required disclosures appear beneath the checkbox: "Message frequency varies. Msg&data rates apply. Reply STOP to opt out."', message_samples=[ "You are now opted in to messages from Acme Corp. Frequency varies. Msg&data rates apply. Reply STOP to opt out.", "Acme Corp: Your order #12345 has shipped. Track it here: https://acme.com/track", "Acme Corp: 20% off this weekend only. Shop now: https://acme.com/sale. Reply STOP to opt out.", ], volume="low", privacy_policy_url="https://acme.com/privacy", terms_and_conditions_url="https://acme.com/terms", ) print(campaign.id) # cpn_01j... print(campaign.status) # "in_review" ``` ```typescript TypeScript theme={null} const campaign = await surge.campaigns.create( "acct_01jrzhe8d9enptypyx360pcmxj", { description: "Transactional and marketing messages for Acme Corp customers who have opted in via our checkout page.", use_cases: ["marketing"], includes: ["links"], consent_flow: 'Users enter their phone number on our checkout page. A checkbox labeled "I agree to receive text messages from Acme Corp" is present and unchecked by default. Required disclosures appear beneath the checkbox: "Message frequency varies. Msg&data rates apply. Reply STOP to opt out."', message_samples: [ "You are now opted in to messages from Acme Corp. Frequency varies. Msg&data rates apply. Reply STOP to opt out.", "Acme Corp: Your order #12345 has shipped. Track it here: https://acme.com/track", "Acme Corp: 20% off this weekend only. Shop now: https://acme.com/sale. Reply STOP to opt out.", ], volume: "low", privacy_policy_url: "https://acme.com/privacy", terms_and_conditions_url: "https://acme.com/terms", }, ); console.log(campaign.id); // cpn_01j... console.log(campaign.status); // "in_review" ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/ACCOUNT_ID/campaigns \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "description": "Transactional and marketing messages for Acme Corp customers who have opted in via our checkout page.", "use_cases": ["marketing"], "includes": ["links"], "consent_flow": "Users enter their phone number on our checkout page. A checkbox labeled \"I agree to receive text messages from Acme Corp\" is present and unchecked by default. Required disclosures appear beneath the checkbox: \"Message frequency varies. Msg&data rates apply. Reply STOP to opt out.\"", "message_samples": [ "You are now opted in to messages from Acme Corp. Frequency varies. Msg&data rates apply. Reply STOP to opt out.", "Acme Corp: Your order #12345 has shipped. Track it here: https://acme.com/track", "Acme Corp: 20% off this weekend only. Shop now: https://acme.com/sale. Reply STOP to opt out." ], "volume": "low", "privacy_policy_url": "https://acme.com/privacy", "terms_and_conditions_url": "https://acme.com/terms" }' ``` The campaign goes straight to `in_review`: ```json theme={null} { "id": "cpn_01jrzhe8d9enptypyx360pcmxl", "status": "in_review", "description": "Transactional and marketing messages for Acme Corp customers who have opted in via our checkout page.", "use_cases": ["marketing"], "includes": ["links"], "volume": "low", "consent_flow": "Users enter their phone number on our checkout page...", "message_samples": [ "You are now opted in to messages from Acme Corp. Frequency varies. Msg&data rates apply. Reply STOP to opt out.", "Acme Corp: Your order #12345 has shipped. Track it here: https://acme.com/track", "Acme Corp: 20% off this weekend only. Shop now: https://acme.com/sale. Reply STOP to opt out." ], "privacy_policy_url": "https://acme.com/privacy", "terms_and_conditions_url": "https://acme.com/terms" } ``` A few things about campaign fields: * **`volume`**: `"low"` allows up to 2,000 SMS segments/day to T-Mobile. `"high"` allows up to 200,000/day, depending on the trust score assigned by The Campaign Registry. * **`use_cases`**: The types of messages you'll send. Common values: `marketing`, `customer_care`, `account_notification`, `two_factor_authentication`. See the [Schema Reference](./schema-reference) for the full list. * **`message_samples`**: 2–5 examples of what you'll actually send. The first should be a compliance opt-in confirmation. Use real brand names, not placeholders. When the status changes to `active`, Surge fires a [`campaign.approved`](../receiving/events#campaignapproved) webhook event. The campaign is approved, but **a number isn't ready to send production messages until it's also been attached to the campaign**. Surge starts attaching numbers automatically as soon as the campaign goes active; the [`phone_number.attached_to_campaign`](../api-reference/webhooks/phone_number/attached_to_campaign) webhook fires per number once the attachment completes. Wait for that event before sending real traffic from a given number. ## 5. Purchase a phone number You can buy a number while your campaign is in review. You just can't send production messages from it until two things have happened: the campaign is `active`, and the number has been attached to it. After the campaign is approved, attachment runs automatically; you'll receive a [`phone_number.attached_to_campaign`](../api-reference/webhooks/phone_number/attached_to_campaign) webhook for each number once it's ready. ```python Python theme={null} phone_number = surge.phone_numbers.purchase( "acct_01jrzhe8d9enptypyx360pcmxj", type="local", area_code="801", ) print(phone_number.number) # +18015559876 ``` ```typescript TypeScript theme={null} const phoneNumber = await surge.phoneNumbers.purchase( "acct_01jrzhe8d9enptypyx360pcmxj", { type: "local", area_code: "801", }, ); console.log(phoneNumber.number); // +18015559876 ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/ACCOUNT_ID/phone_numbers \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "local", "area_code": "801" }' ``` ```json theme={null} { "id": "pn_01jrzhe8d9enptypyx360pcmxm", "number": "+18015559876", "type": "local", "campaign_id": null } ``` `campaign_id` is `null` because the campaign isn't active yet. Surge attaches phone numbers to campaigns automatically. Two things trigger attachment: * **Purchase while a campaign is active**: Surge attaches the number immediately. * **Campaign becomes active after purchase**: Surge attaches all existing unattached numbers on the account when the campaign is approved. When attachment completes, Surge fires a `phone_number.attached_to_campaign` webhook event. Once you receive that event, the number can send production traffic. ## What's next * Review the [Schema Reference](./schema-reference) for every valid value of `type`, `industry`, `use_cases`, and other enum fields * Learn what trips up most registrations in [Avoiding Rejection](./avoiding-rejection) * Start [sending messages](../sending/send-one) with your new number # Avoiding Rejection: Patterns from Real Registration Reviews Source: https://docs.surge.app/registration/avoiding-rejection The seven most common campaign rejection reasons with real reviewer quotes and specific fixes for each. Most rejections and "changes needed" outcomes fall into seven categories, drawn from clustering real reviewer feedback in registration review emails. Addressing these before you submit is faster than iterating after rejection. Ready to submit? See [Register via API](./api-walkthrough) or [Register via Dashboard](./dashboard). *** ## Opt-in flow issues This is the most common rejection reason, and it covers two related problems: missing consent disclosures at the point of collection, and making SMS consent a condition of signup. **What reviewers look for:** At minimum, a phone number input with explicit consent disclosures visible nearby. The disclosures must include message frequency, "Msg\&data rates apply", and "Reply STOP to opt out." Real reviewer feedback: * *"Typically we expect at the very least a phone number input and the consent disclosures shown in the registration flow."* * *"The carriers don't allow SMS consent to be a requirement for signup."* * *"It collects a phone number, but it would need to have checkboxes for transactional and marketing SMS consent."* * *"Looking at the contact form on \[domain], it doesn't contain the necessary consent disclosures to be able to collect SMS consent."* **What to fix:** * Add an unchecked opt-in checkbox specifically for SMS (do not bundle it with general terms agreement) * Display the three required disclosures near the phone number field * Never pre-check the consent box *** ## Business identity consistency Carriers expect the name, domain, and contact information on your registration to match what they can verify publicly. Mismatches between your website, your email domain, and your registered business name cause rejections. Real reviewer feedback: * *"The website doesn't give much info."* * *"The email address you provided doesn't match the business domain, and they expect that to match or to use a common email domain like gmail."* **What to fix:** * Use an email address at your business domain (`hello@acme.com`, not `acme_hello@gmail.com`) * Make sure your website clearly describes what your business does, a blank site, a coming-soon page, or a generic template fails review * Ensure your registered business name, website, and social media profiles all use the same name *** ## Privacy policy issues Your privacy policy must be publicly accessible, must not contain language that implies SMS opt-in through agreeing to the policy itself, and must state that SMS opt-in consent won't be shared with third parties. Real reviewer feedback: * *"Right now you don't appear to have a privacy policy or terms page."* * *"The first two paragraphs should be removed because they say that anyone agreeing to the terms and conditions is opting in to receive text messages, and that won't work because they have to explicitly opt in to receive messages."* **What to fix:** * Publish a privacy policy at a publicly accessible URL (not behind a login) * Remove any language implying that agreeing to the policy constitutes SMS consent * Link to the privacy policy directly from your registration form *** ## Terms of service issues Same accessibility principle as the privacy policy: publicly accessible, not behind a login. Your terms should list the types of messages you'll send and include the phrase "Carriers are not liable for delayed or undelivered messages." Real reviewer feedback: * *"The privacy policy and terms of service are both behind a login."* **What to fix:** * Host both documents at public URLs * The terms page should be indexable, no robots.txt exclusions *** ## Sample message issues Message samples are read literally by reviewers. Placeholder tokens left unreplaced are flagged immediately. Real reviewer feedback: * *"The sample message says 'this is \{Agent Name} from \{Agency}'."* **What to fix:** * When sending as a brand other than your own, replace template tokens (like `{Agent Name}` or `{Agency}`) with realistic example values. Generic placeholders like `[contact name]` or `[link]` are fine, and encouraged * Make samples reflect what you'll actually send. Don't submit generic lorem ipsum messages * If you're sending from an agent or on behalf of a client, use the real brand name *** ## Brand vs sender identity mismatch Every message you send must come from the brand registered on that account. If you're sending on behalf of a client, the registration must reflect the client's brand, not yours. Real reviewer feedback: * *"Every message must match the brand to which the phone number is registered."* **What to fix:** * For each client or brand, create a separate account registered under that client's legal entity * Do not use one campaign to send messages under multiple brand names See [One Account per Customer](./one-account-per-customer) for the architectural pattern. *** ## Business type / EIN mismatch Your business type must match how you're actually incorporated. Registering as `sole_proprietor` when you have an LLC or corporation is a mismatch that reviewers catch. Real reviewer feedback: * *"Because your business name is \[X] LLC, you will need to register as a private corporation rather than a sole proprietor, which means you will need to provide an EIN."* **What to fix:** * If your business name includes "LLC", use `llc` * If it includes "Inc." or "Corp.", use `private_corporation` (or `public_corporation` if publicly traded) * `sole_proprietor` is for unincorporated individuals only and does not require an EIN # Register via Dashboard Source: https://docs.surge.app/registration/dashboard Register your brand and campaign using the Surge dashboard form, the current path for toll-free verification. The dashboard registration form at `hq.surge.app` walks you through the same fields as the API, with step-by-step guidance and inline validation. It's the fastest path for direct businesses registering once. It's also the only path for toll-free number verification today — there is no API equivalent yet. ## Prerequisites Before you start, have these ready: * **Surge dashboard access**: Sign in at [hq.surge.app](https://hq.surge.app). * **An account selected**: Pick one from the **Accounts** page or create a new one. * **Business and campaign details**: Tax ID, website URL, privacy and terms URLs, opt-in flow description, 2–5 sample messages. ## Open the registration form On the chosen account, click **Register** or **Complete Registration** to launch the form. Right now, this is only available from the phone number details page. The Accounts list in the Surge dashboard The form is a 10-step wizard with a progress sidebar on the left. ## 1. Business type Carriers require a tax ID for every business that sends A2P text messages. If your business has a US Employer Identification Number (EIN), choose **Yes, I have an EIN**. Sole proprietors without an EIN choose **No, I do not** and follow the [Sole Proprietor Path](./sole-proprietor). Business type step asking whether the business has an EIN ## 2. Authorized representative The person who has authority to register this brand for messaging. If you're registering on behalf of one of your customers (a vertical SaaS pattern), this must be a representative of the customer's brand, not someone from your platform. They may be contacted by Surge or carrier partners if reviewers have questions. Authorized representative form with name, title, email, and phone fields ## 3. Business information Legal business name, EIN, website URL, industry, and regions of operation. The website must be publicly accessible and clearly reflect the brand, reviewers visit it and reject brands whose sites don't match the registration. See [Avoiding Rejection → Business identity consistency](./avoiding-rejection#business-identity-consistency) for the most common pitfalls here. Business information form with EIN, business name, website, industry, and regions ## 4. Business address The legal address registered to the business. This must match what's on the EIN/CBN registration record carriers cross-check against. ## 5. Campaign What you're sending: use cases (marketing, customer care, two-factor auth, etc.), expected volume, whether messages contain links or phone numbers, and 2–5 sample messages. Campaign step with use cases, low/high volume tiers ## 6. Privacy policy The URL of your published privacy policy. Must be publicly accessible (not behind a login) and must reference your text-messaging program. Privacy policy URL field ## 7. Terms and conditions The URL of your published terms of service. Same accessibility rules as the privacy policy. Terms and conditions URL field ## 8. Opt-in method How users consent to receive messages. Pick from web form, mobile QR code, paper form, verbal consent, or a keyword reply. This maps to the `consent_flow` field in the API. Opt-in method selection ## 9. Opt-in details A plain-English description of your opt-in flow plus the URL where the opt-in happens. Reviewers visit this URL, so it must be live and show the consent flow you described. See [Schema Reference → consent\_flow](./schema-reference#consent_flow) for what makes a strong description. Opt-in details with description and URL ## 10. Confirm A read-only summary of every value you've entered. Edit any section directly from this page; only submit once everything looks right. Campaign Summary review page ## Sole proprietor path If you selected **No, I do not** on Step 1, you're routed through a sole-proprietor variant of the form with different disclosure requirements. See [Sole Proprietor Path](./sole-proprietor) for details. ## After you submit The form submits everything to Surge, which sends it to the carrier network. You'll receive an email when the campaign status changes. If the campaign status becomes `changes_needed`, Surge sends an email with reviewer feedback — see [Fixing a Rejected or Changes-Needed Campaign](./fixing-rejected) for how to respond. ## Toll-free registration Toll-free verification is dashboard-only and uses a separate flow from 10DLC. See [Toll-Free Verification](./toll-free). # Fixing a Rejected or Changes-Needed Campaign Source: https://docs.surge.app/registration/fixing-rejected How to read reviewer feedback, update a campaign in a valid status, and get back to production after rejection. Campaign reviews are done by human reviewers, not automated systems. When they need something from you, the campaign status changes to `changes_needed` or `rejected`. Here's how to read the feedback and get your campaign approved. ## Campaign status lifecycle ```mermaid theme={null} stateDiagram-v2 [*] --> created created --> pending pending --> in_review in_review --> active in_review --> changes_needed in_review --> rejected changes_needed --> pending : resubmit ``` The diagram shows the typical review lifecycle. `deactivated` and `canceled` are administrative states reachable from `active` (and earlier states for `canceled`); see the list below. * **`created`**: you've saved the campaign fields, but it hasn't been submitted to Surge yet * **`pending`**: submitted, waiting for Surge to review it before sending it to the carriers * **`in_review`**: waiting for the carriers to review it * **`active`**: approved; you can attach phone numbers and send production traffic * **`changes_needed`**: reviewer flagged issues; you need to update and resubmit * **`rejected`**: campaign was rejected; see notes on appealing below * **`deactivated`**: previously active campaign that you or Surge canceled * **`canceled`**: you or Surge canceled the campaign * **`suspended`**: previously active campaign suspended by carriers **API status values.** The campaign `status` field in API responses uses a condensed set of public values: `created`, `in_review`, `active`, `rejected`, `canceled`, `deactivated`. Both the `pending` and `in_review` internal states surface as `"in_review"` in the API. Both the `changes_needed` and `rejected` internal states surface as `"rejected"`. Keep this in mind when polling for status or using the update table below. ## Understanding "changes needed" feedback When a campaign moves to `changes_needed`, Surge sends you an email with reviewer notes. Read it carefully — reviewers include specific details about what needs to change. Common patterns and what they mean: | Reviewer says | What to fix | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | "The website doesn't give much info" | Add clear business description and contact information to your homepage | | "The opt-in page doesn't show required disclosures" | Add message frequency notice, "Msg\&data rates apply", and "Reply STOP to opt out" next to the opt-in checkbox | | "The sample message contains placeholder text" | Replace `{brand name}`, `{Agent Name}`, etc. with actual values | | "The privacy policy is behind a login" | Move the privacy policy to a publicly accessible URL | | "Your business email doesn't match the domain" | Use an email at your business domain, not a personal or generic address | ## Updating a campaign To address feedback, update the relevant campaign fields and resubmit. Campaigns can only be updated in certain statuses: | Status (API value) | Can update? | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created` | Yes | | `in_review` | **Usually no**: returns `campaign_locked` (409) once a reviewer picks it up. A brief window exists while the campaign is still queued (internally `pending`) before review begins. | | `rejected` | Yes, both "changes needed" and hard-rejected campaigns can be updated. | | `canceled` | Yes | | `active` | **No**: returns `campaign_locked` (409) | | `deactivated` | **No**: returns `campaign_locked` (409) | If you try to update while `in_review`, wait for the review to complete. The reviewer will either approve it, request changes (moving it to `changes_needed`), or reject it. Only then can you update. The campaign update endpoint requires the **full** field set, not a partial body. Send `consent_flow`, `description`, `message_samples` (≥2 items), `privacy_policy_url`, `terms_and_conditions_url`, `use_cases`, and `volume` together. Missing any field returns a `validation_error`. ```python Python theme={null} from surge import Surge surge = Surge() # reads SURGE_API_KEY from environment campaign = surge.campaigns.update( "{campaign_id}", consent_flow=( "Users enter their phone number during checkout. An unchecked checkbox " 'labeled "I agree to receive text messages from Acme Corp" is present. ' 'Required disclosures are shown: "Message frequency varies. Msg&data ' 'rates apply. Reply STOP to opt out. View our Privacy Policy at ' 'acme.com/privacy."' ), message_samples=[ "Acme Corp: Your order #12345 has shipped! Track it: https://acme.com/track", "Acme Corp: Your cart is waiting. Complete your order: https://acme.com/cart. Reply STOP to opt out.", ], description="Marketing messages to opted-in customers", privacy_policy_url="https://acme.com/privacy", terms_and_conditions_url="https://acme.com/terms", use_cases=["marketing"], volume="low", ) print(campaign.status) # "in_review" ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const campaign = await surge.campaigns.update("{campaign_id}", { consent_flow: 'Users enter their phone number during checkout. An unchecked checkbox ' + 'labeled "I agree to receive text messages from Acme Corp" is present. ' + 'Required disclosures are shown: "Message frequency varies. Msg&data ' + 'rates apply. Reply STOP to opt out. View our Privacy Policy at ' + 'acme.com/privacy."', message_samples: [ "Acme Corp: Your order #12345 has shipped! Track it: https://acme.com/track", "Acme Corp: Your cart is waiting. Complete your order: https://acme.com/cart. Reply STOP to opt out.", ], description: "Marketing messages to opted-in customers", privacy_policy_url: "https://acme.com/privacy", terms_and_conditions_url: "https://acme.com/terms", use_cases: ["marketing"], volume: "low", }); console.log(campaign.status); // "in_review" ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new campaign = surge.campaigns.update( "{campaign_id}", consent_flow: 'Users enter their phone number during checkout. An unchecked checkbox ' \ 'labeled "I agree to receive text messages from Acme Corp" is present. ' \ 'Required disclosures are shown: "Message frequency varies. Msg&data ' \ 'rates apply. Reply STOP to opt out. View our Privacy Policy at ' \ 'acme.com/privacy."', message_samples: [ "Acme Corp: Your order #12345 has shipped! Track it: https://acme.com/track", "Acme Corp: Your cart is waiting. Complete your order: https://acme.com/cart. Reply STOP to opt out." ], description: "Marketing messages to opted-in customers", privacy_policy_url: "https://acme.com/privacy", terms_and_conditions_url: "https://acme.com/terms", use_cases: ["marketing"], volume: "low" ) puts campaign.status # "in_review" ``` ```elixir Elixir theme={null} # Surge.Campaigns.update/3 isn't typed in the Elixir SDK yet — use the raw client. client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, campaign} = Surge.Client.request(client, :patch, "/campaigns/{campaign_id}", json: %{ consent_flow: "Users enter their phone number during checkout. An unchecked checkbox " <> "labeled \"I agree to receive text messages from Acme Corp\" is present. " <> "Required disclosures are shown: \"Message frequency varies. Msg&data " <> "rates apply. Reply STOP to opt out. View our Privacy Policy at " <> "acme.com/privacy.\"", message_samples: [ "Acme Corp: Your order #12345 has shipped! Track it: https://acme.com/track", "Acme Corp: Your cart is waiting. Complete your order: https://acme.com/cart. Reply STOP to opt out." ], description: "Marketing messages to opted-in customers", privacy_policy_url: "https://acme.com/privacy", terms_and_conditions_url: "https://acme.com/terms", use_cases: ["marketing"], volume: "low" } ) IO.puts(campaign["status"]) # "in_review" ``` ```bash cURL theme={null} curl -X PATCH https://api.surge.app/campaigns/{campaign_id} \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "consent_flow": "Users enter their phone number during checkout. An unchecked checkbox labeled \"I agree to receive text messages from Acme Corp\" is present. Required disclosures are shown: \"Message frequency varies. Msg&data rates apply. Reply STOP to opt out. View our Privacy Policy at acme.com/privacy.\"", "message_samples": [ "Acme Corp: Your order #12345 has shipped! Track it: https://acme.com/track", "Acme Corp: Your cart is waiting. Complete your order: https://acme.com/cart. Reply STOP to opt out." ], "description": "Marketing messages to opted-in customers", "privacy_policy_url": "https://acme.com/privacy", "terms_and_conditions_url": "https://acme.com/terms", "use_cases": ["marketing"], "volume": "low" }' ``` After updating a `changes_needed`, `rejected`, or `canceled` campaign, the campaign moves back to `pending` and then `in_review`. ## Checking campaign status Poll the campaign endpoint to track the current status: ```python Python theme={null} from surge import Surge surge = Surge() campaign = surge.campaigns.retrieve("{campaign_id}") print(campaign.status) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const campaign = await surge.campaigns.retrieve("{campaign_id}"); console.log(campaign.status); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new campaign = surge.campaigns.retrieve("{campaign_id}") puts campaign.status ``` ```elixir Elixir theme={null} # Surge.Campaigns.get isn't typed in the Elixir SDK yet — use the raw client. client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, campaign} = Surge.Client.request(client, :get, "/campaigns/{campaign_id}") IO.puts(campaign["status"]) ``` ```bash cURL theme={null} curl https://api.surge.app/campaigns/{campaign_id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "id": "cpn_01jrzhe8d9enptypyx360pcmxl", "status": "in_review", "use_cases": ["marketing"], "volume": "low" } ``` Or subscribe to the `campaign.approved` webhook event to get notified the moment a campaign goes `active`. This avoids polling. See [Webhook Events](../receiving/events). ## Rejected campaigns A `rejected` status is more serious than `changes_needed`. Unlike `changes_needed`, a rejection doesn't automatically move the campaign back into the queue when you update it — you may need to create a new campaign entirely. Rejections related to spam or misuse may also affect your account's ability to register future campaigns. If your campaign is rejected: Reviewers include the specific reason. Don't guess. Partial fixes typically lead to another rejection. Confirm whether to resubmit the existing campaign or start a new one — the right move depends on the reason. Make sure your opt-in flow, sample messages, and business identity are solid before resubmitting. If you're unsure what went wrong, [Avoiding Rejection](./avoiding-rejection) covers the seven most common patterns. # Register Your Business Source: https://docs.surge.app/registration/index Carrier registration overview: what you need, how the review process works, and how to choose your registration path. To crack down on spam and other unwanted messaging, carriers must know the legal entity responsible for every text message sent from software to a device on their network. Before your messages will be delivered at scale from a dedicated number, you need to register two things: a **brand** (your business identity) and a **campaign** (what you're sending and why). Surge's Account → Campaign model maps directly to what carriers call a brand and its associated campaigns. | Surge term | Carrier term | What it represents | | ---------- | ------------ | ------------------------------------------ | | Account | Brand | A legal business entity | | Campaign | Campaign | A registered use case for sending messages | Registration typically completes in 24–48 hours, with a 72-hour maximum response window. ## Two paths to registration The preferred path. Use it if you're provisioning accounts for customers, automating onboarding, or just prefer working in code. Covers account creation through phone number purchase. The form-based alternative at `hq.surge.app`. Mirrors the API fields. Toll-free number verification is currently only available here (API support is planned). Pick the API path unless you need toll-free verification today, in which case start with the dashboard. ## What you'll need To complete registration you need: * Your business's legal name, EIN (US) or CBN (Canada), and registered address * A publicly accessible website that clearly reflects your business * A privacy policy and terms of service (both must be publicly accessible, not behind a login) * A clear description of how users opt in to receive your messages * 2–5 sample messages that represent what you'll actually send ## What happens after you submit After you submit a campaign, Surge sends it to The Campaign Registry (TCR) and your carrier network for review. The campaign enters `in_review` immediately and moves to `active` once approved: ``` in_review → active ``` If reviewers find issues with your registration, the status becomes `rejected` with details about what needs to change. See [Fixing a Rejected Campaign](./fixing-rejected/) for how to read that feedback and resubmit. Once your campaign reaches `active`, Surge fires a `campaign.approved` webhook event and starts attaching any phone numbers on the account to the campaign. **Phone numbers can't send production messages until they're attached to a campaign**: even on an active campaign, attachment runs as a follow-up step. Listen for the [`phone_number.attached_to_campaign`](../api-reference/webhooks/phone_number/attached_to_campaign) webhook event for each number; that's the signal a specific number is ready for production traffic. ## Before you register If you're building software that registers brands on behalf of your own customers, read [Building on Surge: One Account per Customer](./one-account-per-customer) before you start. The architecture decisions you make at this stage are difficult to undo. Want to understand what trips up most registrations? [Avoiding Rejection](./avoiding-rejection) covers the seven most common failure patterns with real reviewer feedback. # Building on Surge: One Account per Customer Source: https://docs.surge.app/registration/one-account-per-customer Why vertical SaaS platforms should create one Surge account per customer and how to implement the pattern. If you're building a SaaS platform and you want your customers to send messages under their own brand, the right architecture is one Surge account per customer. This pattern is called multi-tenant messaging, and the design decisions you make here are much harder to undo than to get right from the start. ## The core expectation When you send messages through Surge, carriers ask: *whose brand is behind this message?* The brand registered on the account must match the brand the message recipient expects to see as the sender. Every number should be registered to the business from which the end customer thinks they're receiving a message. If the message is presented as coming from your platform, and the recipient opted in to messages from your platform, a single platform account can be the right fit. If the message is presented as coming from one of your customers, that customer's business needs its own account, campaign, and number. For example, imagine a communication platform for dental offices called "DentalSoft" with customers like "ABC Dental" and "XYZ Dental." DentalSoft could use one DentalSoft account for payment receipts or appointment reminders that clearly identify DentalSoft as the sender and rely on patient opt-in to DentalSoft. But if a patient opted in to messages from ABC Dental, or reasonably believes ABC Dental is messaging them directly, that number should be registered to ABC Dental. XYZ Dental would need its own account for the same reason. ```mermaid theme={null} flowchart TD P["Your Platform
(one API key)"] P --> A1["Account: Smith Realty
org.type: llc"] P --> A2["Account: Jones Dental
org.type: llc"] P --> A3["Account: Acme Co
org.type: private_corporation"] A1 --> C1["Campaign + Phone Number
brand: Smith Realty"] A2 --> C2["Campaign + Phone Number
brand: Jones Dental"] A3 --> C3["Campaign + Phone Number
brand: Acme Co"] ``` ## Why separate accounts matter **Compliance partitioning.** If one of your customers gets flagged for spam, whether it's a rogue employee or a misunderstanding, only their account is affected. Their campaign suspension doesn't touch your other customers. A single shared account means a flag on one customer's campaign can take down messaging for every customer. **Cost attribution.** Each account bills independently, so you can pass usage costs through to your customers without building a message-counting system on your end. Surge's usage reports are already broken down by account. **Scaling isolation.** A customer with a sudden volume spike doesn't consume rate-limit headroom for your other customers. Message delivery, error rates, and carrier relationships are isolated per account. ## The biggest gotcha **The brand you register must match what message recipients see.** Say you're building a platform for real estate agents. You create an account for agent Jane Smith, register it under "Smith Realty LLC," and purchase a number for her. Every message Jane sends through that number should identify itself as coming from Smith Realty, not from your platform's brand. If the message has a "Powered by YourPlatform" attribution alongside the client's brand, that is fine. But if the registration says "YourPlatform Co" but the message says "Smith Realty," or if you use a shared number across multiple agents' accounts, it will get flagged. ## How to implement the pattern Call `POST /accounts` with the customer's business name. Store the returned `id` alongside their record in your database — every subsequent call uses it. ```bash theme={null} curl -X POST https://api.surge.app/accounts \ -H "Authorization: Bearer YOUR_PLATFORM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Smith Realty", "brand_name": "Smith Realty" }' ``` ```json theme={null} { "id": "acct_01jrzhe8d9enptypyx360pcmxl", "name": "Smith Realty", "brand_name": "Smith Realty" } ``` Save `acct_01jrzhe8d9enptypyx360pcmxl` against your customer's row. Collect EIN, website, address, and authorized representative from the customer, then submit via `PATCH /accounts/{account_id}`. Submit it all at once — partial submissions can't enter review. See [Register via API → Account fields](./api-walkthrough) for the full body shape. `POST /accounts/{account_id}/campaigns` with their use case, opt-in flow, and sample messages. The campaign reflects **their** brand, not yours. See [Schema Reference → Campaign fields](./schema-reference#campaign-fields) for required values. `POST /accounts/{account_id}/phone_numbers`. Once the campaign reaches `active`, Surge auto-attaches the number — see [Attaching numbers](../phone-numbers/attaching). Use the customer's account ID in the path. The platform API key authorizes the call: ```bash theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_PLATFORM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+15551234567", "body": "Smith Realty: Showing confirmed for Saturday at 2pm." }' ``` See [POST /messages](../api-reference/endpoint/messages/create) for the full request and response shapes. You don't need separate API keys per customer. One project-level API key authorizes every account in your project. ## Checking registration status programmatically When you create an account for a customer, you may not have all their business information up front. The status endpoint lets you check what's still needed: ```bash theme={null} curl "https://api.surge.app/accounts/{account_id}/status?capabilities=local_messaging" \ -H "Authorization: Bearer YOUR_PLATFORM_API_KEY" ``` The `fields_needed` array tells you exactly what to collect before you can submit a campaign. Build this check into your onboarding flow so customers see a guided setup experience rather than a registration failure. # Canonical Schema Reference Source: https://docs.surge.app/registration/schema-reference All valid enum values for organization type, industry, regions, campaign use cases, content includes, and volume. Valid values for all enum fields used in account and campaign registration. Every field listed here is read by a human reviewer, so use the most accurate value, not the most convenient one. For context on how these fields fit into the registration flow, see [Register via API](./api-walkthrough). ## Account fields ### `organization.type` The legal structure of your business. | Value | Description | | --------------------- | ------------------------------------------------------------------------------------ | | `co_op` | Cooperative | | `government` | Government or government agency | | `llc` | Limited Liability Company | | `non_profit` | Non-profit organisation | | `partnership` | General or limited partnership | | `private_corporation` | Privately held corporation | | `public_corporation` | Publicly traded corporation | | `sole_proprietor` | Sole proprietorship (no EIN required; see [Sole Proprietor Path](./sole-proprietor)) | If your business name ends in "LLC" or "Inc.", use `llc` or `private_corporation` respectively — not `sole_proprietor`. Sole proprietors don't have an EIN and aren't incorporated. This mismatch is one of the most common rejection reasons. ### `organization.identifier_type` The type of business registration number you're providing. | Value | Description | | ----- | ----------------------------------------------------------------------- | | `ein` | US Employer Identification Number (format: `XX-XXXXXXX` or `XXXXXXXXX`) | | `cbn` | Canadian Business Number (exactly 9 digits) | More identifier types are on the roadmap. Contact support if your business uses a different identifier. ### `organization.industry` Choose the category that best matches your primary business activity. | | | | | ---------------------------- | ---------------- | ------------- | | `agriculture` | `automotive` | `banking` | | `construction` | `consumer` | `education` | | `electronics` | `energy` | `engineering` | | `fast_moving_consumer_goods` | `financial` | `fintech` | | `food_and_beverage` | `government` | `healthcare` | | `hospitality` | `insurance` | `jewelry` | | `legal` | `manufacturing` | `media` | | `not_for_profit` | `oil_and_gas` | `online` | | `professional_services` | `raw_materials` | `real_estate` | | `religion` | `retail` | `technology` | | `telecommunications` | `transportation` | `travel` | ### `organization.regions_of_operation` The geographic regions where your business operates. Accepts an array. | Value | Description | | ---------------- | ------------------------ | | `africa` | Africa | | `asia` | Asia | | `australia` | Australia and Oceania | | `europe` | Europe | | `latin_america` | Latin America | | `usa_and_canada` | United States and Canada | Most US-based businesses use `["usa_and_canada"]`. ### `organization.contact.title` The job title of the authorized representative submitting the registration. | Value | Description | | ----------------- | -------------------------------------------------------------- | | `ceo` | Chief Executive Officer | | `cfo` | Chief Financial Officer | | `director` | Director | | `gm` | General Manager | | `vp` | Vice President | | `general_counsel` | General Counsel | | `other` | Any other title (set `title_other` to the specific title text) | *** ## Campaign fields ### `use_cases` Accepts an array. Choose all that apply to your campaign. If your messages cover multiple cases, list them all. | Value | Description | | ----------------------------- | -------------------------------------------------------------- | | `account_notification` | Account status updates (balance alerts, statement ready, etc.) | | `customer_care` | Support conversations, help desk, case updates | | `delivery_notification` | Shipping and delivery status | | `fraud_alert` | Fraud warnings and suspicious activity notices | | `higher_education` | Messages from colleges and universities | | `marketing` | Promotional content, offers, sales | | `polling_voting` | Polls, surveys, and voting campaigns | | `public_service_announcement` | Informational messages from government or non-profit orgs | | `security_alert` | Two-factor codes, login alerts | | `two_factor_authentication` | OTP codes for authentication | For special use cases (political, proxy, sweepstakes, etc.) that don't appear here, contact support. A campaign registers a **use case**, not an individual promotion or message theme. Different promotions under the same use case (e.g. a Halloween sale and a Christmas sale, both `marketing`) run under one campaign, with representative message samples covering that content. Register a separate campaign only when the content requires a different use case (e.g. `two_factor_authentication` vs `marketing`) or a different brand/account. ### `includes` Content types present in your messages. Required when applicable; leaving a flag unset when the content is present is a common reason for rejection. | Value | Description | | ---------------- | ----------------------------------------------------- | | `links` | Messages contain URLs | | `phone_numbers` | Messages contain phone numbers | | `age_gated` | Messages contain age-restricted content | | `direct_lending` | Messages are related to lending or financial products | ### `volume` Expected daily send volume to T-Mobile (the network with the strictest volume caps). | Value | Description | | ------ | ---------------------------------------------------------- | | `low` | Up to 2,000 SMS segments per day to T-Mobile | | `high` | Up to 200,000 segments per day, subject to TCR trust score | Start with `low` unless you're certain you'll exceed 6,000 messages per day (since T-Mobile typically makes up about a third of volume). You can switch to a high volume campaign down the road when you need more volume. ### `consent_flow` A written description of how recipients opted in to receive your messages. Must be between 40 and 4,096 characters. Reviewers check this against your actual opt-in flow, so be specific and accurate, vague descriptions like "users opt in on our website" are reliably rejected. What to include: * Where the opt-in happens (checkout page, signup form, paper form, etc.) * The exact wording of the consent prompt or checkbox * Whether consent is pre-checked (it shouldn't be) or unchecked by default * A link to either the actual consent flow or a screenshot of it See [Consent Flows](../concepts/consent-flows#writing-your-consent_flow-description) for templates and examples. ### `link_sample` A URL representing a link that will appear in your messages. Optional, but recommended if your campaign uses `includes: ["links"]`. Carriers use this field to establish your campaign's link domain. If you send links from a domain different from what you registered here, carriers are significantly more likely to filter those messages. If link shortening is enabled on your account, Surge substitutes the link shortener URL automatically. Contact support to coordinate if you have a custom link-sample URL. Link shortening is an account-level setting, and it is disabled by default. Contact Surge support to enable it. Sending long URLs without shortening can affect deliverability, but using a shared link shortener increases the chances of the carriers filtering your messages. See [Deliverability](/concepts/deliverability) for tradeoffs. Example: `"https://acme.com/track/12345"` Max length: 255 characters. ### `message_samples` An array of 2–5 real examples of messages you'll send. Each sample must be 20–1,024 characters. The canonical opt-in sample is: > "You are now opted in to messages from \{brand name}. Frequency varies. Msg\&data rates apply. Reply STOP to opt out." Replace `\{brand name\}` with your actual brand name. Template tokens like `\{Agent Name\}` or `\{Agency\}` suggest to the carrier reviewers that you will be sending on behalf of brands other than the one registered, so they are likely to cause a rejection. Variable content in templates should be indicated with square brackets rather than curly braces: `[customer name]`, `[order number]`. Curly braces suggest placeholder tokens that weren't filled in. # Sole Proprietor Path Source: https://docs.surge.app/registration/sole-proprietor How sole proprietors register on Surge: no EIN required, dashboard-first flow, and lower throughput expectations. Sole proprietors are unincorporated individuals operating a business without a separate legal entity. They don't have an EIN and register differently from corporations and LLCs. ## When to use `sole_proprietor` Use `organization.type: "sole_proprietor"` when: * You're an individual operating under your own name or a DBA (doing business as) * You don't have a business EIN. Taxes for your business are filed under your personal Social Security Number * You're not incorporated Do **not** use `sole_proprietor` if your business name includes "LLC", "Inc.", "Corp.", or "Ltd." Those are incorporated entities and require `llc`, `private_corporation`, or `public_corporation` respectively. This is a common, easily caught rejection — see [Avoiding rejection → Business type / EIN mismatch](./avoiding-rejection#business-type-/-ein-mismatch). ## What's different **No EIN required.** Sole proprietors skip the `identifier_type` and `identifier` fields. Surge and the carriers use other verification methods for identity. **Additional disclosures.** Carrier regulations require additional reporting and disclosure language for sole proprietor campaigns. Surge handles these requirements automatically. You don't need to add anything to your messages. **Dashboard-first.** The dashboard registration form has a dedicated flow for the sole proprietor path, with questions and guidance specific to this business type. We recommend completing sole proprietor registration through the dashboard. **Lower volume.** Sole proprietor campaigns typically have lower throughput caps than corporation campaigns. If you expect high message volume, incorporating your business may be worth considering. ## What to prepare * Your full legal name (as it appears on government-issued ID) * A live, public-facing website or social media profile in your business's name — reviewers visit it, and a coming-soon page or one with the wrong name fails review * Your opt-in flow and consent disclosures * A valid business email address (ideally at your business domain, not personal Gmail) * Sample messages The rest of the registration process follows the same pattern as standard registration: create a campaign, add sample messages, link your privacy policy and terms, and wait for review. See [Register via API](./api-walkthrough) for the full step-by-step walkthrough. # Toll-Free Verification Source: https://docs.surge.app/registration/toll-free How toll-free number verification works on Surge and how it differs from standard 10DLC local number registration. Toll-free numbers (8xx area codes) go through a separate verification process from standard 10DLC local number registration. The two processes are independent — you can have both a local campaign (10DLC) and a verified toll-free number on the same account. Surge can purchase numbers in `833`, `844`, `855`, `866`, `877`, and `888` via API. The original `800` prefix is mostly exhausted — Surge can port in an existing `1-800` number on request, but new purchases use one of the other prefixes. ## Toll-free vs 10DLC | | Toll-free verification | 10DLC (standard) | | -------------------- | --------------------------------------------- | ------------------------------------------------------------ | | **Number type** | Toll-free (8xx area codes) | Local 10-digit numbers | | **Daily throughput** | No T-Mobile daily cap | Capped at 2,000 SMS segments/day to T-Mobile on `low` volume | | **Registration** | Per-number verification | Brand + campaign, then attach numbers | | **API support** | Purchase via API; verification dashboard-only | Full API support | ## How to verify a toll-free number `POST /accounts/{account_id}/phone_numbers` with `type: "toll_free"`. This part has full API support. Navigate to **Phone Numbers** and find the number you just purchased. Opens the toll-free verification form. Provide your business information, opt-in description, and sample messages. The number is marked as verified once the carrier approves. Until then, throughput is restricted. ## Changes-needed on toll-free If the carrier requests changes, Surge sends you a notification with details. Use the verification form in the dashboard to update and resubmit. The issues reviewers flag for toll-free are similar to 10DLC — opt-in flow clarity, business identity, and sample message quality. See [Avoiding Rejection](./avoiding-rejection) for the full breakdown. ## API support **Today:** purchasing toll-free numbers via API is supported. **Coming:** full toll-free verification via API. Until it ships, use the dashboard for the verification step. # Deprecation Notices Source: https://docs.surge.app/resources/deprecations Active deprecations: the surge-signature header, link.followed.message_id field, and publishable tokens for embeds. This page tracks API and SDK features that are deprecated and will be removed in a future version. *** ## `surge-signature` webhook header **Status:** Deprecated. Use `webhook-signature`. **What changed:** The webhook request signature header was renamed from `surge-signature` to `webhook-signature` to align with the [Standard Webhooks](https://www.standardwebhooks.com/) specification. The old `surge-signature` header used the format `t=,v1=` with its own signed-payload composition, while `webhook-signature` signs `{webhook-id}.{webhook-timestamp}.{raw-body}` and uses the `v1,` format. If you compute the signature yourself, you need to update both which header you read and how you compute the signature — not just the header name. **How to migrate:** Update your webhook handler to read `webhook-signature` instead of `surge-signature` and to read the `webhook-id` and `webhook-timestamp` headers and compute the new signed-payload string. ```python theme={null} # Before signature = request.headers.get("surge-signature") # After signature = request.headers.get("webhook-signature") ``` See [Signature Verification](../api-reference/webhooks/signature-validation) for the full algorithm and per-language examples. If you're using the `standardwebhooks` library, no code change is needed. It reads the correct headers and computes the new signature format automatically. **Timeline:** Both headers are currently sent on all webhook requests. The `surge-signature` header will be removed in a future release. Migrate before then to avoid broken signature verification. *** ## `link.followed.message_id` field **Status:** Deprecated. Use `data.message.id`. **What changed:** The `link.followed` webhook event originally included a top-level `message_id` field. This has been replaced by a nested `message` object that contains `id` and additional message metadata. **How to migrate:** ```python theme={null} # Before message_id = payload["message_id"] # After message_id = payload["data"]["message"]["id"] ``` **Timeline:** The top-level `message_id` field is still included in `link.followed` events for backward compatibility. It will be removed in a future release. *** ## Publishable tokens for embedded components **Status:** Deprecated. Use signed JWT authentication. **What changed:** Embedded UI components (Inbox, Conversation, Unread Count) previously supported a "publishable token" authentication method. This has been replaced by signed JWT authentication, which provides stronger security and token expiration control. **How to migrate:** Generate a JWT using the token endpoint: ```python Python theme={null} from surge import Surge surge = Surge() response = surge.users.create_token(user_id="{user_id}") token = response.token ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const response = await surge.users.createToken("{user_id}"); const token = response.token; ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new response = surge.users.create_token("{user_id}") token = response.token ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, token} = Surge.Users.create_token(client, "{user_id}", %{}) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/users/{user_id}/tokens \ -H "Authorization: Bearer YOUR_API_KEY" ``` Use the returned `token` value when mounting embedded components. See [Embeddable UI Components](../embeddable/index) for the full authentication documentation. **Timeline:** Publishable tokens will stop working in a future release. Migrate all embedded component mounts to JWT authentication. # Glossary Source: https://docs.surge.app/resources/glossary Definitions for Surge and A2P messaging terms: Account, Brand, Campaign, CSP, TypeID, Dashboard User, User, and more. Terms used in Surge documentation, the API, and the broader A2P messaging industry. *** **A2P messaging** Application-to-person messaging. Text messages sent programmatically from software to a person's mobile phone, as opposed to P2P (person-to-person) messages between individuals. Surge is an A2P platform. *** **Account** A resource in Surge that represents one messaging brand. An account owns phone numbers, campaigns, contacts, conversations, and messages. In carrier terminology, an account corresponds to a *brand*. See also: *Project*, *User* *** **Audience** A named, reusable list of contacts. Audiences are used to send repeated messages to the same group — weekly newsletters, customer cohorts, geographic segments. Created via `POST /accounts/{account_id}/audiences` and populated by adding contacts. Blast an audience by passing its ID in the `to` array when creating a blast. See also: *Blast*, *Contact* *** **Blast** A one-time send to a list of recipients. A blast is created with a `from` number, a `body`, and a `to` array of phone numbers, contact IDs, or audience IDs. Unlike regular messages, blasts are not threaded into conversations. Use a blast for ad-hoc sends; use an audience when the same list receives messages repeatedly. See also: *Audience* *** **Brand** The carrier industry term for the legal business entity behind a messaging campaign. In Surge, an *Account* is your brand. *** **Campaign** A registered use case for sending messages. Campaigns describe what you're sending, to whom, and how recipients opted in. Every phone number that sends production traffic must be attached to an approved campaign. *** **Campaign Service Provider (CSP)** An intermediary registered with The Campaign Registry that submits brand and campaign registrations on behalf of its customers. Surge is a CSP. Most Surge customers don't need to become CSPs themselves, but we can support you in that endeavor if you wish — see [The Campaign Registry and Becoming a CSP](../concepts/tcr). *** **Contact** A phone number record within an account. When you message someone or they message you, Surge creates a contact to represent that number. Contacts track opt-out state and can carry metadata. *** **Conversation** A thread of messages between your phone number and a specific contact. All inbound and outbound messages with the same contact on the same number belong to one conversation. *** **CSP** See *Campaign Service Provider*. *** **Dashboard User** A person who logs in to `hq.surge.app` to manage Surge: API keys, billing, webhooks, phone numbers, and account configuration. Distinct from an API-provisioned User. A Dashboard User can belong to multiple projects. *** **E.164** The international phone number format used throughout the Surge API. E.164 numbers start with `+` followed by the country code and subscriber number, with no spaces or punctuation. Example: `+18015551234`. *** **GSM-7** The character encoding used for standard SMS. Covers the basic Latin alphabet, digits, and common punctuation. Messages using only GSM-7 characters fit 160 characters per segment (153 in multi-part). See [SMS Segments & Encoding](../concepts/sms-segments). *** **10DLC** Ten-digit long code. This can be used to signify what Surge calls "local" numbers, that is non-toll free VoIP numbers used for sending messages. This also refers to the US carrier framework for A2P messaging using standard 10-digit phone numbers. Requires brand and campaign registration via The Campaign Registry. *** **Opt-in** Explicit permission from a recipient to receive text messages. Carriers require documented opt-in consent before you send any text messages. *** **Opt-out** A recipient's request to stop receiving messages, typically by replying STOP. Once opted out, sending to a contact returns an `opted_out` error. *** **MMS** Multimedia Messaging Service. An extension of SMS that supports sending images, GIFs, PDFs, and other media in addition to text. MMS segments can carry more data than SMS segments, but delivery depends on the recipient's carrier and handset supporting MMS. See also: *SMS*, *Segment* *** **Project** The top-level Surge tenant. Your company or product is a project. A project contains one or more accounts, holds your API keys and billing, and configures webhooks. You get one project when you sign up for Surge. *** **Segment** The carrier's billing unit for SMS. One segment is 140 bytes. A GSM-7 message fits 160 characters in one segment; a message using UCS-2 (e.g., with emoji) fits only 70 characters. See [SMS Segments & Encoding](../concepts/sms-segments). *** **Short code** A 5- or 6-digit phone number (e.g., `12345`) used for high-volume A2P messaging. Short codes support very high throughput and are often used for large-scale marketing programs. Purchasing a short code requires a support request — they are not available via the API. Subject to their own carrier registration process separate from 10DLC. *** **SMS** Short Message Service. The standard protocol for sending text-only messages up to 160 GSM-7 characters (or 70 UCS-2 characters) per segment. The default protocol for Surge messages. When a message has attachments or exceeds 10 segments, Surge automatically upgrades to MMS. *** **TCR** See *The Campaign Registry*. *** **Toll-free number** A phone number with an 8xx area code where the call or message cost is borne by the called party, not the caller. Surge purchases new toll-free numbers in `833`, `844`, `855`, `866`, `877`, and `888`; the original `800` prefix is mostly exhausted and is available only via port-in. Toll-free numbers go through a separate verification process from 10DLC local numbers. Available via the API (`type: "toll_free"`); verification currently requires the dashboard. *** **The Campaign Registry (TCR)** The industry body that registers A2P 10DLC brands and campaigns. US mobile carriers use TCR to verify who is behind phone numbers sending text messages. Surge submits registrations to TCR as your CSP. *** **TypeID** The format used for all Surge resource IDs. A TypeID is a type prefix followed by a 26-character alphanumeric ID. Examples: `acct_01jrzhe8d9enptypyx360pcmxj` (account), `msg_01jrzhe8d9enptypyx360pcmxk` (message), `cpn_01jrzhe8d9enptypyx360pcmxl` (campaign). *** **UCS-2** The character encoding used when a message contains characters outside the GSM-7 set (emoji, accented characters, curly quotes, etc.). UCS-2 messages fit only 70 characters per segment. See [SMS Segments & Encoding](../concepts/sms-segments). *** **User** A person provisioned via the Surge API (via `POST /accounts/{account_id}/users`) who interacts with embedded UI components. Users do not log in to the Surge dashboard; they authenticate to embedded components with a short-lived token. Distinct from: *Dashboard User*. *** **Webhook** An HTTP callback Surge sends to your server when something happens: a message arrives, a campaign is approved, a contact opts out. You configure a webhook endpoint URL in the dashboard, and Surge POSTs events to it in real time. # HIPAA Compliance Source: https://docs.surge.app/resources/hipaa HIPAA-compliant messaging and voice are in development on the Custom plan. How to start the process with Surge. HIPAA-compliant messaging and voice is part of Surge's Custom plan. It is currently in development. ## What's included HIPAA compliance on the Custom plan covers: * **Secure messaging**: end-to-end encrypted messaging via Surge's secure chat interface, suitable for protected health information (PHI) * **HIPAA-compliant voice**: call recording and voicemail handling with PHI safeguards * **Business Associate Agreement (BAA)**: Surge enters into a BAA with your organization, establishing the legal framework for handling PHI under HIPAA ## Current status HIPAA features are in active development. The expected timeline and specific feature scope will be communicated via the changelog when closer to general availability. If your organization has an upcoming HIPAA compliance deadline or wants to discuss early access, contact Surge support at `support@surge.app`. ## How to get started HIPAA compliance is not available on self-serve plans. To start the process: 1. Contact Surge at `support@surge.app` or through the dashboard 2. Discuss your HIPAA requirements and timeline 3. Surge will provide information on the Custom plan pricing and the BAA process ## In the meantime Surge's standard messaging is not HIPAA-compliant. Do not send protected health information (patient names, diagnoses, medication details, insurance information) through standard Surge messaging while HIPAA compliance is in development. If you're building a healthcare application today, consider using Surge only for non-PHI communications (appointment reminders with no clinical content, general notifications) while HIPAA-compliant features are finalized. # Release Notes Source: https://docs.surge.app/resources/release-notes Browse Surge's public release notes on the web or via the API to track new features, deprecations, and removed surfaces. Surge maintains a public log of API and product changes. There are two ways to consume it. ## Web Browse releases at [surge.app/changelog](https://surge.app/changelog). Notable changes — new endpoints, deprecations, breaking webhook payload updates — are announced there first. ## RSS For programmatic access, subscribe to the changelog's RSS feed: ``` https://surge.app/changelog/rss.xml ``` # Elixir SDK Source: https://docs.surge.app/sdks/elixir Install the Elixir SDK, configure for Phoenix, send messages, handle errors, and use WebhookPlug or WebhookHandler. The Elixir SDK provides a typed client for the Surge API plus a Phoenix-friendly `WebhookPlug` and `WebhookHandler` behaviour for handling inbound events. It's published as `surge_api` on Hex and supports Elixir 1.14 and newer. This page covers installing the package, configuring the client for Phoenix, sending your first request, handling errors, and wiring webhook routes with either the plug or the behaviour. ## Install and authenticate Add to your `mix.exs`: ```elixir mix.exs theme={null} defp deps do [ {:surge_api, "~> 0.2.0"} ] end ``` ```bash theme={null} mix deps.get ``` Set your API key in config: ```elixir config/runtime.exs theme={null} config :surge_api, api_key: System.get_env("SURGE_API_KEY") ``` Or create a client directly: ```elixir theme={null} client = Surge.Client.new("sk_live_your_key_here") ``` ## Your first request Using the default client (reads from application config): ```elixir theme={null} {:ok, message} = Surge.Messages.create( "acct_01jrzhe8d9enptypyx360pcmxj", %{to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm."} ) IO.puts(message.id) # "msg_01j..." IO.puts(message.status) # "queued" ``` Or with an explicit client: ```elixir theme={null} client = Surge.Client.new("sk_live_your_key_here") {:ok, message} = Surge.Messages.create( client, "acct_01jrzhe8d9enptypyx360pcmxj", %{to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm."} ) ``` ## Retrieving a message ```elixir theme={null} {:ok, message} = Surge.Messages.get("msg_01kqbhwra9egg8sdcsp9veg391") IO.inspect(message.body) ``` ## Error handling The SDK returns tagged tuples: ```elixir theme={null} case Surge.Messages.create("acct_01j...", %{to: "+18015551234", body: "Hello"}) do {:ok, message} -> IO.inspect(message.id) {:error, %Surge.Error{type: "opted_out", message: msg}} -> Logger.warning("Contact opted out: #{msg}") {:error, %Surge.Error{type: type, message: msg}} -> Logger.error("Surge error #{type}: #{msg}") end ``` ## Webhook handling ### WebhookPlug for Phoenix The SDK includes `Surge.WebhookPlug` that handles signature verification and event parsing. Add it to your `endpoint.ex` **before** `Plug.Parsers`: ```elixir lib/my_app_web/endpoint.ex theme={null} plug Surge.WebhookPlug, at: "/webhook/surge", handler: MyAppWeb.SurgeHandler, secret: System.get_env("SURGE_WEBHOOK_SECRET") ``` For runtime configuration, pass a tuple or function as the secret: ```elixir theme={null} plug Surge.WebhookPlug, at: "/webhook/surge", handler: MyAppWeb.SurgeHandler, secret: {Application, :get_env, [:myapp, :surge_webhook_secret]} ``` ### WebhookHandler behaviour Implement the `Surge.WebhookHandler` behaviour to process events. The callback receives a `Surge.Events.Event` struct with an atom `type` and a `data` field: ```elixir theme={null} defmodule MyAppWeb.SurgeHandler do @behaviour Surge.WebhookHandler @impl true def handle_event(%Surge.Events.Event{type: :message_received} = event) do IO.inspect(event.data.body, label: "Received") :ok end @impl true def handle_event(%Surge.Events.Event{type: :contact_opted_out} = event) do MyApp.handle_opt_out(event.data) :ok end @impl true def handle_event(_event), do: :ok end ``` The callback should return `:ok` or `{:ok, term}` for success, or `:error` or `{:error, reason}` to signal a failure (which returns HTTP 400 to Surge). ### Manual signature verification The Elixir SDK currently consumes the older `surge-signature` header (format `t=,v1=`) with a different signed-payload composition than the Standard Webhooks spec used by Python / TypeScript / Ruby. **Migrating to Standard Webhooks (`webhook-signature`) this week** — this Note will be removed once the migration ships. See [Signature validation](../api-reference/webhooks/signature-validation) for both formats. If you're not using `Surge.WebhookPlug`, verify signatures with `Surge.Webhook.construct_event/3`: ```elixir theme={null} case Surge.Webhook.construct_event(raw_body, signature_header, webhook_secret) do {:ok, %Surge.Events.Event{} = event} -> handle_event(event) {:error, reason} -> Logger.warning("Invalid webhook signature: #{inspect(reason)}") end ``` The `signature_header` is the value of the `surge-signature` HTTP header, formatted as `t=timestamp,v1=hex_signature`. # Using the SDKs Source: https://docs.surge.app/sdks/index Choose a Surge SDK, authenticate with an env var or constructor, and make requests for endpoints without typed methods. Surge has official SDKs for Python, TypeScript, Ruby, and Elixir. Each SDK wraps the REST API with language-native types, pagination helpers, retry logic, error handling, and webhook signature verification. The four SDK pages are organized the same way: install and authenticate, send your first request, paginate, handle errors, and verify webhooks. Pick the SDK that matches your stack: `pip install surge-sdk`. Sync and async clients. `npm install @surgeapi/node`. Full TypeScript types. `gem "surge_api"`. Sorbet types included. `{:surge_api, "~> 0.2"}`. Phoenix `WebhookPlug` and `WebhookHandler`. ## Authentication All SDKs read the API key from the `SURGE_API_KEY` environment variable by default. You can also pass it directly to the client constructor. ```python Python theme={null} # Reads SURGE_API_KEY from environment surge = Surge() # Or explicitly: surge = Surge(api_key="sk_live_...") ``` ```typescript TypeScript theme={null} // Reads SURGE_API_KEY from environment const surge = new Surge(); // Or explicitly: const surge = new Surge({ apiKey: "sk_live_..." }); ``` ## Account scoping All account-scoped SDK methods take the `account_id` as a parameter (or first positional argument). This maps to the account ID in the request path. ```python Python theme={null} messages = surge.messages.list(account_id="acct_01j...") ``` ```typescript TypeScript theme={null} const messages = await surge.messages.list({ accountId: "acct_01j..." }); ``` ## Making requests not covered by an explicit function Every SDK exposes a raw HTTP method for endpoints that don't have a dedicated SDK function. For example, new endpoints in beta or private APIs. ```python Python theme={null} response = surge.request("GET", "/accounts/acct_01j.../status", params={ "capabilities": "local_messaging" }) ``` ```typescript TypeScript theme={null} const response = await surge.get("/accounts/acct_01j.../status", { query: { capabilities: "local_messaging" }, }); ``` Use this escape hatch when you need to call an endpoint before the SDK has a typed method for it. You get full request/response types from the SDK client but no IDE autocompletion on the response body. # Python SDK Source: https://docs.surge.app/sdks/python Install the Python SDK, send messages with sync and async clients, paginate, handle typed errors, and verify webhooks. The Python SDK wraps the Surge HTTP API with a typed client, sync and async variants, automatic pagination, and a webhook verifier. It's published as `surge-sdk` on PyPI and supports Python 3.9 and newer. This page covers installing the package, sending your first request with both clients, paginating list endpoints, handling typed errors, and verifying webhook signatures in your handler. ## Install and authenticate ```bash theme={null} pip install surge-sdk ``` Set your API key as an environment variable: ```bash theme={null} export SURGE_API_KEY=sk_live_your_key_here ``` The client reads it automatically: ```python theme={null} from surge import Surge surge = Surge() ``` For async code, use `AsyncSurge`: ```python theme={null} from surge import AsyncSurge surge = AsyncSurge() ``` ## Your first request Send a message: ```python theme={null} message = surge.messages.create( account_id="acct_01jrzhe8d9enptypyx360pcmxj", to="+18015551234", body="Your appointment is confirmed for Friday at 2pm.", ) print(message.id) # msg_01j... print(message.status) # "sent" ``` With `AsyncSurge`: ```python theme={null} import asyncio async def main(): message = await surge.messages.create( account_id="acct_01j...", to="+18015551234", body="Your appointment is confirmed.", ) print(message.status) asyncio.run(main()) ``` ## Pagination The SDK returns auto-paginating iterators for list endpoints. You don't need to manage cursors manually: ```python theme={null} # Iterates through all messages automatically for message in surge.messages.list(account_id="acct_01j..."): print(message.id, message.status) ``` To page manually, use the raw list method: ```python theme={null} page = surge.messages.list_page(account_id="acct_01j...", limit=25) print(page.data) # list of messages print(page.pagination.next_cursor) ``` ## Retries and timeouts The SDK retries transient failures (network errors, 5xx responses) with exponential backoff. Default: 3 retries. Configure retries and timeout on the client: ```python theme={null} surge = Surge( max_retries=5, timeout=30.0, # seconds ) ``` To disable retries for a specific call: ```python theme={null} message = surge.messages.create( account_id="acct_01j...", to="+18015551234", body="Hello", max_retries=0, ) ``` ## Error handling Errors raise typed exceptions: ```python theme={null} from surge import SurgeError, APIStatusError try: surge.messages.create( account_id="acct_01j...", to="+18015551234", body="Hello", ) except APIStatusError as e: print(e.status_code) # e.g. 422 print(e.error.type) # e.g. "opted_out" print(e.error.message) ``` Common exception types: | Exception | When | | --------------------- | ---------------------------------- | | `APIStatusError` | API returned a 4xx or 5xx response | | `APIConnectionError` | Network error or timeout | | `AuthenticationError` | Invalid or missing API key (401) | | `RateLimitError` | Rate limit exceeded (429) | ## Webhooks Verify webhook signatures and parse events: ```python theme={null} import os from surge.webhooks import Webhook wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"]) # In your webhook handler (Flask example): @app.post("/webhooks/surge") def surge_webhook(): try: event = wh.verify(request.get_data(), request.headers) except Exception: return "", 400 match event["type"]: case "message.received": handle_inbound(event["data"]) case "contact.opted_out": handle_opt_out(event["data"]) return "", 200 ``` The `Webhook` class follows the Standard Webhooks spec and handles replay protection automatically. # Ruby SDK Source: https://docs.surge.app/sdks/ruby Install the Ruby SDK with Sorbet types, send messages, paginate, handle errors, and verify webhooks in Rails. The Ruby SDK ships with Sorbet type signatures for every endpoint, so editors and `srb tc` can flag bad payloads before runtime. It's published as `surge_api` on RubyGems and supports Ruby 3.1 and newer. This page covers installing the gem, sending your first request, paginating list endpoints, rescuing typed errors, and verifying webhook signatures inside a Rails controller. ## Install and authenticate Add to your Gemfile: ```ruby Gemfile theme={null} gem "surge_api" ``` ```bash theme={null} bundle install ``` Set your API key: ```bash theme={null} export SURGE_API_KEY=sk_live_your_key_here ``` ```ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new ``` Sorbet type signatures are included for typed Ruby projects. ## Your first request ```ruby theme={null} message = surge.messages.create( "acct_01jrzhe8d9enptypyx360pcmxj", to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm." ) puts message.id # msg_01j... puts message.status # "sent" ``` ## Pagination The SDK provides an auto-paginating enumerator: ```ruby theme={null} # Iterates through all messages surge.messages.list("acct_01j...").each do |message| puts "#{message.id}: #{message.status}" end ``` For manual pagination: ```ruby theme={null} page = surge.messages.list_page("acct_01j...", limit: 25) page.data.each { |m| puts m.id } puts page.pagination.next_cursor ``` ## Retries and timeouts ```ruby theme={null} surge = SurgeAPI::Client.new( max_retries: 5, timeout: 30 # seconds ) ``` ## Error handling ```ruby theme={null} begin surge.messages.create( "acct_01j...", to: "+18015551234", body: "Hello" ) rescue SurgeAPI::APIStatusError => e puts e.status # 422 puts e.error.type # "opted_out" puts e.error.message rescue SurgeAPI::AuthenticationError => e puts "Invalid API key" end ``` ## Webhooks ```ruby theme={null} require "surge_api/webhooks" wh = SurgeAPI::Webhook.new(ENV["SURGE_WEBHOOK_SECRET"]) # Rails example: class WebhooksController < ApplicationController skip_before_action :verify_authenticity_token def create begin event = wh.verify(request.raw_post, request.headers) rescue SurgeAPI::WebhookVerificationError head :bad_request and return end case event["type"] when "message.received" handle_inbound(event["data"]) when "contact.opted_out" handle_opt_out(event["data"]) end head :ok end end ``` # TypeScript SDK Source: https://docs.surge.app/sdks/typescript Install the TypeScript SDK, send messages with full type safety, paginate, handle typed errors, and verify webhooks. The TypeScript SDK ships full type definitions for every endpoint, request payload, and response object, so your editor catches mistakes before they hit production. It runs in Node.js 18+ and any modern bundler-based environment. This page covers installing `@surgeapi/node`, sending your first request, paginating list endpoints, narrowing typed errors, and verifying webhook signatures in your handler. ## Install and authenticate ```bash theme={null} npm install @surgeapi/node ``` Set your API key as an environment variable: ```bash theme={null} export SURGE_API_KEY=sk_live_your_key_here ``` ```typescript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); ``` ## Your first request ```typescript theme={null} const message = await surge.messages.create("acct_01jrzhe8d9enptypyx360pcmxj", { to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm.", }); console.log(message.id); // msg_01j... ``` ## Type imports Every resource and parameter has a corresponding TypeScript type: ```typescript theme={null} import type { Message, MessageCreateParams } from "@surgeapi/node"; const params: MessageCreateParams = { to: "+18015551234", body: "Hello", }; const message: Message = await surge.messages.create("acct_01j...", params); ``` ## Pagination The SDK returns auto-paginating iterators using async iteration: ```typescript theme={null} // Iterates through all messages for await (const message of surge.messages.list("acct_01j...")) { console.log(message.id); } ``` Page manually when you need cursor control: ```typescript theme={null} const page = await surge.messages.list("acct_01j...", { limit: 25 }); console.log(page.data); console.log(page.pagination.next_cursor); ``` ## Retries and timeouts The SDK retries transient failures automatically. Configure on the client: ```typescript theme={null} const surge = new Surge({ maxRetries: 5, timeout: 30_000, // milliseconds }); ``` ## Error handling Errors are typed exceptions: ```typescript theme={null} import { APIError, AuthenticationError } from "@surgeapi/node"; try { await surge.messages.create("acct_01j...", { to: "+18015551234", body: "Hello" }); } catch (error) { if (error instanceof APIError) { console.log(error.status); // e.g. 422 console.log(error.error.error.type); // e.g. "opted_out" console.log(error.error.error.message); } } ``` ## Webhooks Use the SDK's built-in webhook helper to verify signatures and parse events: ```typescript theme={null} // Express example: const surge = new Surge({ webhookSigningSecret: process.env.SURGE_WEBHOOK_SECRET }); app.post("/webhooks/surge", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = surge.webhooks.unwrap(req.body.toString(), { headers: req.headers }); } catch { return res.status(400).end(); } switch (event.type) { case "message.received": handleInbound(event.data); break; case "contact.opted_out": handleOptOut(event.data); break; } res.status(200).end(); }); ``` Pass the raw request body (string) to `surge.webhooks.unwrap`, not the parsed JSON. Signature verification requires the exact byte sequence that was signed. Use `express.raw()` or your framework's equivalent. The webhook secret is passed to the `Surge` client constructor as `webhookSigningSecret`, or it can be passed per-call as the `key` option. # Ongoing Conversations Source: https://docs.surge.app/sending/conversations How contacts and conversation threads work, when to use them, and how to manage opt-out state per thread. When the same person sends and receives multiple messages over time, Surge tracks them as a conversation thread. Understanding how conversations and contacts work helps you build two-way messaging flows, surface message history, and manage opt-out state correctly. ## Contacts and conversations A **contact** represents a phone number in the context of a specific account. When someone messages your number for the first time, or when you first message them, Surge creates a contact record and a conversation thread. A **conversation** is the thread of all messages between your number and a contact. Every message you send or receive includes a `conversation.id` in the response. ```json theme={null} { "id": "msg_01jrzhe8d9enptypyx360pcmxj", "blast_id": null, "body": "Your order is ready for pickup.", "attachments": [], "conversation": { "id": "cnv_01jav8xy7fe4nsay3c9deqxge9", "phone_number": { "id": "pn_01jsjwe4d9fx3tpymgtg958d9w", "number": "+18015556789", "type": "local" }, "contact": { "id": "ctc_01ja88cboqffhswjx8zbak3ykk", "phone_number": "+18015551234" } }, "metadata": {} } ``` ## When to use contacts vs raw messaging **Raw messaging** (passing a phone number directly in the `to` field) is fine for simple one-way notifications where you don't need to track who sent what or manage opt-out state per person. Blast sends work this way. **Contacts** become useful when: * You're building two-way messaging and need to correlate inbound replies with specific customers * You need to check or manage opt-out state for an individual * You want to associate metadata (name, account ID in your system) with a phone number * You're building embeddable UI components that need to show a contact's message history Create a contact explicitly to attach metadata before the first message: ```python Python theme={null} from surge import Surge surge = Surge() contact = surge.contacts.create( account_id="{account_id}", phone_number="+18015551234", first_name="Jordan", last_name="Patel", email="jordan.patel@example.com", metadata={"customer_id": "cust_9887"}, ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const contact = await surge.contacts.create("{account_id}", { phone_number: "+18015551234", first_name: "Jordan", last_name: "Patel", email: "jordan.patel@example.com", metadata: { customer_id: "cust_9887" }, }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new contact = surge.contacts.create( "{account_id}", phone_number: "+18015551234", first_name: "Jordan", last_name: "Patel", email: "jordan.patel@example.com", metadata: { "customer_id" => "cust_9887" } ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, contact} = Surge.Contacts.create(client, "{account_id}", %{ phone_number: "+18015551234", first_name: "Jordan", last_name: "Patel", email: "jordan.patel@example.com", metadata: %{customer_id: "cust_9887"} }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/contacts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+18015551234", "first_name": "Jordan", "last_name": "Patel", "email": "jordan.patel@example.com", "metadata": { "customer_id": "cust_9887" } }' ``` ```json theme={null} { "id": "ctc_01jrzhe8d9enptypyx360pcmxn", "phone_number": "+18015551234", "first_name": "Jordan", "last_name": "Patel", "email": "jordan.patel@example.com", "metadata": { "customer_id": "cust_9887" } } ``` Contacts support these fields: `phone_number` (required), `first_name`, `last_name`, `email`, `date_of_birth`, and `metadata`. **All contact PII is encrypted at rest.** `first_name`, `last_name`, `email`, `date_of_birth`, `phone_number`, and `metadata` are stored encrypted in the database. You can safely store customer-identifying information here without it being readable if the database is compromised. Surge also automatically populates carrier data (`phone_carrier`, `phone_carrier_name`, `phone_country`) on contacts based on carrier lookups. This data is populated asynchronously after the first message send or verification — it is not available at contact creation time. ## Following a conversation When a contact sends you an inbound message, the [`message.received`](../receiving/events#message-received) webhook event includes the conversation ID. Use that ID to fetch the full thread or send a reply into the same conversation. ```python Python theme={null} # Send a reply into an existing conversation message = surge.messages.create( account_id="{account_id}", conversation={"id": "cnv_01jav8xy7fe4nsay3c9deqxge9"}, body="Your order is at the front desk, no signature needed.", ) ``` ```typescript TypeScript theme={null} const message = await surge.messages.create("{account_id}", { conversation: { id: "cnv_01jav8xy7fe4nsay3c9deqxge9" }, body: "Your order is at the front desk, no signature needed.", }); ``` ```ruby Ruby theme={null} message = surge.messages.create( "{account_id}", conversation: { id: "cnv_01jav8xy7fe4nsay3c9deqxge9" }, body: "Your order is at the front desk, no signature needed." ) ``` ```elixir Elixir theme={null} {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ conversation: %{id: "cnv_01jav8xy7fe4nsay3c9deqxge9"}, body: "Your order is at the front desk, no signature needed." }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "conversation": {"id": "cnv_01jav8xy7fe4nsay3c9deqxge9"}, "body": "Your order is at the front desk, no signature needed." }' ``` When you send into a conversation, Surge routes the message from the same number the contact previously reached. ## Retrieving message history Surge does not currently expose an endpoint that returns messages filtered by a single `conversation.id`. To reconstruct a thread from the API: 1. Call [`GET /accounts/{account_id}/messages`](/api-reference/endpoint/messages/list) to list every message on the account, paginating with `after` / `next_cursor`. 2. Filter the results client-side by `conversation.id`. 3. Cache what you've already fetched so you don't rescan the full account on every reply. For an app that displays live threads to end users, the [embeddable Conversation component](/embeddable/conversation) renders the full history for a single conversation without you needing to build this yourself. Message history for a specific conversation is also visible in the Surge dashboard. ## Conversation status A conversation has one of three statuses: | Status | Meaning | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `active` | The conversation is open; messages can be sent and received | | `archived` | The conversation has been closed in the dashboard but the contact has not opted out. Sending to an archived conversation re-opens it automatically | | `opted_out` | The contact replied with a recognized opt-out keyword. Sending returns an `opted_out` error until they re-opt-in | The `opted_out_at` timestamp is set when the conversation moves to `opted_out` status. ## Opt-out state Opt-out state lives on the conversation (one contact might opt out of one number but remain active on another). When a contact replies STOP, the `conversation.opted_out_at` timestamp is set and the `contact.opted_out` webhook event fires. Sending to an opted-out conversation returns an `opted_out` error. Do not attempt to send around opt-outs. Doing so violates carrier policies and can result in your campaign being deactivated. A contact re-opts in by texting START, YES, or UNSTOP to the same number. When that happens, `contact.opted_in` fires and the conversation status returns to `active`. ## Audiences If you want to group contacts into named lists for repeated sends, use audiences. An audience is a collection of contacts that you blast together. See [Send to Many People](./send-many) for the full pattern. ## Cross-channel tracking (roadmap) Conversations and contacts today are SMS-specific. As Surge adds RCS, WhatsApp, and other channels, the contact model will extend to track communication history across all channels in a single thread. See [Channels Roadmap](../channels-roadmap/rcs-and-whatsapp). # Handle Failures Source: https://docs.surge.app/sending/handle-failures Every send-time and delivery-time error code, retryability, and specific remediation steps for each failure type. When a message can't be sent or delivered, the API returns a structured error object. Here's what each error means and what to do about it. ## Error structure All errors follow the same shape: ```json theme={null} { "error": { "type": "opted_out", "message": "This contact has opted out of messages." } } ``` Some errors include a `detail` object with field-level information: ```json theme={null} { "error": { "type": "validation_error", "message": "Could not perform the request with the provided parameters.", "detail": { "body": ["can't be blank"] } } } ``` ## Errors at send time These errors come back synchronously when you call `POST /accounts/{account_id}/messages`. ### `opted_out` (422) The recipient has previously replied STOP. Carriers require you to honor opt-outs. Do not attempt to send to this contact until they explicitly re-opt-in by texting START. **Fix:** Check contact opt-out state before sending. Subscribe to `contact.opted_out` webhook events to update your database in real time. ### `demo_message_limit` (403) You've sent 25 messages from the demo number. The demo is for exploration and it's not for production use. The 25-message cap is a property of the demo number itself. Changing your plan does not raise it. To send more, you need a real phone number attached to an active campaign. **Fix:** [Register your business](../registration/index) and [purchase a phone number](../phone-numbers/purchasing). If you already purchased a number and still hit this error, you're likely still sending from the demo number. Pass the purchased number's ID as `from` (or set it as the account's default), and confirm it has a non-null `campaign_id` — see [Attaching a Number to a Campaign](../phone-numbers/attaching). ### `link_size_limit` (422) A link included in the message body is over 1000 bytes long. This is too long for our shortener to handle, and likely to be broken in the message. **Fix:** Remove unnecessary parameters from the link, or try shortening it before sending. ### `no_phone_number` (422) No `from` number was specified and the account has no default phone number configured. **Fix:** Either set a default phone number on the account in the dashboard, or always pass a `from` value. ### `phone_number_not_found` (422) The phone number ID passed as `from` doesn't exist or doesn't belong to this account. **Fix:** Confirm the phone number ID is correct and belongs to the account you're sending from. ## Errors at delivery (via webhooks) These appear in `message.failed` webhook events after a message was initially accepted. The `failure_reason` field on the event contains the slug. ### `unreachable_destination` The recipient's number is disconnected, out of service, or unreachable on the carrier network. **What to do:** Try again later in case the recipient's phone was turned off or outside of the carrier's service area. If continued attempts fail to be delivered, consider removing them from your list, as it may be a permanent disconnection. **Permanent undeliverability.** When Surge receives `invalid_mobile_number` or `unreachable_carrier` for a number, that number is permanently flagged as undeliverable in Surge's carrier database. All future sends to it (from any account) are cancelled immediately without hitting the carrier. If a number becomes reachable again (e.g., the subscriber reactivates their SIM), the flag clears when a successful delivery is recorded. Stop sending to failed numbers — it has no effect, and your campaign volume quota is still consumed. Contact support if you'd like to opt out of this behavior. ### `invalid_content_type` An MMS attachment's content type isn't supported by the recipient's carrier or handset. **What to do:** Retry with a different format (prefer `image/jpeg` or `image/png`) or send the content as a link instead of an attachment. ### `message_filtered` The carrier's spam filter blocked the message. This can happen when message content matches spam patterns, and is most typically seen when links included in the message have been flagged by the carrier as potentially spam. **What to do:** 1. Check that message content matches your registered campaign description and sample messages 2. Make sure message content doesn't use any wording that could suggest a relationship to forbidden messaging categories including alcohol, gambling, drugs, cryptocurrency, etc. 3. Avoid link shorteners that aren't trusted by carriers. If possible use a link domain that is specific to the brand sending the messages. ### Complete failure reason reference All possible values for `failure_reason` in `message.failed` events: | Reason | Meaning | Retryable? | | ------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------ | | `account_archived` | The sending account has been archived | No | | `account_locked` | The platform has been locked due to a policy violation | No. Contact support | | `attachment_retrieval_error` | Surge couldn't fetch the attachment URL when building the MMS | Yes. Fix the URL or hosting | | `blocked` | The number has been explicitly blocked at the carrier level | No | | `carrier_error` | A transient error from the carrier network | Yes. Surge retries automatically | | `configuration_error` | The account's carrier credentials are misconfigured | No. Contact support | | `daily_message_cap_reached` | The campaign's daily T-Mobile volume cap was hit | Yes. Automatically next day | | `geo_not_enabled` | The destination country isn't in the platform's allowed regions | No. Contact support to enable the region | | `invalid_attachment_url` | The attachment URL is malformed or not publicly accessible | Yes. Fix the URL | | `invalid_content_type` | MMS attachment type unsupported by the recipient's carrier | Yes. Use a supported format | | `invalid_mobile_number` | The number is not a valid mobile number (e.g., a landline) | No. Permanently undeliverable | | `matching_sender_and_receiver` | The `from` and `to` numbers are the same | No. Sending to yourself isn't supported | | `message_filtered` | Blocked by carrier spam filters or Surge's content policy | Investigate content before retrying | | `message_too_big` | The message body exceeds the 1,600-character limit | Yes. Shorten the message | | `mms_not_supported` | The recipient's carrier or handset doesn't support MMS | Yes. Surge falls back to SMS automatically | | `opted_out` | The contact has opted out of this conversation | No. Wait for explicit opt-in | | `premium_rate_number` | The destination is a premium-rate number | No | | `suspected_fraud` | Surge's fraud detection flagged the send | No. Contact support | | `unknown_destination` | The number doesn't exist on any carrier network | No. Permanently undeliverable | | `unknown_error` | An error occurred that Surge couldn't categorize | Investigate with support | | `unreachable_carrier` | The destination carrier network is unreachable | No. Permanently undeliverable | | `unreachable_destination` | The number is disconnected or out of service | No | | `unregistered_number` | The sending number isn't registered with the carrier for this message type | No. Contact support | | `unsupported_route` | No valid carrier route exists for this number pair | No. Contact support | ## Validation errors ### `validation_error` (422) A request field is missing or invalid. The `detail` map contains per-field errors. ```json theme={null} { "error": { "type": "validation_error", "message": "Could not perform the request with the provided parameters.", "detail": { "to": ["is not a valid E.164 phone number"], "body": ["can't be blank"] } } } ``` **Fix:** Correct the flagged fields. Phone numbers must include the country code with a `+` prefix (e.g. `+18015551234`, not `8015551234`). ## Full error reference See the [Error Reference](../api-reference/errors) for the complete list of error types, HTTP status codes, and additional context. # Send to Many People Source: https://docs.surge.app/sending/send-many Blasts vs Audiences: when to use each, how to create and send to both, and campaign volume caps. Surge has two mechanisms for sending to multiple recipients: **Blasts** for one-time sends and **Audiences** for reusable contact lists. The choice between them depends on whether the same group of people will receive messages more than once. ## Blasts vs Audiences **Use a Blast** when you have a list of recipients you're assembling specifically for this send. You pass the recipient list at send time and don't need to maintain it afterward — weekly flash sales, event announcements, one-time notifications. **Use an Audience** when the same group of people receives messages regularly. You maintain the audience over time (adding and removing contacts), then blast the whole audience — weekly newsletters, customer cohorts, or geographic segments. Both send the same underlying message; the difference is how you manage the recipient list. *** ## Blasts ### Send to a list of phone numbers The simplest case: pass E.164 phone numbers directly in the `to` array. ```python Python theme={null} from surge import Surge surge = Surge() blast = surge.blasts.create( account_id="{account_id}", from_="pn_01jrzhe8d9enptypyx360pcmxm", body="Flash sale: 30% off everything this weekend. Shop at acme.com/sale", to=["+18015551234", "+18025551234", "+18035551234"], ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const blast = await surge.blasts.create("{account_id}", { from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Flash sale: 30% off everything this weekend. Shop at acme.com/sale", to: ["+18015551234", "+18025551234", "+18035551234"], }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new blast = surge.blasts.create( "{account_id}", from_: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Flash sale: 30% off everything this weekend. Shop at acme.com/sale", to: ["+18015551234", "+18025551234", "+18035551234"] ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, blast} = Surge.Blasts.create(client, "{account_id}", %{ from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Flash sale: 30% off everything this weekend. Shop at acme.com/sale", to: ["+18015551234", "+18025551234", "+18035551234"] }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/blasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "pn_01jrzhe8d9enptypyx360pcmxm", "body": "Flash sale: 30% off everything this weekend. Shop at acme.com/sale", "to": ["+18015551234", "+18025551234", "+18035551234"] }' ``` ### Send to contacts Pass contact IDs (with `ctc_` prefix) instead of phone numbers. This links the blast to your contact records for tracking and opt-out management. ```python Python theme={null} blast = surge.blasts.create( account_id="{account_id}", from_="pn_01jrzhe8d9enptypyx360pcmxm", body="Your subscription renews tomorrow. Manage it at acme.com/account", to=[ "ctc_01jrzhe8d9enptypyx360pcmxa", "ctc_01jrzhe8d9enptypyx360pcmxb", "ctc_01jrzhe8d9enptypyx360pcmxc", ], ) ``` ```typescript TypeScript theme={null} const blast = await surge.blasts.create("{account_id}", { from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your subscription renews tomorrow. Manage it at acme.com/account", to: [ "ctc_01jrzhe8d9enptypyx360pcmxa", "ctc_01jrzhe8d9enptypyx360pcmxb", "ctc_01jrzhe8d9enptypyx360pcmxc", ], }); ``` ```ruby Ruby theme={null} blast = surge.blasts.create( "{account_id}", from_: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your subscription renews tomorrow. Manage it at acme.com/account", to: [ "ctc_01jrzhe8d9enptypyx360pcmxa", "ctc_01jrzhe8d9enptypyx360pcmxb", "ctc_01jrzhe8d9enptypyx360pcmxc" ] ) ``` ```elixir Elixir theme={null} {:ok, blast} = Surge.Blasts.create(client, "{account_id}", %{ from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your subscription renews tomorrow. Manage it at acme.com/account", to: [ "ctc_01jrzhe8d9enptypyx360pcmxa", "ctc_01jrzhe8d9enptypyx360pcmxb", "ctc_01jrzhe8d9enptypyx360pcmxc" ] }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/blasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "pn_01jrzhe8d9enptypyx360pcmxm", "body": "Your subscription renews tomorrow. Manage it at acme.com/account", "to": [ "ctc_01jrzhe8d9enptypyx360pcmxa", "ctc_01jrzhe8d9enptypyx360pcmxb", "ctc_01jrzhe8d9enptypyx360pcmxc" ] }' ``` ### Schedule a blast Add `send_at` to queue the blast for a future time: ```python Python theme={null} blast = surge.blasts.create( account_id="{account_id}", name="April sale launch", from_="pn_01jrzhe8d9enptypyx360pcmxm", body="Acme Corp: Our biggest sale of the year starts now. Shop: acme.com/spring", to=["+18015551234", "+18025551234", "+18035551234"], send_at="2026-06-01T09:00:00Z", ) ``` ```typescript TypeScript theme={null} const blast = await surge.blasts.create("{account_id}", { name: "April sale launch", from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Acme Corp: Our biggest sale of the year starts now. Shop: acme.com/spring", to: ["+18015551234", "+18025551234", "+18035551234"], send_at: "2026-06-01T09:00:00Z", }); ``` ```ruby Ruby theme={null} blast = surge.blasts.create( "{account_id}", name: "April sale launch", from_: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Acme Corp: Our biggest sale of the year starts now. Shop: acme.com/spring", to: ["+18015551234", "+18025551234", "+18035551234"], send_at: "2026-06-01T09:00:00Z" ) ``` ```elixir Elixir theme={null} {:ok, blast} = Surge.Blasts.create(client, "{account_id}", %{ name: "April sale launch", from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Acme Corp: Our biggest sale of the year starts now. Shop: acme.com/spring", to: ["+18015551234", "+18025551234", "+18035551234"], send_at: "2026-06-01T09:00:00Z" }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/blasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "April sale launch", "from": "pn_01jrzhe8d9enptypyx360pcmxm", "body": "Acme Corp: Our biggest sale of the year starts now. Shop: acme.com/spring", "to": ["+18015551234", "+18025551234", "+18035551234"], "send_at": "2026-06-01T09:00:00Z" }' ``` *** ## Audiences ### Create an audience An audience is a named contact list. Create it first, then populate it. ```python Python theme={null} audience = surge.audiences.create( account_id="{account_id}", name="Premium subscribers", ) ``` ```typescript TypeScript theme={null} const audience = await surge.audiences.create("{account_id}", { name: "Premium subscribers", }); ``` ```ruby Ruby theme={null} audience = surge.audiences.create( "{account_id}", name: "Premium subscribers" ) ``` ```elixir Elixir theme={null} # Surge.Audiences isn't typed in the Elixir SDK yet — use the raw client. {:ok, audience} = Surge.Client.request(client, :post, "/accounts/{account_id}/audiences", json: %{name: "Premium subscribers"} ) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/audiences \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Premium subscribers" }' ``` ```json theme={null} { "id": "aud_01jrzhe8d9enptypyx360pcmxz", "name": "Premium subscribers" } ``` ### Add contacts to the audience ```python Python theme={null} surge.audiences.add_contact( audience_id="aud_01jrzhe8d9enptypyx360pcmxz", id="ctc_01jrzhe8d9enptypyx360pcmxa", ) ``` ```typescript TypeScript theme={null} await surge.audiences.addContact("aud_01jrzhe8d9enptypyx360pcmxz", { id: "ctc_01jrzhe8d9enptypyx360pcmxa", }); ``` ```ruby Ruby theme={null} surge.audiences.add_contact( "aud_01jrzhe8d9enptypyx360pcmxz", id: "ctc_01jrzhe8d9enptypyx360pcmxa" ) ``` ```elixir Elixir theme={null} {:ok, _} = Surge.Client.request(client, :post, "/audiences/aud_01jrzhe8d9enptypyx360pcmxz/contacts", json: %{id: "ctc_01jrzhe8d9enptypyx360pcmxa"} ) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/audiences/aud_01jrzhe8d9enptypyx360pcmxz/contacts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "id": "ctc_01jrzhe8d9enptypyx360pcmxa" }' ``` Repeat for each contact, or build this into the workflow that creates or updates contacts. ### Blast the audience Pass the audience ID in the `to` array. Surge expands the audience at send time and applies opt-out status automatically — opted-out contacts are skipped, so you never need to filter the list yourself. ```python Python theme={null} blast = surge.blasts.create( account_id="{account_id}", from_="pn_01jrzhe8d9enptypyx360pcmxm", body="Your monthly update from Acme Corp: acme.com/newsletter/april", to=["aud_01jrzhe8d9enptypyx360pcmxz"], ) ``` ```typescript TypeScript theme={null} const blast = await surge.blasts.create("{account_id}", { from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your monthly update from Acme Corp: acme.com/newsletter/april", to: ["aud_01jrzhe8d9enptypyx360pcmxz"], }); ``` ```ruby Ruby theme={null} blast = surge.blasts.create( "{account_id}", from_: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your monthly update from Acme Corp: acme.com/newsletter/april", to: ["aud_01jrzhe8d9enptypyx360pcmxz"] ) ``` ```elixir Elixir theme={null} {:ok, blast} = Surge.Blasts.create(client, "{account_id}", %{ from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your monthly update from Acme Corp: acme.com/newsletter/april", to: ["aud_01jrzhe8d9enptypyx360pcmxz"] }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/blasts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "pn_01jrzhe8d9enptypyx360pcmxm", "body": "Your monthly update from Acme Corp: acme.com/newsletter/april", "to": ["aud_01jrzhe8d9enptypyx360pcmxz"] }' ``` You can mix audience IDs, contact IDs, and raw phone numbers in the same `to` array. *** ## Volume caps Campaign volume limits apply to all sends, including blasts: | Campaign volume | T-Mobile limit | | --------------- | ----------------------------------------------------------- | | `low` | 2,000 SMS segments per day | | `high` | Up to 200,000 segments per day (subject to TCR trust score) | Surge queues messages within your daily cap and delivers them in order. If you regularly approach the `low` cap, request a volume increase through support after your campaign is approved. *** ## Tracking a blast Blast-level delivery metrics (deliverability rate, opt-out rate) are available in the dashboard under the Blasts section. Per-message delivery status comes through the `message.sent`, `message.delivered`, and `message.failed` webhook events — see [Track Message Delivery](./track-delivery) for the full mechanism. # Send to One Person Source: https://docs.surge.app/sending/send-one Send SMS and MMS to a single recipient: required fields, attachments, scheduling, personalization, and common errors. All outbound messages — SMS and MMS — go through the same endpoint: ``` POST /accounts/{account_id}/messages ``` ## Send an SMS The minimum required fields are `to` (the recipient's phone number in E.164 format) and `body` (the message text). If you've configured a default phone number on the account, you don't need to specify `from` — otherwise you'll get a [`no_phone_number` error](./handle-failures#no_phone_number). ```python Python theme={null} from surge import Surge surge = Surge() message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Your appointment is confirmed for Friday at 2pm.", ) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm.", }); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm." ) ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ to: "+18015551234", body: "Your appointment is confirmed for Friday at 2pm." }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Your appointment is confirmed for Friday at 2pm." }' ``` To send from a specific number, include its ID or E.164 number as `from`. (Python and Ruby use `from_` to avoid the reserved keyword; the wire field is still `from`.) ```python Python theme={null} message = surge.messages.create( account_id="{account_id}", to="+18015551234", from_="pn_01jrzhe8d9enptypyx360pcmxm", body="Your appointment is confirmed for Friday at 2pm.", ) ``` ```typescript TypeScript theme={null} const message = await surge.messages.create("{account_id}", { to: "+18015551234", from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your appointment is confirmed for Friday at 2pm.", }); ``` ```ruby Ruby theme={null} message = surge.messages.create( "{account_id}", to: "+18015551234", from_: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your appointment is confirmed for Friday at 2pm." ) ``` ```elixir Elixir theme={null} {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ to: "+18015551234", from: "pn_01jrzhe8d9enptypyx360pcmxm", body: "Your appointment is confirmed for Friday at 2pm." }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "from": "pn_01jrzhe8d9enptypyx360pcmxm", "body": "Your appointment is confirmed for Friday at 2pm." }' ``` The response includes the message ID, body, and the conversation thread the message belongs to: ```json theme={null} { "id": "msg_01jrzhe8d9enptypyx360pcmxj", "blast_id": null, "body": "Your appointment is confirmed for Friday at 2pm.", "attachments": [], "conversation": { "id": "cnv_01jrzhe8d9enptypyx360pcmxk" }, "metadata": {} } ``` ### Tracking delivery Message delivery status isn't a field on the REST response — it comes through webhook events. Subscribe to these event types: | Event | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------ | | `message.sent` | Carrier accepted the message for transmission | | `message.delivered` | Carrier confirmed the message reached the handset (or was handed off to the carrier for local numbers) | | `message.failed` | The message could not be delivered; the `reason` field in the event explains why | Not all carriers send delivery receipts, so `message.delivered` isn't guaranteed for every carrier. See [Webhook Events](../receiving/events) for the full payload shapes. ## Send an MMS Include an `attachments` array to send MMS. Each item needs a `url` pointing to a publicly accessible file. ```python Python theme={null} message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Here is your invoice.", attachments=[{"url": "https://acme.com/invoices/inv-1234.pdf"}], ) ``` ```typescript TypeScript theme={null} const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Here is your invoice.", attachments: [{ url: "https://acme.com/invoices/inv-1234.pdf" }], }); ``` ```ruby Ruby theme={null} message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Here is your invoice.", attachments: [{ url: "https://acme.com/invoices/inv-1234.pdf" }] ) ``` ```elixir Elixir theme={null} {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ to: "+18015551234", body: "Here is your invoice.", attachments: [%{url: "https://acme.com/invoices/inv-1234.pdf"}] }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Here is your invoice.", "attachments": [ { "url": "https://acme.com/invoices/inv-1234.pdf" } ] }' ``` The response includes the attachment IDs. To retrieve an attachment file later, use `GET /attachments/{attachment_id}/file`. ### MMS gotchas * **Content type**: some carriers reject attachment types they don't support. Common safe types are `image/jpeg`, `image/png`, and `image/gif`. PDFs deliver reliably to most handsets but not all. * **File size**: very large files may be silently truncated or fail delivery on certain networks. Keep attachments under 1 MB where possible. * **Body is optional**: you can send an MMS with attachments only, no `body` text. **Attachment URL restrictions.** Surge fetches the attachment URL to build the MMS, so the URL must be publicly accessible over `https://` or `http://` (no other schemes). The following are blocked: * Private IP addresses: `10.x.x.x`, `172.16.x.x`, `192.168.x.x`, `127.x.x.x`, etc. * Reserved TLDs: `.local`, `.internal`, `.localhost`, `.test` You cannot use localhost or internal network URLs for attachments, even in development. Host test files on a public URL or use a tunneling service like ngrok. ### Automatic SMS-to-MMS conversion Surge can automatically upgrade a plain SMS to MMS in two situations: 1. **Message exceeds 10 segments**: any message over 10 segments (around 1,530 GSM-7 characters or 670 UCS-2 characters) is automatically sent as MMS regardless of whether you included attachments. 2. **Message is 3 or more segments and Auto-MMS is enabled for your project**: long messages will automatically send as MMS to save you money by sending as a single MMS message instead of a concatenated SMS sequence. Reach out to support if you'd like to disable this behavior for your project. If the recipient's carrier doesn't support MMS (`mms_not_supported`), Surge falls back to SMS. ## Schedule a message Set `send_at` to an ISO 8601 timestamp to schedule a message for later delivery. The maximum scheduling window is 65 days from now. ```python Python theme={null} message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Your trial ends tomorrow, upgrade to keep your data.", send_at="2026-06-01T09:00:00Z", ) ``` ```typescript TypeScript theme={null} const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Your trial ends tomorrow, upgrade to keep your data.", send_at: "2026-06-01T09:00:00Z", }); ``` ```ruby Ruby theme={null} message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Your trial ends tomorrow, upgrade to keep your data.", send_at: "2026-06-01T09:00:00Z" ) ``` ```elixir Elixir theme={null} {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ to: "+18015551234", body: "Your trial ends tomorrow, upgrade to keep your data.", send_at: "2026-06-01T09:00:00Z" }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Your trial ends tomorrow, upgrade to keep your data.", "send_at": "2026-06-01T09:00:00Z" }' ``` A scheduled message is accepted and queued immediately. Delivery events (`message.delivered`, `message.failed`) fire when the message is actually sent at the scheduled time. ## Personalize messages with contact variables If the recipient has a contact record, Surge substitutes these tokens in the message body at send time: | Token | Replaced with | | -------------- | ------------------------------------------------------------- | | `{first_name}` | Contact's first name, or empty string if not set | | `{last_name}` | Contact's last name, or empty string if not set | | `{full_name}` | First and last name joined, or empty string if neither is set | ```python Python theme={null} message = surge.messages.create( account_id="{account_id}", to="+18015551234", body="Hi {first_name}, your order has shipped!", ) ``` ```typescript TypeScript theme={null} const message = await surge.messages.create("{account_id}", { to: "+18015551234", body: "Hi {first_name}, your order has shipped!", }); ``` ```ruby Ruby theme={null} message = surge.messages.create( "{account_id}", to: "+18015551234", body: "Hi {first_name}, your order has shipped!" ) ``` ```elixir Elixir theme={null} {:ok, message} = Surge.Messages.create(client, "{account_id}", %{ to: "+18015551234", body: "Hi {first_name}, your order has shipped!" }) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Hi {first_name}, your order has shipped!" }' ``` If the contact's `first_name` is `Jordan`, the recipient receives `Hi Jordan, your order has shipped!`. If the contact has no `first_name`, the `{first_name}` token is replaced with an empty string, so the recipient gets `Hi , your order has shipped!` — set names on your contacts before sending personalized messages to avoid awkward blanks. Tokens are replaced when you pass `to` (a phone number or contact ID); they are not replaced on blast sends that bypass contact resolution. ## Link shortening When link shortening is enabled on your account, Surge rewrites URLs in the message body to shortened links before sending. Shortened links power the [`link.followed`](../api-reference/webhooks/link/followed) webhook event, which fires the first time a contact opens the link. The account-level default is managed by Surge. Contact support to change it for your account. To override the default for a single message, pass a `settings` object with `link_shortening` set to `"enabled"` or `"disabled"`. This is currently the only supported per-message setting. ```bash cURL theme={null} curl -X POST https://api.surge.app/accounts/{account_id}/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+18015551234", "body": "Track your order at https://acme.com/orders/ord-9887/tracking", "settings": { "link_shortening": "disabled" } }' ``` Links over 1000 bytes are too long for the shortener and fail with a [`link_size_limit` error](./handle-failures#link_size_limit). For deliverability guidance on shortened links, see [Deliverability](../concepts/deliverability). ## Add metadata `metadata` is a free-form JSON object you can attach to any message. It's returned in webhook events and list responses, which makes it useful for correlating Surge messages with records in your own system. ```json theme={null} { "to": "+18015551234", "body": "Your order has shipped.", "metadata": { "order_id": "ord_9887", "customer_tier": "premium" } } ``` Keep metadata values below a few KB. Metadata can have up to 50 keys, each up to 50 characters long, with values up to 500 characters long. They are embedded in every webhook payload that references the message, which can make your webhook handler payload processing heavier than necessary if large metadata objects are used. ## Common errors | Error type | Cause | Fix | | ---------------------- | ------------------------------------------ | -------------------------------------------------- | | `opted_out` | The recipient has replied STOP | Do not send to opted-out contacts | | `demo_message_limit` | You've hit the 25-message demo cap | [Register and buy a number](../registration/index) | | `link_size_limit` | The message body contains too many URLs | Reduce links or use link shortening | | `invalid_content_type` | Attachment type unsupported by the carrier | Use a supported image format | Full error reference is in [Handle Failures](./handle-failures) and the [Error Reference](../api-reference/errors). # Track Message Delivery Source: https://docs.surge.app/sending/track-delivery How message delivery statuses work, webhook-based tracking, and how to list messages with cursor pagination. Message delivery status comes through webhook events, not the REST API response. The create endpoint (`POST /accounts/{account_id}/messages`) returns the message object immediately — the status at that point is "created", and what happens next depends on the carrier. ## How delivery works ```mermaid theme={null} stateDiagram-v2 [*] --> created created --> sent sent --> delivered sent --> failed ``` | Status | Meaning | | ----------- | --------------------------------------------------------------------------- | | `created` | Message created and accepted by the API | | `sent` | Carrier accepted the message (`message.sent` webhook fires) | | `delivered` | Carrier confirmed delivery to the handset (`message.delivered` fires) | | `failed` | Delivery failed at any stage (`message.failed` fires with `failure_reason`) | Status advances in one direction only. If Surge receives an out-of-order carrier status report (a rare but real occurrence on some networks), it is discarded rather than rolling the status back. A message in `delivered` state will never revert to `sent`. Not all carriers send delivery receipts. If you never receive `message.delivered` after `message.sent`, it doesn't mean the message wasn't delivered. The carrier simply didn't confirm it. This can be seen in messaging to the US and Canada, but it's especially prevalent outside of North America. ## Webhook-based tracking (recommended) Configure a webhook endpoint (Settings → Webhooks in the dashboard) and handle the events you care about: ```python theme={null} @app.post("/webhooks/surge") async def surge_webhook(request: Request): payload = wh.verify(await request.body(), request.headers) match payload["type"]: case "message.delivered": msg = payload["data"] await db.messages.update(msg["id"], {"status": "delivered"}) case "message.failed": msg = payload["data"] await db.messages.update(msg["id"], { "status": "failed", "failure_reason": msg.get("failure_reason") }) return Response(status_code=200) ``` Return `200` quickly. If your processing takes time, enqueue the work and return immediately — Surge retries if your handler times out. See [Receiving Messages & Webhooks](../receiving/index) for endpoint setup and signature verification. ## Retrieving a message by ID The retrieve-message response does **not** include a `status` field. It returns the message body, attachments, conversation thread, and metadata. For status, rely on the webhook events. You can retrieve a sent message to check its current fields: ```python Python theme={null} from surge import Surge surge = Surge() message = surge.messages.retrieve("{message_id}") print(message.body) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const message = await surge.messages.retrieve("{message_id}"); console.log(message.body); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new message = surge.messages.retrieve("{message_id}") puts message.body ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, message} = Surge.Messages.get(client, "{message_id}") IO.puts(message.body) ``` ```bash cURL theme={null} curl https://api.surge.app/messages/{message_id} \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "id": "msg_01jrzhe8d9enptypyx360pcmxj", "blast_id": null, "body": "Your appointment is confirmed for Friday at 2pm.", "attachments": [], "conversation": { "id": "cnv_01jrzhe8d9enptypyx360pcmxk" }, "metadata": {} } ``` Use this endpoint to fetch the message body or attachment URLs if you didn't store them at send time. For status, rely on the webhook events. ## Listing messages To retrieve all messages for an account, use the list endpoint with cursor-based pagination: ```python Python theme={null} # Auto-paginating iterator — call list() and iterate for message in surge.messages.list(account_id="{account_id}"): print(message.id, message.body) ``` ```typescript TypeScript theme={null} // Auto-paginating async iterator for await (const message of await surge.messages.list("{account_id}")) { console.log(message.id, message.body); } ``` ```ruby Ruby theme={null} # Cursor — page.data is the current page's array; auto_paging_each iterates everything page = surge.messages.list("{account_id}") page.data.each { |m| puts "#{m.id} #{m.body}" } ``` ```elixir Elixir theme={null} # Surge.Messages.list isn't typed in the Elixir SDK yet — use the raw client. {:ok, page} = Surge.Client.request(client, :get, "/accounts/{account_id}/messages") Enum.each(page["data"], fn m -> IO.puts("#{m["id"]} #{m["body"]}") end) ``` ```bash cURL theme={null} curl "https://api.surge.app/accounts/{account_id}/messages" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```json theme={null} { "data": [...], "pagination": { "next_cursor": "eyJpZCI6Ii4uLiJ9", "previous_cursor": null } } ``` Pass `after=CURSOR` or `before=CURSOR` as query parameters to page through results. The list returns messages in reverse chronological order (most recent first). # Send and verify a code Source: https://docs.surge.app/verifications/send-and-verify Send a six-digit OTP to a phone number and verify the user's code with two API calls, expiry and retry limits included. Surge's verification API sends a six-digit code to a phone number and then checks the code the user provides. It handles code generation, delivery, retry limits, and expiration. You don't manage any of that yourself. But the biggest benefit to using Surge Verifications is that codes are sent through our registered numbers, so you don't need to register a campaign, toll-free number, or sender ID to be able to send verification codes. ## How it works Verification is a two-step flow: 1. **Create a verification**: Surge sends a six-digit code to the number 2. **Check the code**: Surge validates the code the user entered ``` POST /verifications → creates the verification, sends the code POST /verifications/{verification_id}/checks → validates the user's input ``` ## 1. Send the code ```python Python theme={null} from surge import Surge surge = Surge() verification = surge.verifications.create(phone_number="+18015551234") print(verification.id) ``` ```typescript TypeScript theme={null} import Surge from "@surgeapi/node"; const surge = new Surge(); const verification = await surge.verifications.create({ phone_number: "+18015551234", }); console.log(verification.id); ``` ```ruby Ruby theme={null} require "surge_api" surge = SurgeAPI::Client.new verification = surge.verifications.create(phone_number: "+18015551234") puts verification.id ``` ```elixir Elixir theme={null} client = Surge.Client.new(System.get_env("SURGE_API_KEY")) {:ok, verification} = Surge.Verifications.create(client, %{phone_number: "+18015551234"}) IO.puts(verification.id) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/verifications \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+18015551234" }' ``` ```json theme={null} { "id": "vfn_01jrzhe8d9enptypyx360pcmxj", "phone_number": "+18015551234", "status": "pending", "attempt_count": 0 } ``` The user receives a text like: *"Your verification code is 847291. It expires in 10 minutes."* ## 2. Check the code When the user submits the code in your UI, send it to Surge for verification: ```python Python theme={null} result = surge.verifications.check("{verification_id}", code="847291") print(result.result) # "ok", "incorrect", "expired", etc. ``` ```typescript TypeScript theme={null} const result = await surge.verifications.check("{verification_id}", { code: "847291", }); console.log(result.result); ``` ```ruby Ruby theme={null} result = surge.verifications.check("{verification_id}", code: "847291") puts result.result ``` ```elixir Elixir theme={null} {:ok, result} = Surge.Verifications.check(client, "{verification_id}", %{code: "847291"}) IO.puts(result.result) ``` ```bash cURL theme={null} curl -X POST https://api.surge.app/verifications/{verification_id}/checks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "847291" }' ``` The response includes a `result` field indicating whether the verification succeeded: ```json theme={null} { "result": "ok", "verification": { "id": "vfn_01jrzhe8d9enptypyx360pcmxj", "phone_number": "+18015551234", "status": "verified", "attempt_count": 1 } } ``` ## Limits * **Expiration:** 10 minutes from creation. After that, the verification moves to `expired` status and all check attempts return `expired`. * **Max attempts:** 3 incorrect guesses per verification. After 3 incorrect attempts, the verification moves to `exhausted` status. Create a new verification and prompt the user to resend the code. ## Result values | Result | Meaning | What to do | | ------------------ | -------------------------------------- | ---------------------------------------------------- | | `ok` | Code matched | Grant access | | `incorrect` | Wrong code | Show an error, let the user retry | | `expired` | Code is more than 10 minutes old | Prompt user to request a new code | | `already_verified` | This verification was already approved | Treat as `ok` (idempotent) | | `exhausted` | 3 incorrect attempts reached | Create a new verification; do not retry the same one | ## Putting it together A complete flow in Python: ```python theme={null} from surge import Surge surge = Surge() # User enters their phone number response = surge.verifications.create(phone_number="+18015551234") verification_id = response.id # Later: user submits the code check = surge.verifications.check( id=verification_id, code=user_submitted_code ) match check.result: case "ok" | "already_verified": # Grant access await grant_access(user) case "incorrect": return {"error": "Incorrect code. Please try again."} case "expired": return {"error": "Code expired. Request a new one."} case "exhausted": return {"error": "Too many attempts. Please request a new code."} ``` For deliverability quirks and edge cases, see [Usage patterns](./usage-patterns). # Usage patterns and delivery gotchas Source: https://docs.surge.app/verifications/usage-patterns Opted-out contacts, international quirks, carrier filtering, and security tips for OTP verification in production. Verification codes are sent as SMS, which inherits SMS's quirks. Here's what to plan for in production. ## Opted-out contacts If the phone number has previously replied "STOP" to a Surge verification number, the API returns an `opted_out` error on the create call. You cannot send verification codes to opted-out numbers. Show the error's `message` field to the user which tells them to send the code "START" to the appropriate phone number to opt back in. Alternatively offer the user a different verification path (email, support contact). Don't retry on the same number. ## International numbers Codes deliver to most international numbers, but delivery speed varies by carrier. If a user doesn't receive the code within 60 seconds: * Prompt them to request a resend (create a new verification). * The original verification is now wasted from the user's perspective; if the new code arrives and matches, treat it as success. * For a small number of countries / carriers, delivery can be intermittent. Monitor `result: expired` rates by region as a leading indicator. ## Carrier filtering Verification codes are generally exempt from spam filtering, carriers know OTP traffic is high-trust by pattern. But: * Very high volumes of verifications to a single area code or carrier can trigger rate limits. * A handful of carriers periodically tighten their filters and OTP messages get caught. If you see a spike in `incorrect` results that's actually "user never got the code," check the per-carrier delivery rate in your message logs. ## Sender number Verification codes are sent from a shared pool of Surge demo numbers, not from any phone number on your account. The sending number varies between verifications, users may see different "from" numbers across OTP flows. This is expected behavior. You cannot customize the sender number for verifications. This also means that if a user texts STOP to the sender number from a verification, it opts them out of that specific Surge number, not out of your campaign numbers. Contact support if this creates operational issues. ## Implementation tips * **Lock retries on your side too.** Surge enforces an `exhausted` threshold, but you should also rate-limit resends and re-checks at your application layer to prevent one user from racking up SMS costs. * **Log the result, not the code.** When debugging, log `verification.id` and `result` — never the six-digit code. Treat the code like a password.