Who's calling: matching a number to a customer before the phone stops ringing
A phone system knows a number. An ERP knows a customer. In most companies nothing knows both, and closing that gap is a harder problem than the vendor demo suggests — nine years of phone numbers nobody normalised, an event stream instead of a polling loop, and a screen pop that must never be able to stop a call.
The woman who answers the phone at a wholesaler we work with keeps a laminated card next to her keyboard. On it, in her own handwriting, are the sixteen customers who call most often, each with an account number. She made it herself over about two years, because the alternative is asking a man who has been buying from this company since 2016 to please spell his company name again.
That card is a computer telephony integration. It is a reverse index from phone number to customer record, maintained by hand, with a hit rate somewhere around sixty per cent and no failover when she takes a week off. It exists because the two systems either side of it never learned to talk: the phone system knows a number, the ERP knows a customer, and nothing in the building knows both.
Every vendor demo of a cloud phone system closes this gap in about four seconds. A call comes in, a card slides up on screen with the customer's name, their last order and an open ticket, and everyone in the room nods. What the demo does not show you is the part that decides whether it works on your data: the lookup. We have now built or fixed this integration enough times to say that the interesting engineering is almost entirely in three places — the shape of the numbers, the shape of the event, and the discipline of keeping the whole thing out of the call path.
The three seconds you actually have
Start with the budget, because it eliminates most designs immediately.
A caller dials. Somewhere in the network your PBX receives the invitation to set up the call, and a fraction of a second later a phone starts ringing on someone's desk. That person picks up on the second or third ring. Call it three seconds from the system knows a call is coming to a human is speaking, and the useful part of that window is shorter still, because information arriving on screen at the same moment as "good afternoon" is information nobody reads.
So the honest budget for the whole chain — PBX event, number normalisation, database lookup, push to a browser tab, render — is under a second, and the database lookup inside it gets a couple of hundred milliseconds. Not because two hundred milliseconds is a magic number, but because everything else in the chain is a network hop you do not control and you want the one part you do control to be uninteresting.
That budget kills the naive implementation before anyone writes it. The naive implementation is a JSON-RPC call into Odoo doing a search_read on res.partner with an ilike on the phone field. On a database with two hundred thousand contacts that is a sequential scan behind an ORM behind an RPC layer that was never designed for latency-sensitive reads, and it will sometimes take four seconds, and the four-second case is the one that happens when the ERP is busy, which is the same time of day the phone rings most.
The number is never the number
Here is the part every integration underestimates. Your PBX hands you a number in one canonical form — +32475123456. Your ERP contains what people typed into it over nine years:
0475 12 34 56
0475/123456
+32 475 12 34 56
0032 475 123456
04 75 12 34 56
+32 (0)475 12 34 56
0475123456 (privé)Odoo stores the string it was given. It does not rewrite it, and it should not — that field is also what a human reads off the screen and dials by hand. So the matching problem is not "find the partner with this phone number". It is "find the partner whose free-text phone field, once normalised, equals this number", and free-text normalisation at query time means a scan.
The good news is that Odoo already ships the ingredient. The phone_validation module wraps Google's libphonenumber, and the mail.thread.phone mixin adds a stored, sanitised phone_sanitized field to the models that inherit it. The bad news is which models those are: crm.lead has it, res.partner — the model you most want to search — historically does not. Check yours, because this has moved between versions, and it is a five-minute check that saves you from writing code you did not need.
Where it is missing, add it, in a module rather than by hand on the database. A stored computed field with an index is roughly ten lines:
from odoo import api, fields, models
from odoo.addons.phone_validation.tools import phone_validation
class ResPartner(models.Model):
_inherit = "res.partner"
phone_e164 = fields.Char(
string="Phone (E.164)",
compute="_compute_phone_e164",
store=True,
index=True,
readonly=True,
)
@api.depends("phone", "country_id", "company_id")
def _compute_phone_e164(self):
for partner in self:
country = partner.country_id or partner.company_id.country_id
partner.phone_e164 = self._to_e164(partner.phone, country)
def _to_e164(self, raw, country):
if not raw:
return False
try:
return phone_validation.phone_format(
raw,
country.code,
country.phone_code,
force_format="E164",
raise_exception=True,
)
except Exception:
return FalseTwo things about that snippet matter more than the code.
The first is country_id or company_id.country_id. A number written 0475 12 34 56 is only Belgian because of context that lives outside the string, and if you default every unqualified number to your own country you will confidently match a Dutch supplier's 06 mobile to a Belgian landline that happens to share nine digits. Numbers without a country prefix are ambiguous; treat the resolved country as part of the input, and accept that a contact with neither a country nor an international prefix simply cannot be indexed reliably.
The second is that it belongs in an addon. It is tempting to do this in SQL — a generated column and an index is two statements and no deployment. But a column Odoo does not know about is a column that survives exactly until someone runs an upgrade, and unindexed schema drift is the thing that turns an ERP upgrade into a weekend. If you are going to change what the database holds, do it as a module with a migration, the same way you would any other schema change that has to be safe across two live versions.
One contact has more than one number, and the number lives on five models
The stored column gets you a working demo. Then reality arrives in three shapes.
A contact has several numbers. A phone and a mobile, sometimes a direct line as well. One column holds one of them.
A number belongs to several records. A company's switchboard number is often copied onto every one of its forty employee contacts, because someone imported it that way in 2019. Match on it and you get forty rows and no way to rank them.
The number you want is not on res.partner. It is on an open lead, a helpdesk ticket's contact, a delivery address, a job applicant, a supplier's accounts department. Searching each of them in turn is five queries and five chances to blow the budget.
The shape that survives all three is a dedicated reverse index: one row per (number, record), rebuilt from the source models, with an explicit rank.
class PbxNumberIndex(models.Model):
_name = "pbx.number.index"
_description = "Reverse index from an E.164 number to a business record"
_order = "rank, id"
number = fields.Char(required=True, index=True)
res_model = fields.Char(required=True)
res_id = fields.Many2oneReference(model_field="res_model", required=True)
# lower wins: a person's mobile beats a company switchboard
rank = fields.Integer(default=100)Maintained two ways, both of which you need: written on create and write of the source records so the index is current, and rebuilt in full by a nightly job so that an import, a merge or a restore cannot leave it quietly wrong. The nightly rebuild is not belt-and-braces, it is the only thing standing between you and an index that has been forty per cent stale since March.
Then the lookup is one index probe:
SELECT res_model, res_id
FROM pbx_number_index
WHERE number = '+32475123456'
ORDER BY rank, id
LIMIT 5;Sub-millisecond, no ORM in the path, and cheap enough that you can afford a second probe for the case that will otherwise embarrass you: a number stored without an international prefix that never made it into the index. Match on the significant digits from the right, with an index that makes a suffix a prefix:
CREATE INDEX pbx_number_index_suffix_idx
ON pbx_number_index (reverse(number) text_pattern_ops);
SELECT res_model, res_id
FROM pbx_number_index
WHERE reverse(number) LIKE reverse('475123456') || '%'
ORDER BY rank, id
LIMIT 5;Use that as a fallback and label it as one. A suffix match is a guess, and guesses belong on screen as probably rather than as a name in bold.
Which brings up the rule that saves you from the worst possible failure mode of this feature. When the match is ambiguous, show the ambiguity. Three candidates means three lines on the card and a human choosing in half a second. Silently picking the highest-ranked one means that once a month someone greets a customer by the wrong company name, and that single event costs more goodwill than the feature earns in a quarter.
Withheld, spoofed, and never an identity
Three cases that are not edge cases.
Numbers arrive withheld. Your consumer gets anonymous, or an empty string, or a placeholder full of zeroes depending on the carrier. Render "unknown caller" and move on; do not log an error, because this is normal traffic and an error you see forty times a day is an error you stop seeing.
Numbers are shared. A household, a shared office, a company where the switchboard is the only number anyone published. The index handles it; the interface has to as well.
And numbers are trivially forgeable. Caller ID is a claim made by the calling party's carrier, not a proof, and treating it as one is how you get an attacker who phones your support desk from "the finance director's mobile" and gets a password reset. Caller recognition is a convenience for the person answering. It is not authentication, it never becomes authentication, and if you need to know who is on the line you need an actual second factor. Write that sentence into the rollout training, because the screen pop is persuasive in a way a raw number never was.
Events, not polling — and the CDR is too late
Every PBX has an API that lists calls. Almost every first attempt at this integration polls it, and almost every one of them fails for the same reason: that API returns call detail records, and a CDR exists once the call has ended. It is a billing artefact. By the time it can tell you a customer called, the conversation is over and the person who needed the information has hung up.
What you want is the live event stream — Asterisk's AMI or ARI, a hosted PBX's webhooks, whatever your vendor calls the thing that emits a call is ringing at extension 204 while it is still true. One always-connected consumer sitting beside the PBX, doing one job:
const LOOKUP_BUDGET_MS = 250
pbx.on("call.ringing", async (event) => {
const agent = agents.forExtension(event.extension)
if (!agent) return
let match: Match | null = null
try {
match = await withTimeout(lookup(event.caller), LOOKUP_BUDGET_MS)
} catch {
metrics.increment("cti.lookup.timeout")
}
// the number always goes out, match or no match
push(agent, { call: event.id, number: event.caller, match })
})The timeout is not defensive decoration. It is the contract: this component is allowed to fail, and when it fails the user gets a card with a phone number on it instead of no card at all. A screen pop that arrives late is worse than one that arrives incomplete, because the person is already talking.
Getting the card onto the screen is the last hop. If the user is looking at Odoo, use the bus — the gevent worker that used to be called longpolling and is now a websocket — which means remembering that it is a separate process on a separate port, and that your ingress has to route it as such or the feature works locally and silently does nothing in production. If the user is in an internal tool you built, you already own the socket. Either way this is a fan-out of one small message to one connected user, and it is worth noticing that you do not need a message broker for that; Postgres can carry it alongside everything else it is already carrying for you.
Click-to-dial is the easy half, and the half users judge you on
Technically it is nothing. A tel: link costs one line, and on a phone it works perfectly.
On a work laptop it usually does nothing at all, or opens whichever application claimed the protocol handler two years ago. So the real implementation is a button that calls the PBX and asks it to originate a call — which rings the user's own device first, and only then dials the customer.
That ordering is correct and every single user finds it confusing on day one. They click "call", their own desk phone rings, and their instinct is that something has gone wrong. It is the number one support question after every rollout we have done, and the fix is not technical: label the button so it says what will happen, and mention it in the fifteen minutes of training that this feature deserves and rarely gets.
Two details worth building in from the start. Dial from the same normalised number the index holds, not the free-text field, or you will discover which of your contacts have a phone field ending in (privé). And gate the originate endpoint on the calling user, not on the extension supplied by the browser — an endpoint that will place a call from any extension to any number is a toll-fraud gift, and telephony fraud is metered in real money per minute.
Logging calls: the ERP owns business records, the PBX owns calls
The tempting move once the events flow is to write every call into Odoo. Resist it for about a week and the reason becomes obvious: a busy company generates thousands of calls a month, and a partner record whose chatter is four thousand lines of "inbound call, 41 seconds" is a partner record nobody scrolls.
The split we hold to: the PBX keeps the call log, the ERP keeps the things a human decided. Calls flow into a dedicated model, queryable and reportable, and nothing lands in a record's chatter unless a person tied it to something — a note after the call, a follow-up task, a line on the ticket. That way the chatter stays a record of what happened commercially, which is what people actually read it for.
The one automation worth having on the raw stream is the missed call. A known customer who rings during business hours and does not get through is a business event, not a telephony statistic, and it is the single highest-value output of this whole integration: a to-do on the account manager, or a task on whoever is dispatching, before the customer has decided to phone a competitor. If you are already scoring and routing leads in the CRM, an unanswered call from a warm lead is a stronger signal than most of the ones you are scoring on. If you run field service with technicians on the road, a missed call from a customer whose job is scheduled for tomorrow is the one you want surfaced within the hour.
Keep the reporting where reporting lives. Queue response times, calls per hour, missed-call rate by weekday: that is a dashboard question, and pointing a reporting tool at the data beats building call analytics screens inside the ERP that three people will look at twice.
Recording, retention, and the sentence at the start of the call
Recording is the feature every client asks for and the one that deserves the most caution, because it changes what kind of system you are running.
Practical position, and we do argue for it: do not record by default. If there is a real reason — a support desk with disputes, an order line where mishearing a quantity costs a pallet — then record that queue, not the whole company. Announce it at the start of the call. Set a retention period in the low tens of days and make deletion automatic, because a recording archive that grows forever is a liability that grows forever with it, and nobody has ever gone looking for a call from fourteen months ago.
Keep the audio out of the ERP filestore. It is tempting to attach recordings to the partner, and it puts a category of personal data with a short retention obligation inside the same store as invoices you must keep for seven years, browsable by anyone with access to the record. If it must be reachable from Odoo, store a link and gate the audio behind record rules that name who may listen.
And do not casually pipe the audio to a transcription service because a demo was impressive. That is a new processor, a new cross-border question, and a decision to make deliberately rather than discover. The same instinct applies as with the ERP itself: this traffic belongs inside the boundary you already control, and the PBX-to-ERP hop should never leave it.
The rule that matters more than all of the above
The integration must never be able to stop a call.
Telephony is the one system in a business where degradation is not acceptable. If the website is slow, people wait. If the ERP is down, work stops and everyone knows why. If the phones do not ring, a company is losing revenue in a way it cannot see and cannot measure afterwards, and no screen pop is worth one minute of that.
So the CTI service observes the PBX; it never sits in the call path, never makes a routing decision, and cannot hold a call open waiting for an answer. Every dependency it has is allowed to fail:
- ERP unreachable → no match, the number still pops, the phone still rings
- Index stale → wrong or missing name, the number still pops, the phone still rings
- CTI service down entirely → nothing pops, the phone still rings
Then watch the one metric that actually tells you the feature is healthy, which is not uptime: the match rate. Uptime says the service is answering. Match rate says it is answering usefully, and it is the number that quietly collapses when someone bulk-imports two thousand contacts with unprefixed numbers, or a country field gets cleared in a data cleanup. Put it on the same dashboards as everything else you run, alongside the rest of the stack's metrics, alert on a drop rather than on a threshold, and send the lookup exceptions somewhere a human sees them — an error tracker, not a log file.
What it is worth, honestly
The business case usually gets written as seconds saved per call, and that number is real but small. Fifteen seconds of "who am I speaking to, and what is this about" times sixty calls a day is a quarter of a person, which is enough to justify the work and not enough to explain why people who have it refuse to go back.
The part that does not fit in the spreadsheet is what the person answering knows before they speak. That this caller has an open ticket that was escalated yesterday. That this is the customer whose delivery is late. That the number is not in the system at all, which means it is probably new business and should be treated like it. None of that is time saved; it is a different conversation, and it is the one clients describe when they talk about the feature a year later.
The cost is likewise not where people expect. The integration is a couple of weeks. The data is the project. Every client we have done this for has discovered, in week one, that a meaningful slice of their contact database has numbers that cannot be matched by anything — missing, wrong country, three numbers in one field separated by a slash. Cleaning that up is genuinely worth doing and it is not a telephony task, which is why we would rather find it during a fit-gap conversation than three days before go-live.
Where this is the wrong thing to build
If you are four people taking six calls a day, do not build any of this. Use whatever caller lookup your phone system ships with, accept the hit rate, and spend the budget somewhere it compounds.
If you are running a contact centre — queue strategies, wallboards, workforce planning, compliance recording — buy a contact-centre product. The integration described here is for a business where the phone matters and telephony is not the business, which is most of them, and it stops being the right answer at about the point where someone's job title contains the word "queue".
And if your contact data is genuinely a mess, do that first. A caller recognition system on bad data does not fail loudly; it fails at forty per cent, everyone learns not to trust the card, and you have spent a project's budget on a feature people route around. The laminated card, at least, was honest about its hit rate.
The short version
The demo is the easy part. What decides whether this works on your data is a reverse index from a normalised number to a business record — built in an addon so an upgrade cannot quietly delete it, rebuilt nightly so an import cannot quietly corrupt it, and ranked so a switchboard number shared by forty contacts produces a choice rather than a wrong guess.
Feed it from the PBX's live event stream, never its call detail records, which arrive after the conversation they describe. Give the lookup a couple of hundred milliseconds and a timeout, because a card with just a number on it beats a card that arrives after "good afternoon". Log calls where calls belong and leave the ERP's chatter for things a human decided. Treat a missed call from a known customer as the most valuable event the whole system produces. And keep every part of it out of the call path, because the phones ringing is not a feature you are allowed to regress.
This is the kind of seam we spend most of our time on: two systems that both work, and the integration between them that decides whether anyone experiences it that way. We run business phone systems in the cloud and build the ERP integrations behind them — and when the answer turns out to be a small service rather than a setting in someone's admin panel, we build that too. If your reception desk has a laminated card, it has already specified the feature for you.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.