Email is where notifications go to be ignored. In the markets I work in — Kenya, Uganda, Tanzania — SMS is still the channel that gets read within minutes, which is why "text the customer when their order ships" and "text the salesperson when their quote is approved" are recurring requests on Acumatica projects here. Twilio is the API I reach for when the client operates across countries; below is the architecture I deploy, plus the regional gotchas that no US-centric tutorial will warn you about.
The shape: business event → relay → Twilio
Acumatica has no native SMS provider, so every SMS integration is the same three-piece pattern:
- A Generic Inquiry exposing the trigger rows and every field the message needs (order number, customer phone, amount).
- A business event on that GI (trigger by record change — e.g. shipment status flips to Confirmed) with a webhook subscriber.
- A small relay — I use an Azure Function or a Laravel endpoint depending on what the client already hosts — that receives the event, normalises the phone number, and calls Twilio's Messages API.
Could you call Twilio directly from a graph extension on RowPersisted? You could, and I've ripped that code out of two implementations. An HTTP call inside the persist cycle means a slow or failing Twilio request blocks the user saving a shipment, and a transaction rollback after the call means you texted a customer about a shipment that doesn't exist. Fire the event after commit and let the relay own the network I/O.
var evt = await JsonSerializer.DeserializeAsync<AcuEvent>(req.Body);
var row = evt.Inserted[0];
var to = PhoneUtil.ToE164(row["Phone1"], defaultRegion: "KE"); // +2547...
if (to == null)
return Log.SkipInvalidNumber(row); // 200 OK — don't make Acumatica retry bad data
var form = new Dictionary<string, string> {
["To"] = to,
["From"] = _senderId, // messaging service SID in practice
["Body"] = "Habari! Order " + row["OrderNbr"] +
" has shipped via " + row["Carrier"] +
". Track: " + row["TrackingUrl"],
["StatusCallback"] = _baseUrl + "/twilio/status?ref=" + row["OrderNbr"]
};
var msg = new HttpRequestMessage(HttpMethod.Post,
"https://api.twilio.com/2010-04-01/Accounts/" + _accountSid + "/Messages.json");
msg.Headers.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.ASCII.GetBytes(_accountSid + ":" + _authToken)));
msg.Content = new FormUrlEncodedContent(form);
var resp = await _http.SendAsync(msg);
resp.EnsureSuccessStatusCode();
Phone numbers: the actual hard part
The Twilio call is trivial. The data quality in Acumatica's phone fields is not. In every live database I've touched, Phone1 contains a mixture of 0722 123 456, +254722123456, 0722-123-456, and the occasional "call John first". Twilio wants strict E.164. Your relay must normalise with a proper library (libphonenumber ports exist for .NET and PHP) and a sensible default region, and it must skip rather than fail on garbage — returning a non-2xx to Acumatica for a bad phone number just makes the business event retry a message that can never succeed.
Longer term, the better fix is upstream: a validation attribute on the DAC that normalises numbers at entry. I usually ship the relay-side normaliser first (works today) and the entry-side validator second (fixes the data over months).
Sender IDs, and why Kenya is special
Here's the part the tutorials miss. In Kenya — and most East African markets — you cannot just buy a Twilio long-code number and start blasting transactional SMS to local subscribers:
- Alphanumeric sender IDs must be pre-registered. Safaricom and Airtel require sender ID registration through the carrier, and Twilio requires you to complete that registration (via their console request process) before an alpha sender like
ACMELTDwill deliver. Budget two to six weeks of lead time. I am not exaggerating; I've had projects where the sender ID paperwork was the critical path. - Unregistered traffic gets silently filtered. The Twilio API will happily return
queued, and the message will simply never arrive. If you skip status callbacks you will spend days blaming your code. - Price per segment varies wildly by carrier. Kenyan SMS via international aggregators costs multiples of what a local gateway (Africa's Talking, for instance) charges. For Kenya-only clients I often recommend the local gateway; Twilio wins when the client sends to five countries and wants one API and one contract.
Closing the loop with status callbacks
That StatusCallback URL in the code above is not optional in my deployments. Twilio POSTs you sent, delivered, undelivered, failed transitions per message. The callback handler writes the final status back into Acumatica via the contract-based REST API — I usually keep a simple custom table (notification log) bound to a GI so support staff can answer "did the customer get the text?" without a Twilio login. Two rules: validate Twilio's X-Twilio-Signature header on the callback endpoint, and make the write idempotent on MessageSid because callbacks can arrive out of order and more than once.
Twilio credentials belong in the relay's secret store (Key Vault, environment config), not in Acumatica automation step parameters or hard-coded in a published customization that gets copied between tenants. I've seen a production auth token in a test tenant's webhook URL. Rotate keys the day you inherit a project.
Bursts, quiet hours, and cost control
Business events will happily fire 500 webhooks when someone mass-confirms shipments after a stock take. Three defences I ship as standard: the relay enqueues instead of sending inline (a queue also smooths Twilio's own rate limits on long codes); a quiet-hours check (nobody wants a 2 a.m. dunning SMS, and in some jurisdictions marketing texts at night are a compliance issue); and a monthly cap with an alert, because SMS is the one notification channel where a bug has a direct per-unit price. A loop that emails you 10,000 times is embarrassing. A loop that texts 10,000 times is an invoice.
Wrapping up
The Twilio API is the easy afternoon; the project is everything around it. Fire from business events rather than inside the persist cycle, normalise phone numbers defensively and skip the unfixable ones, register your sender ID before you write a line of code if you're sending into East Africa, and wire status callbacks back into an Acumatica-visible log so delivery is a fact, not a hope. Do those four things and SMS becomes the most reliably read channel your ERP has.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.