Start free
All posts

The carrier's answer belongs to you, not to us

When a send fails, the carrier returns a reason. Keeping that reason and handing over only the label turns diagnosis into a support ticket — and moves work onto your team that had already been done. This is about why platforms do that, why we stopped, and what changes on call.

CCX EngineeringPlatform · Engineering · Aug 28, 2026 · 8 min read
Two identical plates under the same light: one blank, the other engraved edge to edge. The same event, with and without the evidence.
Two identical plates under the same light: one blank, the other engraved edge to edge. The same event, with and without the evidence. Illustration.

What you'll take away

  • The normalised error is a contract, the raw response is evidence, and both fit in the same payload. Choosing between them was the mistake.
  • Invalid number, carrier block and handset offline are three problems with three different answers. Under one label, all three become the same log line.
  • Normalising isn't laziness: it's what lets you change provider without rewriting your logic. The laziness was discarding the original afterwards.
  • A new field doesn't break integrations, but it does break a parser set to reject unknown keys. That's why there's a lead time, and why the lead time is announced first.

The event that explains nothing

Every messaging platform receives two things when a send fails. The first is the channel provider's raw response, with the code and text it emitted. The second is the platform's classification of that response, fitted into a stable vocabulary. Almost all of them hand over the second and keep the first.

The effect shows up on call. The event says it failed and stops there. There's no way to tell a number that doesn't exist from a carrier block from a handset that's been offline since yesterday. Three problems with three different answers — the first needs a record fixed, the second needs a conversation with whoever runs the route, the third needs only patience — and none of them appear in the payload.

So the team opens a ticket. On the other side, someone queries a log and writes back, hours later, with a value that was already in the system at the exact moment of the failure. The work wasn't done in that interval: it was transferred. And it was transferred to people with less context about the customer's operation than the customer has.

What makes this uncomfortable is that the information was never lost. It arrived, it was read, it was used to decide the classification, and it was discarded straight after. This isn't a case of data nobody had; it's a case of data somebody held for a moment and didn't pass on.

Why platforms normalise, and why that isn't laziness

Normalisation deserves a defence before the criticism, because it exists for a good reason. Every channel provider has its own error dictionary, with its own codes, its own groupings and messages in different languages. Writing business logic straight on top of that means rewriting all of it every time the route changes — and routes change for commercial reasons, for quality, for availability, often without the integrator hearing about it.

Classification solves that. It gives a vocabulary that doesn't depend on who delivered the message, and that vocabulary is what retries, alerts and reports should be written against. An integration coupled to the normalised field survives a provider change without a line altered, and that promise is worth far more than it looks on the day it has to be kept.

So the laziness isn't normalising: it's discarding the original afterwards. The two solve different problems and don't compete for space. The classification exists so the machine can decide; the original exists so a person can understand. Handing over only the first assumes nobody will ever need the second — and the existence of the ticket queue proves that assumption wrong.

Two fields, two jobs

There's an honest argument against exposing the provider's code, and it deserves to be taken seriously. Exposing it couples the integrator to a supplier they didn't choose and that can be swapped without notice. A code that means blocked today may not exist at the next provider, and logic written on top of it breaks during migration — silently, which is worse.

The argument is right, and that is exactly why the answer is two fields and not one. The normalised field carries the error class and never changes meaning. The raw field carries provider, code and message as they came. One is for the application to decide with, the other for a person to understand with. Naming them separately is what stops them being confused.

That separation is what makes changing provider an event without drama. The evidence changes, because it belongs to the provider. The contract stays, because it's ours. Anyone who wrote logic on the normalised field rewrites nothing, and anyone reading the raw field on call will see different codes — which is correct, because whoever actually answered is now someone else.

There's a responsibility that comes with it, worth saying out loud: whoever reads the raw field needs to know it isn't a contract. It's in the documentation and it's in the list above, but repeating it costs nothing. Write a conditional on the provider's code and it will work for months, then fail on migration day, and the defect will look like ours.

What changes on call

The gain isn't philosophical and it shows up fast. With the original provider and code in the same event, the first person to look already separates a record problem from a route problem. A record is fixed internally and now; a route becomes a conversation with whoever operates it. Different paths, and picking the wrong one costs the whole of the first shift.

It also changes what happens when a ticket is genuinely needed. Instead of opening with "messages are failing", it opens with the provider, the code, the original message and the timestamp. Whoever picks it up doesn't have to ask for information before they can start looking, and the first reply stops being a question.

And it changes what can be automated. An alert firing on the normalised field is stable but coarse: it tells you the error class went up. With the raw field in the same event, that alert can carry which specific code went up in its body — without anyone writing logic on top of it. Context in a notification and coupling in logic are different things, and only the second is dangerous.

Why a new field still needs a lead time

The published promise is that new fields may appear and existing fields don't change type or meaning. It works because consumers tolerate unknown keys. And not everyone does: a webhook validating the payload against a strict schema rejects the whole event because of a field it never wanted to read.

That isn't the integrator's mistake. Strict validation is a defensible choice, and in some sectors it's required. What it means in practice is that "adding a field is backwards compatible" is true about the format and not true about every real consumer — and the gap between those two statements is where integrations break.

That's why the field was announced on 18 July to take effect on 18 August, in the changelog and by email, with the required action written into the entry itself. A month isn't a courtesy. It's an acknowledgement that compatibility is a two-sided contract, and that the customer's side sometimes has to touch code before the change lands.

The rule holds in the other direction too. Nothing is removed without six months' notice, with a documented replacement from day one and both routes running side by side for the whole period. A deprecation announced without real coexistence isn't a deprecation: it's removal with advance warning, and the difference is felt by whoever has to rewrite an integration against a deadline.

The principle, for when the case is a different one

The rule that falls out of this is broader than one field. When a platform processes third-party data to produce an abstraction, it ends up holding two things, and the abstraction is the less informative of the two. Handing over only the abstraction is comfortable for whoever built it and expensive for whoever depends on it at a bad moment.

The test is simple and works for any field: if someone on your team had to open a ticket to discover something the platform already knew at the instant of the event, that something belonged in the event. Not as a contract, not as a stability promise — as evidence, named so that nobody mistakes one for the other.

That's why this is a post about a field and not about a feature. The field is small. What it says about whoever builds the platform is that the customer's data belongs to the customer, including when it's ugly, including when it arrives in another language, and including when handing it over means admitting the route failed for a reason we'd rather not show.

How to read a failure event

01statusWhat happened, in one word. It's what drives your retry flow, and the only field most integrations read on day one.
02error.codeThe error class in our vocabulary. Stable across providers, and the one your logic should be written against. If you can only couple to one field, couple to this one.
03channel_response.providerWho answered. Without it the code in the next item has no context to be read against: the same number means different things at different providers.
04channel_response.code and .messageWhat the provider returned, raw. It's what the person on call reads, and what you paste into the ticket when a ticket is genuinely needed.
05What NOT to do with the raw fieldWrite business logic on top of it. It's evidence for people to read, not a contract for machines to obey — and it changes when the provider changes, which is exactly the point.
See the webhook reference

Want to see this running on your own number?

Sign up through the site and get a US$5 test credit.