Skip to content

SMS API Providers

pg_relay sends SMS the same way it reaches Slack or Teams: through the webhook transport, with pg_relay_notifier building the provider-correct message and a classify_webhook_response branch interpreting the reply — zero Processor code per provider. This chapter assesses twelve SMS providers against that transport's rules: the three auth styles, the body_style encodings, and _env: secret resolution (which walks the profile only — never the message).

Provider Supported auth.style body_style Send endpoint Success reply → provider ref
Twilio Yes basic_auth form POST https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json 201 → body sid
Telnyx Yes bearer_header json POST https://api.telnyx.com/v2/messages 200 → body data.id
Bandwidth Yes basic_auth json POST https://messaging.bandwidth.com/api/v2/users/{account_id}/messages 202 → body id
Plivo Yes basic_auth json POST https://api.plivo.com/v1/Account/{auth_id}/Message/ 202 → body message_uuid
Vonage Yes basic_auth json POST https://api.nexmo.com/v1/messages 202 → body message_uuid
AWS SNS No
Sinch Yes bearer_header json POST https://sms.api.sinch.com/xms/v1/{service_plan_id}/batches 201 → body id
Infobip Yes custom_header json POST https://{base_url}.api.infobip.com/sms/2/text/advanced 200 → body messages[0].messageId
Telesign Yes basic_auth form POST https://rest-api.telesign.com/v1/messaging 2xx → body reference_id
Kaleyra Yes custom_header json POST https://api.kaleyra.io/v1/{sid}/messages 2xx → body id
MessageBird Yes custom_header json POST https://rest.messagebird.com/messages 201 → body id
Sendblue Yes custom_header json POST https://api.sendblue.co/api/send-message 2xx → body message_handle

Eleven of the twelve work today. Providers whose name links to a section below need something beyond the table — a header trick, a specific API variant, or a warning. The four that don't — Telnyx, Bandwidth, Plivo, Sinch — are clean fits; one worked example covers them all, below.

Rules that apply to every provider

"Sent" means accepted, not delivered. Every SMS provider reports final delivery (the DLR) through a callback webhook — and pg_relay deliberately has no inbound HTTP listener. A 2xx reply is terminal sent; the provider reference recorded by set_status is the message id, which is your handle for reconciling delivery out of band in the provider's console or logs.

Classification is near-uniform. A well-behaved classify_webhook_response branch treats a 2xx as sent (with the id from the body as the provider reference), 429 / any 5xx / http_status = 0 as retry, and any other 4xx as failed. Infobip is the one exception — see below.

Phone numbers are message content. The same rule that keeps email addresses out of pg_relay's logs applies to phone numbers: they must never appear in detail text your classifier builds, in trigger-side logging, or anywhere else outside the message itself. A provider's own error body may echo a number back — pass it through as detail only if you accept that, or strip it.

Documentation-verified, not live-verified

The endpoints, auth mechanics, and response shapes in this chapter come from each provider's current documentation, not from live sends through pg_relay. Run one real send per provider — the same discipline used for the Slack recipe — before relying on a profile in production.

The clean fits: Telnyx, Bandwidth, Plivo, Sinch

These four need nothing beyond the table: a JSON body, a credential the auth block expresses directly, and a message id in the reply. Telnyx, the cleanest of all, as the worked example:

// profile
{
  "url": "https://api.telnyx.com/v2/messages",
  "auth": {"style": "bearer_header", "secret": "_env:TELNYX_API_KEY"},
  "timeout_seconds": 30
}

// message — Telnyx's own request shape, sent verbatim
{
  "from": "+61480000000",
  "to": "+61400000000",
  "text": "disk alert: volume is filling"
}

Bandwidth and Plivo swap the auth block for basic_auth (Bandwidth: an API user and password; Plivo: the Auth ID as username, the Auth Token as secret) and use their own field names; Sinch uses bearer_header with its service-plan API token and a batches request shape. In every case the reply's message id is the natural provider reference.

Twilio

Twilio's Messages API accepts only form-encoded bodies — this is the provider the webhook transport's body_style: "form" option exists for. The message must be flat (which Twilio's is anyway), and an array of scalars like MediaUrl becomes repeated form keys automatically.

// profile
{
  "url": "https://api.twilio.com/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXX/Messages.json",
  "body_style": "form",
  "auth": {"style": "basic_auth", "username": "ACXXXXXXXXXXXXXXXX", "secret": "_env:TWILIO_AUTH_TOKEN"},
  "timeout_seconds": 30
}

// message — Twilio's form fields, capitalised exactly as Twilio names them
{
  "To": "+61400000000",
  "From": "+61480000000",
  "Body": "disk alert: volume is filling"
}

The Account SID appears twice — in the URL and as the Basic-auth username — which is Twilio's convention, not a mistake. A 201 reply carries the message sid (provider reference) and a status of queued; Twilio's StatusCallback delivery receipts have nowhere to land in pg_relay, so delivery confirmation stays in the Twilio console.

Telesign

The second form-encoded API. Same body_style: "form" arrangement as Twilio, with Basic auth carrying the Customer ID as username and the API key as secret. Telesign's documentation specifies the Content-Type with a charset suffix, which the headers override handles:

// profile
{
  "url": "https://rest-api.telesign.com/v1/messaging",
  "body_style": "form",
  "auth": {"style": "basic_auth", "username": "YOUR_CUSTOMER_ID", "secret": "_env:TELESIGN_API_KEY"},
  "headers": {"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"},
  "timeout_seconds": 30
}

// message
{
  "phone_number": "61400000000",
  "message": "disk alert: volume is filling",
  "message_type": "ARN"
}

The reply's reference_id is the provider reference. (message_type is Telesign's traffic classification — ARN for alerts and notifications.)

Vonage — Messages API only

Vonage works through its Messages API (POST https://api.nexmo.com/v1/messages, Basic auth with the API key as username and secret as secret, JSON body with message_type: "text" and channel: "sms"; 202message_uuid).

Its legacy SMS API expects api_key and api_secret inside the request body. _env: resolution never touches message, so historically that made the legacy API unusable; the profile's body_merge field now covers it — the profile injects both keys from environment variables, and the secrets never enter the notifier's message data:

// profile addition for the legacy API (body_style "form", per its docs)
"body_merge": {"api_key": "_env:VONAGE_API_KEY", "api_secret": "_env:VONAGE_API_SECRET"}

Prefer the Messages API anyway — it is the current, documented, feature-complete route — but the legacy endpoint is no longer structurally off-limits. The general rule this chapter used to state ("any API that authenticates in the request body is unusable") is retired: a body-authenticating API is served by body_merge, with the credential still sourced from the environment and the merged key owned by the profile, never the producer.

AWS SNS — why "No"

SNS requires every request to be signed with AWS Signature Version 4 — a rolling HMAC computed over the request itself. There is no static-header form of SigV4, so none of the webhook transport's auth styles can express it, and implementing a request signer is explicitly out of scope for the Processor (the same ruling that excludes JWT service-account flows). Unlike SES — which the Amazon SES chapter reaches over SMTP precisely to avoid SigV4 — SNS has no alternative protocol to fall back to.

If your stack is AWS-committed, the practical options are a supported provider from this table, or your own thin proxy (e.g. an API Gateway + Lambda that accepts a static bearer token and calls SNS with IAM credentials) — but that proxy is your infrastructure, outside pg_relay's remit.

Infobip

Two quirks. First, auth: Infobip's header is Authorization: App YOUR_API_KEY — an App scheme, not Bearer — so bearer_header would send the wrong prefix. Use custom_header on the Authorization header with the entire value, scheme included, stored in the environment variable:

export INFOBIP_AUTH="App your-api-key-here"
// profile
{
  "url": "https://xxxxx.api.infobip.com/sms/2/text/advanced",
  "auth": {"style": "custom_header", "header_name": "Authorization", "secret": "_env:INFOBIP_AUTH"},
  "timeout_seconds": 30
}

Second, classification: Infobip answers 200 for requests it accepted for processing per message, with each message's real disposition in the body's status object. Your classifier must read messages[0].status.groupNamePENDING (accepted, in flight) is sent; REJECTED is failed even though the HTTP status said 200. Trusting the HTTP code alone would record rejected messages as delivered.

Kaleyra

Kaleyra.io (Tata Communications) authenticates with a bare api-key header — a straightforward custom_header:

// profile
{
  "url": "https://api.kaleyra.io/v1/HXAP16XXXXXXXXXX/messages",
  "auth": {"style": "custom_header", "header_name": "api-key", "secret": "_env:KALEYRA_API_KEY"},
  "timeout_seconds": 30
}

Its /messages endpoint documents both form-data and JSON request examples; the JSON form (with to, sender, type, body, channel: "sms") is the one to use, keeping body_style at its default. The SID in the URL identifies your account. Of the eleven supported providers this is the one whose documentation was thinnest at assessment time — give its live-send verification extra attention.

MessageBird (now Bird)

Same custom_header trick as Infobip, different scheme: the header is Authorization: AccessKey YOUR_KEY, so the environment variable holds the full AccessKey ... value and custom_header targets Authorization. The classic rest.messagebird.com/messages API (JSON: originator, recipients array, body; 201id) remains the simplest route. The company has rebranded as Bird with a newer channels-based API — also JSON with header auth, so equally compatible — but the legacy endpoint is the better-documented recipe today; check which your account is provisioned for.

Sendblue

Sendblue (iMessage-first, US-centric, with SMS fallback) authenticates with two secret headers — sb-api-key-id and sb-api-secret-key — where the auth block holds exactly one. The second header rides in profile.headers, which works because _env: resolution walks every string in the profile, headers included:

// profile
{
  "url": "https://api.sendblue.co/api/send-message",
  "auth": {"style": "custom_header", "header_name": "sb-api-key-id", "secret": "_env:SENDBLUE_KEY_ID"},
  "headers": {"sb-api-secret-key": "_env:SENDBLUE_KEY_SECRET"},
  "timeout_seconds": 30
}

// message
{
  "number": "+15550000000",
  "content": "disk alert: volume is filling"
}

The reply's message_handle is the provider reference. Which of the two headers sits in auth and which in headers is arbitrary — both are resolved from the environment either way.


Continue to Incident Alerting Platforms — the same assessment format pointed at PagerDuty, Jira Service Management, and ten other on-call platforms, where dedup keys make pg_relay's at-least-once delivery genuinely idempotent.