← All essays
May 13, 20265 min read

Repointing Twilio Webhooks Via REST API, Not the Console

Repointing 200 Twilio webhooks via the console is a four-hour, error-prone afternoon. Via the REST API it is a 90-second script. Here is the script.

Repointing Twilio Webhooks Via REST API, Not the Console

The cutover plan had a single Twilio item on it: "repoint inbound webhooks to the new origin." Six numbers, six webhook URLs, supposedly a five-minute job in the console.

I budgeted forty. It took two — because I never opened the console.

Why the console is the wrong tool for this

Twilio's console is fine when you're configuring a single number for the first time. It is the wrong tool the moment you need to change the same field on six numbers, in a known order, at a known wall-clock minute, with an audit trail and a rollback.

The failure modes of a console-driven repoint are familiar to anyone who has done one:

  • Tab fatigue. Six numbers means six tabs, six edit panels, six save buttons. By number four you are clicking on muscle memory and one of those saves goes to the wrong field.
  • No diff, no rollback. The console doesn't tell you what the previous value was. If the cutover aborts at minute 3, "put it back the way it was" requires you to remember what each number pointed to fifteen minutes ago.
  • Out-of-band rate. You repoint number one, an SMS lands a second later, and now you don't know whether it hit the old origin or the new one. The console gives you no per-call timestamp tighter than "today."
  • No record. The change shows up in Twilio's audit log eventually, but not in your run-book. Two weeks later when somebody asks "when did Twilio start hitting the new origin?" the answer is "uh, around 7pm-ish."

The REST API fixes every one of those.

What the call actually looks like

The endpoint is plain HTTP POST to the IncomingPhoneNumbers resource:

curl -X POST \
  "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers/$NUMBER_SID.json" \
  -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  --data-urlencode "SmsUrl=https://api.example.com/twilio/sms" \
  --data-urlencode "SmsMethod=POST" \
  --data-urlencode "VoiceUrl=https://api.example.com/twilio/voice" \
  --data-urlencode "VoiceMethod=POST" \
  --data-urlencode "StatusCallback=https://api.example.com/twilio/status"

Two things to notice. First, every field you don't pass keeps its current value — there is no "merge vs replace" gotcha. Second, the response is the full updated resource as JSON, including the values you just overwrote, which gives you a per-number snapshot for the audit log.

The repoint script

The whole cutover step is a thirty-line bash script committed to the run-book repo:

#!/usr/bin/env bash
set -euo pipefail

: "${TWILIO_ACCOUNT_SID:?}"
: "${TWILIO_AUTH_TOKEN:?}"
: "${NEW_ORIGIN:?}"           # https://api.example.com

NUMBERS=(
  "PNxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"   # +1-555-0100  main
  "PNyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"   # +1-555-0101  support
  # ...four more
)

LOG="$(mktemp -t twilio-repoint.XXXXXX.jsonl)"
echo "logging to $LOG"

for SID in "${NUMBERS[@]}"; do
  echo "==> $SID"
  before=$(
    curl -fsS -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
      "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers/$SID.json"
  )
  after=$(
    curl -fsS -X POST -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
      "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/IncomingPhoneNumbers/$SID.json" \
      --data-urlencode "SmsUrl=$NEW_ORIGIN/twilio/sms" \
      --data-urlencode "VoiceUrl=$NEW_ORIGIN/twilio/voice" \
      --data-urlencode "StatusCallback=$NEW_ORIGIN/twilio/status"
  )
  jq -n --arg sid "$SID" --argjson b "$before" --argjson a "$after" \
     '{ts: now|todate, sid: $sid, before: $b, after: $a}' >> "$LOG"
done

echo "done. log: $LOG"

What this buys you on cutover day:

  • One command, six numbers, deterministic order. No tab juggling.
  • before snapshot per number. The rollback script is the same loop with $before values plugged back in. We tested it once on a staging number; we never needed it on cutover.
  • JSONL audit trail with timestamps. When somebody asks "when did number 0102 flip?" the answer is in the log to the second.
  • set -euo pipefail. First failed curl aborts the loop. The numbers downstream of the failure stay on the old origin, which is exactly what you want — you can't half-repoint a service into an undefined state.

What the run-book says around it

The repoint command does not exist in isolation. The cutover sequence around it:

  1. T-2 min: new origin fully warm. Synthetic webhook from the new origin's health check has succeeded twice.
  2. T-1 min: signature-validation key for the new origin is staged in Secret Manager but not yet referenced by the running revision. The old origin still validates. Both origins will accept Twilio's signature for the duration of the cutover.
  3. T+0: run the repoint script. Thirty seconds end-to-end for six numbers.
  4. T+1 min: tail Cloud Logging on the new origin, confirm twilio.signature.valid=true on the first inbound message per number.
  5. T+2 min: flip the old origin's webhook handlers to return 410 Gone so any straggler retry from Twilio is loud, not silent.

Steps 4 and 5 are what catches a botched repoint. If the script logged success but a number is still hitting the old origin (DNS cache, CDN edge, console manual override from earlier in the week), the 410 in step 5 surfaces it within minutes.

The signature-validation footgun

The one thing the REST API doesn't fix for you: Twilio computes its X-Twilio-Signature header over the full URL the request was sent to. If your old origin was https://old.example.com/twilio/sms and your new origin is https://api.example.com/twilio/sms, the signature is computed against api.example.com. The new origin's validator must use the same hostname Twilio used.

Behind a load balancer or a proxy, the request hits your application code as http://10.0.0.5/twilio/sms (the internal hostname). The signature won't match unless your validator reconstructs the public URL from X-Forwarded-Host and X-Forwarded-Proto. That code:

function publicUrlFromRequest(req) {
  const proto = req.get('x-forwarded-proto') || req.protocol;
  const host  = req.get('x-forwarded-host')  || req.get('host');
  return `${proto}://${host}${req.originalUrl}`;
}

const valid = twilio.validateRequest(
  process.env.TWILIO_AUTH_TOKEN,
  req.get('x-twilio-signature'),
  publicUrlFromRequest(req),
  req.body
);

Catch this in the synthetic webhook before T-2, not at T+1. The fix is one line; the discovery on cutover is twenty minutes of "why is every signature failing."

What I tell teams

Three things, every cutover with a webhook-driven vendor:

  1. Repoint via REST API, not the console. It is faster to write the script than to repoint the third number by hand, and you get the audit trail for free.
  2. Capture before and after. A rollback you can't run isn't a rollback.
  3. Validate the signature against the public URL, not the internal one. Behind any L7 hop, the validator will lie to you unless you reconstruct the URL from forwarded headers.

The same pattern applies to Stripe, SendGrid, Postmark, Plaid, and every other vendor whose console has an "edit webhook URL" button. The button is for first-time setup. The cutover belongs in a script.


Run the same audit on your own stack. Open the 30-question checklist →

Next in the series: Cloudflare DNS-Only Behind GCP Managed Certs →

Run the audit on your own stack

A 30-question self-audit. P0/P1/P2 severity. Takes about an hour.

Open the checklist →