Skip to content

Phone Numbers Are Not Record IDs: Wiring Twilio Into Salesforce

A build guide for syncing Twilio and Salesforce in both directions. It covers the identity mapping that decides whether the integration works at all, normalising to E.164 and upserting on a dedicated External ID instead of querying by phone, the inbound path over validated and deduplicated webhooks, the outbound path over Change Data Capture and its retention window, why a message status keeps changing after the send so a poll on DateSent misses it, how to keep the Salesforce API allocation intact, and what the failure codes an operations team sees actually mean.

Author
Ruben Burdin · Founder & CEO
Published
July 23, 2026
Read time
13 min read
Phone Numbers Are Not Record IDs: Wiring Twilio Into Salesforce
REVOPS

Syncing Twilio with Salesforce in both directions is two flows, not one. Twilio to Salesforce: an inbound message or a delivery receipt arrives at your endpoint as a webhook and gets written onto a Salesforce record. Salesforce to Twilio: a record change, usually a rep sending a reply or an automation qualifying a lead, publishes a change event that becomes an outbound message through the Twilio API.

Neither direction is hard on its own. The pair is hard because the two systems identify the same person differently. Twilio knows a customer as an E.164 phone number. Salesforce knows them as a record ID, and its Phone field is free text that somebody typed. Nothing in either product reconciles the two for you, and every duplicate Contact, unmatched reply and permanently stuck message status traces back to that gap.

Three things that decide whether a Twilio and Salesforce sync works: normalise numbers to E.164, upsert on a unique External ID field, and push both directions with webhooks and Change Data Capture instead of polling

This guide starts with that identity mapping, then walks each direction, then covers the two things that quietly break an integration that already works: the message status lifecycle and the Salesforce API allocation. If you are still choosing a platform rather than building on one, start with the wider guide to an enterprise iPaaS for Twilio.

Phone numbers are not record IDs

Twilio speaks E.164 and nothing else. Every webhook it sends carries From and To in that format: +14155552671, country code included, no spaces, no punctuation. Salesforce carries Phone and MobilePhone as free text, so the same customer can be (415) 555-2671 on one record, 415.555.2671 on another, and blank on a third.

The pattern that fails is the obvious one. An inbound SMS arrives, you query for a Contact WHERE Phone = :from, get nothing back because the formats do not match, and create a Contact. Do that on a busy number and you generate a duplicate for every conversation, while the reply a rep sends from the new record carries none of the history. Even when the format matches, a phone number is not a unique key: two people share a household line, a company switchboard sits on fifty Contacts, and the query hands back a list you have to guess your way through.

The pattern that works has three parts. Normalise every number to E.164 once, at the edge, before anything tries to match. Store the normalised value in a dedicated custom field on the Salesforce object, marked External ID and Unique, rather than relying on Phone. Then upsert against that External ID instead of querying and branching. An upsert is idempotent: if Twilio retries the webhook, or your handler dies after Salesforce committed but before it read the response, the retry updates the record that already exists rather than creating a second one. Twilio's Lookup API is the right tool for the one-off backfill, because it validates a number and returns its line type, which is how you find the free-text values that were never phone numbers at all.

Identity resolution for Twilio and Salesforce: raw phone strings are normalised to E.164, matched against a unique External ID field, and upserted onto one Salesforce Contact
Match on the External ID, not the Phone field. That single choice is what keeps inbound messages on one Contact.

Salesforce record IDs have a trap of their own. They come in a 15-character case-sensitive form and an 18-character case-insensitive form, where the 18-character version is the 15-character one plus a three-character checksum. Compare the two forms as strings and the match silently fails. Store 15-character IDs somewhere that compares case-insensitively and two genuinely different records can collide on what your unique index believes is one key. Keep the 18-character form everywhere and never compare across the two.

One honest caveat: a person with two numbers does not fit one External ID field. If a mobile and a desk line both route inbound messages to the same person, the right model is a child object holding one row per number with a lookup to the Contact, and the External ID lives on that child. The Twilio connector and Salesforce connector pages list what each side expects.

Twilio to Salesforce: webhooks, and what to do with them

Twilio is push-shaped. Two webhooks carry almost everything a CRM cares about: the incoming-message webhook configured on the phone number or the Messaging Service, which fires when somebody texts you, and the StatusCallback you set per message, which fires as an outbound message moves through its states.

Three rules for the handler, all of them learned expensively by somebody.

  • Answer fast. Twilio POSTs form-encoded parameters and waits for a 2xx; the request times out at 15 seconds, after which it can fall back to the FallbackUrl you configured. Calling Salesforce synchronously inside the handler puts the Salesforce API's latency inside Twilio's timeout. Acknowledge, queue, then write.
  • Validate the signature. Twilio signs each request with an X-Twilio-Signature header, an HMAC-SHA1 over the full request URL plus the sorted POST parameters, keyed on your auth token. An endpoint that skips the check is an unauthenticated write path into your CRM for anyone who learns the URL.
  • Deduplicate on MessageSid. Webhook delivery is at-least-once and unordered. You will receive the same callback twice, and you will receive delivered before sent. Key every write on the MessageSid and rank the statuses, so a late-arriving sent cannot overwrite a delivered that already landed.

At volume, Twilio's Event Streams is the durable version of the same idea: one subscription instead of a webhook per resource, delivering to an HTTP sink or Amazon Kinesis, also at-least-once. It earns its setup cost when more than one consumer needs the events.

Then the question every project argues about: where does a message go in Salesforce? A Task on the Contact puts it in the activity timeline reps already read, with no new tab and no new permission set. But a Task's Status field describes the task, not the message, so delivery state goes into a custom field anyway, and reporting on delivery rate turns into reporting on activities.

A custom object, say SMS_Message__c with MessageSid as its External ID, a status picklist, an error-code field and a lookup to the Contact, makes message status a first-class field you can report on, filter and automate against.

Which I would pick: the custom object as the record of messages, plus one Task per conversation rather than per message. The timeline is a display concern and delivery state is a data concern, and the moment you merge them, every delivery receipt becomes an activity write. That distinction also decides your API bill.

Twilio eventWhat it carriesThe Salesforce write it should triggerHow often it fires
Inbound message webhookMessageSid, From, To, Body, NumMediaUpsert the Contact on the E.164 External ID, then insert one linked message recordOnce per inbound message
StatusCallback: queued, sending, sentMessageSid, MessageStatusUpdate the existing message record only. No Contact write, and no new activityTwo or three times per outbound message
StatusCallback: deliveredMessageSid, MessageStatusSet the terminal status and stamp a delivered timestamp on the message recordOnce per delivered message
StatusCallback: undelivered, failedMessageSid, MessageStatus, ErrorCodeSet the terminal status and put the numeric error code on a field a human readsOnce, when it happens
Send rejected with 21610ErrorCode, ToSet the opt-out field on the Contact and stop sending. This is a consent change, not a retryOnce per opted-out number
Salesforce change eventreplayId, the changed fieldsNone. This one runs the other way: create the message in Twilio with a StatusCallback setOnce per qualifying record change

Each Twilio event and the write it should produce in Salesforce. Most of them touch the message record, not the Contact.

Salesforce to Twilio: change events, or a poll you will regret

Going the other way, the choice is between events and polling, and it is not close.

Salesforce Change Data Capture publishes record changes as Platform Events on the Streaming API. Subscribe to the channel for the object you care about and each event carries a replayId your consumer stores, so it resumes from where it stopped after a disconnect. The catch is retention: change events sit in the event bus for a bounded window, documented at about 72 hours. A consumer down past that window cannot replay the gap, so an integration built on CDC needs a reconciliation pass it can run on demand rather than only at go-live: re-read the affected records over a SystemModstamp window and re-apply. Teams that skip building it find out they needed it on a Monday morning.

If you poll instead, filter on SystemModstamp rather than LastModifiedDate, because some automations do not touch LastModifiedDate. Polling also spends API requests on every cycle whether anything changed or not, which matters for reasons the next section but one makes concrete.

The send side has a limit that surprises people. Twilio throughput is per sender and carrier-governed: a US long code is documented at roughly one message per second, toll-free higher, short code higher again. A Messaging Service queues bursts across its sender pool, but that queue is bounded, and Twilio documents a window of about four hours; past it, messages drop with error 30001. So a Salesforce campaign that flips forty thousand records at once is not a retry problem. It is a sender-pool sizing problem, to be solved before the flow is switched on.

Sequence of one round trip: an inbound Twilio webhook is signature-checked and deduplicated, the Contact is upserted on the E.164 External ID, a Salesforce change event triggers an outbound message, and the StatusCallback updates the message status back in Salesforce
One round trip between Twilio and Salesforce, with origin tags stopping the status write-back from looping.

That round trip is what separates a sync from two scripts pointed at each other. Every write carries its origin, so the status update written back into Salesforce is not read a second later as a fresh change and pushed out as another SMS. Without origin tracking these integrations loop, and unlike a database loop this one costs money per iteration. The guide to bidirectional sync with real examples walks through the same mechanics on other systems.

Why the Salesforce record still says "sent"

A message is not finished when the Twilio API returns a 201. It moves through queued, sending and sent, then either delivered or undelivered, or it fails outright with failed. The delivery receipt comes from the carrier and lands later, sometimes seconds later, sometimes minutes.

Here is the part that breaks generic integration platforms. Twilio's GET /Messages endpoint filters on To, From and DateSent, with DateSent>= and DateSent<= for a range. There is no updated-since filter. A message's status changes without DateSent changing, so a polling loop keyed on send date reads the message once, at send time, when it says queued or sent, and never sees the delivered that arrives ninety seconds afterwards.

That is the real reason a CRM fills up with messages sitting permanently at "sent" under a poll-every-fifteen-minutes integration. It is not a poll interval that needs tuning, and shortening it does not help. Anyone building a delivery-rate report on that field is reporting on send attempts.

The fix is to set StatusCallback on every outbound message and treat those callbacks as the source of truth, with the rank check from earlier so an out-of-order callback cannot regress a record. Failed and undelivered messages also carry an ErrorCode, and that number is the field an operations person actually needs, because it decides whether the message can be retried at all.

The Salesforce API budget is the real constraint

Salesforce request allocations are counted per org over a rolling 24 hours, and they scale with edition and licence count rather than with how busy your integration is. Every callout draws from the same pool as every other integration in the org, and once it is exhausted the API stops answering.

Do the arithmetic before you build. One outbound message typically produces the send plus three or four status callbacks. If each callback becomes its own Salesforce update, one message costs four or five requests. Ten thousand messages a day is forty to fifty thousand requests before anything else in the org touches the API. That is how a messaging project ends up being the thing that broke the nightly ERP sync.

  • Collapse the writes. One activity per conversation whose latest-status field gets updated, instead of one Salesforce record per event. Very few orgs need every intermediate state persisted in the CRM.
  • Batch what is left. The Composite API and Bulk API 2.0 carry many records per request. A sync layer that buffers status callbacks for a few seconds and writes them together turns fifty requests into one.
  • Filter at the edge. Keep queued and sending in the sync layer and write only terminal states, delivered, undelivered and failed, into Salesforce. Nobody opens a record to learn that a message was queued.

Then alarm on API consumption as a percentage of the allocation rather than on error counts, because the failure mode here is a hard stop and not a gradual slowdown. And remember that your writes fire whatever Apex triggers and flows the org has on that object, so a write-heavy integration into an org with automation on Task is slower and more expensive than the request count alone suggests.

Book a Stacksync demo: keep Twilio messages and Salesforce records in real-time two-way sync

The failure codes you will actually see

Twilio returns a numeric error code on failure, and the useful thing about them is that most are not retry conditions. Treating everything as "retry with backoff" is how an integration spends its send budget on messages that were never going to deliver.

  • 21610: the recipient replied STOP. Twilio holds the opt-out at the Messaging Service level and refuses the send. This is a consent update, not an error. Write it back onto the Salesforce record so the two sides do not drift, and stop attempting sends. Consent living in Twilio while a checkbox lives in the CRM is a legal exposure, not a cosmetic mismatch.
  • 30003: unreachable handset. The device is off, out of coverage, or the number is no longer in service. An immediate retry does nothing; a retry hours later sometimes works.
  • 30007: the carrier filtered the message, usually as suspected spam. This is content and sender reputation, not code. Resending the same body from the same sender reproduces it.
  • 30034: US application-to-person traffic went over a long code without A2P 10DLC brand and campaign registration. It is a compliance step, it takes calendar time, and no retry logic fixes it. Find out about it long before launch week.
  • 20429: too many requests. You are being rate limited on the API itself. Back off exponentially; tightening the loop makes it worse.
  • 30001: queue overflow. The Messaging Service queue for that sender filled because the producer pushes faster than the sender pool drains inside the queue window. The fix is more senders or a slower producer.

The design consequence is worth stating plainly: 21610 belongs on the Contact as a consent field, 30034 belongs on a project plan, 30001 belongs on a capacity dashboard, and only 20429 belongs in a retry policy. An integration that routes all six into one "sync error" queue teaches its operators to ignore that queue.

Getting it running

The order matters more than the tooling. Normalise phone numbers and settle the External ID before you map a single message field. Take Twilio inbound over validated, deduplicated webhooks rather than a poll. Take Salesforce outbound over Change Data Capture, with a reconciliation path for the retention window. Treat StatusCallback as the source of truth, and count your Salesforce API calls before the allocation counts them for you.

To see it running against a real org, book a demo, look at the Salesforce and Twilio integration, or read the companion guides on an enterprise iPaaS for Twilio and two-way sync between Twilio and HubSpot. And if you want the story behind treating a phone number as an API call in the first place, there is the origin story of Twilio.

Start syncing Twilio and Salesforce both ways, with message status kept current on the record

FAQ

Frequently asked questions

How do I sync Twilio with Salesforce in both directions?
Treat it as two flows with one shared key. Inbound, Twilio to Salesforce: point the incoming-message webhook and the per-message StatusCallback at your endpoint, validate the X-Twilio-Signature header, deduplicate on MessageSid, and upsert the Contact on a dedicated External ID field holding the number in E.164 rather than querying the free-text Phone field. Outbound, Salesforce to Twilio: subscribe to Change Data Capture events for the objects that trigger messages, resume from the replayId after any disconnect, and create the message through the Twilio API with a StatusCallback set. The shared key is the normalised phone number stored as a unique External ID, which is what makes every write idempotent and stops retries creating duplicate records.
How do I stop inbound SMS creating duplicate Salesforce contacts?
Stop matching on the Phone field. Twilio always sends the number in E.164 form, such as +14155552671, while Salesforce Phone and MobilePhone are free text that somebody typed, so a SOQL query filtered on Phone usually finds nothing and the handler creates a new Contact. Normalise every number to E.164 at the edge, store it in a custom field marked External ID and Unique, and upsert against that field instead of querying and branching. Because an upsert matches on the External ID, a retried webhook updates the record that already exists. Twilio's Lookup API is useful during the one-off backfill, since it validates a number and returns its line type, which surfaces the free-text values that were never phone numbers.
Why does the Salesforce record still say the message was sent when it was delivered?
Because the integration is polling and Twilio has no updated-since query. GET /Messages filters on To, From and DateSent, with DateSent>= and DateSent<= for a range, but a message's status keeps changing after it is sent, moving from queued to sent to delivered, undelivered or failed, and the delivery receipt arrives from the carrier later. None of those transitions change DateSent, so a poll keyed on send date reads the message once at send time and never sees the delivered that lands a minute afterwards. Shortening the poll interval does not fix it. Set StatusCallback on every outbound message and treat the callbacks as the source of truth for status.
Should Twilio messages be Salesforce Tasks or a custom object?
A custom object when message status has to be a real field, a Task when the only requirement is that reps see the conversation. A Task lands in the activity timeline people already read, with no new tab and no new permission set, but its Status field describes the task rather than the message, so delivery state ends up in a custom field anyway and delivery-rate reporting becomes activity reporting. A custom object such as SMS_Message__c, with MessageSid as its External ID, a status picklist, an error-code field and a lookup to the Contact, makes status reportable and automatable. The practical answer is both: the custom object as the record of messages, plus one Task per conversation rather than per message, so a delivery receipt never costs an activity write.
Do I have to validate the X-Twilio-Signature header?
Yes, on any endpoint that writes to your CRM. Twilio signs each webhook with an X-Twilio-Signature header, an HMAC-SHA1 over the full request URL plus the sorted POST parameters, keyed on your account auth token. Without that check, the endpoint is an unauthenticated write path into Salesforce for anyone who learns the URL, and inbound message webhooks are easy to guess once one is known. The handler also has to answer quickly: Twilio POSTs form-encoded parameters and times out at 15 seconds, after which it can fall back to the configured FallbackUrl, so acknowledge first and queue the Salesforce write rather than calling Salesforce inside the request.
Can Zapier or Make keep Twilio and Salesforce in sync?
They can move individual events, but they are not a two-way sync. Both have Twilio triggers and actions and Salesforce triggers and actions, so a new inbound SMS can create a Salesforce record and a Salesforce change can send a message. What you build is two independent one-way automations with no shared state between them: no deduplication on MessageSid across both, no origin tag to stop a write-back looping, and no single conflict policy. Pricing is per task, so writing every delivery receipt as its own run gets expensive quickly, which pushes teams into dropping status updates and back to records that say sent forever.
What do Twilio error codes 21610, 30007 and 30034 mean for a CRM sync?
They mean three different things and only one of them is close to a retry. 21610 means the recipient replied STOP, so Twilio holds the opt-out at the Messaging Service level and refuses the send. That is a consent update: write it back onto the Salesforce record and stop attempting sends, because consent living in Twilio while a checkbox lives in the CRM is a legal exposure. 30007 means the carrier filtered the message, usually as suspected spam, which is a content and sender-reputation problem, so resending the same body from the same sender reproduces it. 30034 means US application-to-person traffic went over a long code without A2P 10DLC brand and campaign registration, a compliance step that takes calendar time and that no retry logic fixes.
How many Salesforce API calls does a Twilio integration use?
More than teams expect, because status is chatty. One outbound message typically produces the send plus three or four status callbacks, so if every callback becomes its own Salesforce update, one message costs four or five requests. Ten thousand messages a day is forty to fifty thousand requests before anything else in the org touches the API, and Salesforce allocations are counted per org over a rolling 24 hours and shared with every other integration. Collapse the writes to one activity per conversation, batch what is left through the Composite API or Bulk API 2.0, and keep intermediate states such as queued in the sync layer so only terminal states reach Salesforce.

About the author

Ruben Burdin
Ruben Burdin
Founder & CEO

Ruben Burdin is the Founder and CEO of Stacksync, the first real-time and two-way sync for enterprise data at scale. Ruben is a Y Combinator alumni with a strong background in software engineering and business.

All posts by Ruben Burdin

About Stacksync

Stacksync powers real-time, two-way sync between CRMs, ERPs, and databases. Engineers sync data at scale and automate workflows, not dirty API plumbing.

Coworkers laughing in front of a laptop in a casual office setting

Your last integration took months.
Your next one takes a prompt.