Skip to content

Custom SMS Gateway

This page explains how to connect an SMS carrier that is not on the provider list by using the Custom gateway provider. It covers the Configuration Manager fields, the webhooks the platform exposes, the request/response contracts your adapter must speak, and an optional reference adapter (a small local HTTP service that bridges Twilio to the Custom-provider contract) that you can download and run for lab testing.

Check the provider list first

Custom is the last resort, not the starting point. Before building anything, work through these in order:

  1. Look for your carrier on the provider list. Telnyx, Twilio, Bandwidth, SignalWire, Plivo, Flowroute, MessageBird, Vonage, Infobip, and Sinch are all supported directly, with no adapter and nothing extra to run. See Messaging Gateways.
  2. Ask us to add it. Most carriers turn out to be an ordinary HTTP API that differs from one we already support only in what the fields are called. Adding one of those is a configuration change on our side, not an integration project — usually far less work than anything on this page, and it means you get the same tests and support as every other provider. Tell us which carrier you need before you start writing code.
  3. Use a transformation URL. If the carrier is nearly compatible but needs a value computed rather than renamed, the Transform URL field lets you supply just that piece without standing up a full adapter. See Transformation URL below.
  4. Then write an adapter, using the contracts on this page.

What the Custom provider gives you

Selecting Provider = Custom on an SMS gateway tells Configuration Manager to use its generic SMS contract instead of a built-in carrier driver. Once saved, the platform exposes:

  • Outbound sending — Configuration Manager submits outbound messages to the URL you configure in Provider’s URL. Your adapter or your carrier’s REST endpoint is responsible for actually placing the message with the carrier.
  • Inbound webhook — https://<domain>/sms/<gateway-name>/generic on the selected gateway domain. Your adapter (or the carrier itself, if it can be configured to post directly) forwards received SMS/MMS events here.
  • Delivery status webhook — https://<domain>/sms/<gateway-name>/generic/status for delivery receipts.

Replace <domain> with the value picked in the gateway’s Domain field and <gateway-name> with the gateway’s Name.

Configuration Manager fields

Open Configuration Manager -> Communications Settings -> Messaging Gateways and create a new gateway.

  • Name — unique alphanumeric name (no spaces). Appears in the generated webhook URLs.
  • Provider — set to Custom.
  • Description — optional.
  • Domain — public domain that Twilio / your carrier / your adapter will reach inbound and status webhooks on.
  • Provider’s URL — full URL to which outbound send requests will be submitted. Point this at your own adapter, your carrier’s API, or (for lab testing) at the bundled reference sample gateway.
  • Account SID — identifier passed in outbound requests, reused as a generic credential.
  • Auth Token — secret paired with Account SID.
  • Webhook secret — optional shared secret your adapter must present on every inbound webhook. See Securing the inbound webhook.
  • Transform URL — optional. Advanced; leave blank unless you need it. See Transformation URL.
  • Reject unverified webhooks — when enabled, inbound webhooks that fail verification are dropped instead of being accepted with a warning.

After the gateway is saved, the form shows the generated Webhook URL and Delivery Status URL. Configure those at your carrier’s portal (or, for the reference adapter, at Twilio).

Transformation URL

An adapter has to sit in the request path, which means running a service, keeping it up, and securing it. Often that is more machinery than the problem needs: the carrier’s API is close enough to work, but one value has to be computed — a checksum over several fields, a lookup, a decision that depends on something only you know.

Transform URL covers that case without an adapter. When it is set, the platform POSTs the payload to your URL and uses the response in place of its own interpretation, in both directions. Your code runs in your process, in whatever language you like, and cannot affect the platform if it fails or is slow.

Return {"ignore": true} to have an event discarded — useful when a carrier posts event types you do not want treated as messages.

Prefer a full adapter only when you genuinely need to be in the request path: to hold a persistent connection to the carrier, or to speak something other than HTTP.

Securing the inbound webhook

The inbound webhook URL is not secret in any useful sense: it contains only the gateway name and the provider, so anyone who learns it can post a message that reaches an agent and is recorded in reporting as genuine. A Custom gateway has no carrier signature to check, so the platform verifies a shared secret instead.

Set Webhook secret on the gateway, then have your adapter send the same value on every inbound and status POST, either as a header:

X-TL-Webhook-Secret: <secret>

or, for senders that cannot set headers, as a query parameter on the webhook URL:

https://<domain>/sms/<gateway-name>/generic?webhook_secret=<secret>

Verification is compared in constant time, and the secret is encrypted at rest and masked in logs.

While Reject unverified webhooks is off, a request that fails verification is still processed and a warning naming the sending address is written to the api-server-ng log. This is deliberate: it lets you add the secret to an integration that is already carrying live traffic and confirm the log is clean before you start dropping anything. Once it is clean, enable rejection.

Gateways whose carrier defines its own signature scheme are verified with it and need no shared secret: Twilio inbound and status callbacks are checked against X-Twilio-Signature, and WhatsApp webhooks against Meta’s X-Hub-Signature-256 using the app secret stored on the WhatsApp account.

You still need to flag the relevant DIDs as SMS-enabled and configure Inbound Messaging Routes so that inbound messages reach users or queues.

Message flow

flowchart LR
App["Your app / Configuration Manager"] -->|"outbound send"| Adapter["Your adapter or carrier API<br/>(Provider's URL)"]
Adapter -->|"carrier API call"| Carrier["SMS carrier"]
Carrier -->|"inbound SMS"| Adapter
Carrier -->|"delivery receipt"| Adapter
Adapter -->|"POST /sms/&lt;name&gt;/generic"| Platform["Thirdlane Platform"]
Adapter -->|"POST /sms/&lt;name&gt;/generic/status"| Platform

Your adapter is the translation layer: it speaks the carrier’s API on one side and the Custom-provider contract on the other. Some carriers can be configured to POST directly to the platform webhooks, in which case the adapter is only needed for outbound translation (or not at all, if the carrier’s outbound API is compatible with what Configuration Manager submits).

Request/response contracts

The platform uses a Twilio-compatible form-encoded shape for outbound, inbound, and status events.

1. Outbound send

Configuration Manager submits a POST to the configured Provider’s URL with:

  • Content-Type: application/x-www-form-urlencoded
  • Fields:
    • From — sender number in E.164
    • To — recipient number in E.164
    • Body — message text
    • MediaUrl — (optional) media URL for MMS
    • StatusCallback — the generated Delivery Status URL for the gateway

Your endpoint should respond with JSON compatible with Twilio’s Messages.json response (at minimum a message sid and an initial status such as queued).

2. Inbound webhook

Your adapter (or the carrier) posts inbound events to:

POST https://<domain>/sms/<gateway-name>/generic
Content-Type: application/x-www-form-urlencoded

Typical fields:

  • MessageSid
  • From
  • To
  • Body
  • NumMedia
  • Media fields when NumMedia > 0: MediaUrl0, MediaContentType0, …

3. Delivery status webhook

Delivery updates post to:

POST https://<domain>/sms/<gateway-name>/generic/status
Content-Type: application/x-www-form-urlencoded

Typical fields:

  • MessageSid
  • SmsSid
  • MessageStatus / SmsStatus (queued, sent, delivered, undelivered, failed, received)
  • From
  • To
  • RawDlrDoneDate (optional, carrier-specific)

Reference sample gateway (Twilio adapter)

A minimal reference adapter is provided as a starting point. It runs as a small local HTTP service that:

  • Sends outbound SMS through the Twilio API.
  • Receives inbound SMS/MMS from Twilio and forwards the payload to your upstream Custom-provider inbound URL.
  • Forwards delivery status callbacks from Twilio to your upstream status URL.

Three equivalent implementations are provided; pick whichever language your operators are comfortable with:

  • sms_gw.pl — Perl.
  • sms_gw.py — Python 3, standard library only (no pip packages).
  • sms_gw.js — Node.js, built-in modules only (no npm install).

Only one sample can listen on the default port (65533) at a time.

Configuration constants

Edit the constants at the top of the sample you choose:

  • SID — Twilio Account SID.
  • TOKEN — Twilio Auth Token.
  • DOMAIN_API_SMS_SERVICE — your upstream host (the Domain set on the Custom gateway).
  • URI_API_SMS_SERVICE_INBOUND — upstream path for inbound events, e.g. /sms/<gateway-name>/generic.
  • URI_API_SMS_SERVICE_STATUS — upstream path for delivery status events, e.g. /sms/<gateway-name>/generic/status.

Local listener defaults:

  • Address: 127.0.0.1
  • Port: 65533

Local routes served by the sample

These routes are sample-only and intentionally prefixed with /testsms/ so they do not collide with the platform’s production /sms/... webhooks:

  • GET/POST /testsms/generic/send — sends an SMS using form fields from, to, body, optional mediaUrl.
  • POST /testsms/generic — receives inbound Twilio payload and forwards it to your upstream inbound URL.
  • POST /testsms/generic/status — receives Twilio status callbacks and forwards them to your upstream status URL.

Example outbound call:

Terminal window
curl -X POST "http://127.0.0.1:65533/testsms/generic/send" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "from=+14159917673" \
--data-urlencode "to=+15555550123" \
--data-urlencode "body=Hello from Custom provider"

Run

From the directory where you saved the sample, run one of:

Terminal window
perl sms_gw.pl
python3 sms_gw.py
node sms_gw.js

Expected startup output:

Upstream: http://127.0.0.1:65533/

Exposing the sample via Nginx

If Twilio (or Configuration Manager) must reach the sample over HTTPS on your server, add the bundled Nginx snippets.

Upstream — save as /etc/nginx/conf.d/https/upstream/custom/custom_sms_gw.conf:

upstream custom_sms_gw {
server 127.0.0.1:65533;
}

Service location — save as /etc/nginx/conf.d/https/service/custom/custom_sms_gw_svc.conf:

location /testsms {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://custom_sms_gw;
proxy_read_timeout 86400s;
proxy_http_version 1.1;
}

Then reload Nginx:

Terminal window
sudo nginx -t && sudo systemctl reload nginx

Moving toward production

Earlier versions of this page carried a ten-point checklist for hardening a custom gateway. Most of those points are the platform’s responsibility, and presenting them as yours was misleading. What you get on a Custom gateway, exactly as on a built-in provider:

  • Webhook verification. The shared secret is checked on every inbound and status request, with the option to reject failures outright.
  • Duplicate deliveries. A redelivered webhook is recognized by its message ID and discarded, so it cannot become a second message or a second billing record.
  • Unknown delivery receipts. A receipt for a message the platform did not send is discarded rather than acted on.
  • Status normalization. Carrier vocabulary is mapped onto one set of states, and a value that is not recognized is logged and dropped rather than guessed at.
  • Timeouts and retries on reads. Sends are deliberately not retried, because a retried send that actually succeeded delivers the message twice.
  • Opt-out. STOP and its variants suppress further messages to that number automatically and are recorded for audit. This is a legal requirement in most jurisdictions and is not something your adapter should implement.
  • Traffic capture. Capture traffic for troubleshooting records both the raw payload and how the platform interpreted it, which is usually enough to settle whether a fault is yours or the carrier’s.

What is genuinely yours, if you run an adapter:

  • The carrier contract, number provisioning, and keeping credentials somewhere sensible.
  • Everything about the adapter as a service: TLS certificate verification, authentication on any endpoint you expose, rate and request-size limits, and not writing message bodies to logs that should not hold them.
  • Validating what you send: E.164 formatting, message length, and acceptable media types.
  • Monitoring the adapter itself. Send success and failure rates, carrier API latency and error codes, and an alert on a sudden drop in deliveries.

Before going live, test at minimum: a plain outbound message, an outbound message with media, an invalid destination number, a carrier timeout, an inbound message with no media, an inbound message with several attachments, every terminal delivery status, and the same webhook delivered twice.