Karan Ghai is a Senior Software Engineer at PayGlocal with 2.5+ years of experience building backend infrastructure for global payments. He has worked on products like Multi-Currency Accounts and cross-border remittance flows, engineering reliable, high-scale systems that help businesses manage international collections and settlements with less friction and greater visibility.
Every notification your platform sends carries an expectation: it should reach the right person, at the right time, with a clear record of what happened along the way. Yet many notification systems are designed to send messages, not to prove they were delivered.
When failures leave behind nothing more than scattered log lines, debugging becomes guesswork and silent drops become almost impossible to trace. This article explains how we redesigned PayGlocal's notification pipeline from a fire-and-forget AWS Lambda into a stateful, auditable system that records every notification from the moment it arrives, making delivery more observable, reliable, and accountable.
One channel breaks the chain while the rest connect clean
📌TL;DR
•A single fire-and-forget AWS Lambda sent every notification across four channels and kept no record of whether any of them arrived, so "delivered" and "disappeared" looked identical from the outside.
•We did not patch it. We split it into a lightweight forwarder and a stateful notification server that writes down every message the moment it arrives, carries a correlation id through every log line, and keeps an audit trail that outlives the live database.
•Honest ledger: channels within a request still run one after another, and there is still no automatic retry. We can now see those gaps, which we never could before.
When a missing message has no record, where do you even look?
A customer says a notification never arrived. You go looking for the record of what happened to it, and there isn't one. That's the moment that started this whole rebuild: not one dramatic outage, but the slow realization that the system had no memory of its own actions.
For a couple of years, every notification PayGlocal sent, a refund receipt, a settlement alert, an on-call page, passed through a single AWS Lambda function acting as a queue consumer. It sat behind a message queue, unpacked whatever event came in, decided who needed to know, and fanned that decision out across four channels: email, SMS, WhatsApp, and voice calls, each handed off to a different third-party provider. On paper, it was a clean, serverless, pay-per-invocation design. In practice, it was fire and exit: decide, attempt, return, and forget. Whatever happened after that return statement was gone the moment the invocation ended.
We didn't rebuild because Lambdas are unfashionable. We rebuilt because when we finally read the code end to end, we found that the system had never been designed to remember anything about a notification once it left, and a few concrete ways that made real, silent drops possible.
Fire, and exit
The handler's job, in its own logic, was to decide who needed to know and send it. Decide, attempt, return success. Nowhere in that path did anything write down that a specific notification, for a specific event, to a specific recipient, had been attempted, let alone whether it succeeded. The only table the whole system ever wrote to was a narrow counter used to throttle repeated alerts, nothing about ordinary delivery outcomes lived anywhere but a log stream.
A validation check failing quietly inside a channel notifier, a missing recipient, a phone number that didn't match the expected pattern, was treated as routine: log one line, skip that channel, keep going, return success at the end regardless. So a channel could be silently skipped by design and a channel could genuinely fail, and from outside the function, both looked identical to "delivered." There was never a record to check afterward, only a guess.
The catch block that ate the evidence
A few different integration points made this worse, each in the same shape. Something would fail underneath, a lookup, a downstream call, a write meant to record internal state, and the failure would be caught, reduced to a blank result or a single log line, and treated as if nothing had happened. The function kept going on incomplete or quietly wrong information rather than stopping to say so.
None of these crashed anything. That was the problem. Each one let the system continue, convinced it was fine, while the original cause of the failure had already gone missing.
Whether a message went out or failed came down to one thing: a line of text in a console log, plain and unstructured, with nothing tying it back to the specific notification it belonged to. There was no concept of a delivery receipt, no read status, no click, nothing that came back from the other end of a channel to confirm what actually happened after the send call returned. If the log line existed, and if you knew which of thousands to look for, and if it hadn't already scrolled out of retention, you could reconstruct a guess. That was the entire mechanism.
For a system whose whole job was communicating with people, it had no way to answer the most basic version of that job: did this reach anyone, and did anyone do anything with it. It could tell you a function ran. It could never tell you a message landed.
The Lambda wasn't failing loudly enough to get paged for. By the time anyone thought to ask what happened to a specific message, there was nothing left to check but scattered logs and a guess. Underneath all of this was the same root cause: nothing forced a record, an error, or a delivery attempt to be accounted for. The system had no concept of "this notification is still owed to someone." It either sent, or it silently didn't, and there was no third state, no stored record, to catch the difference after the fact.
Why we did not just patch it
Patching a single silent validation skip is a one-line fix; patching a handful of swallowed exceptions is an afternoon. We considered both, but neither one fixes the shape underneath: a stateless function with no memory of its own history, triggered by a queue with no dead-letter policy we could find configured anywhere, running several sequential, synchronous provider calls inside a single invocation with a hard timeout ticking the whole time.
A dead-letter queue, for anyone outside this corner of the stack, is the holding pen a message system drops a message into when it cannot be processed, so it is kept for inspection instead of vanishing. We could not find one wired up.
Fix today's silent skip and the function still has no concept of a notification as a thing with a lifecycle, no shared place to say "this one is pending, this one succeeded, this one needs a human." Every patch we sketched turned into the same sentence: stop treating a notification as something that either happens inside one invocation or never happened at all, start tracking it as an entity with a state that outlives the function call. At that point we weren't patching a Lambda anymore, we were designing a small piece of infrastructure. So we built one, split into two purpose-built pieces instead of one function trying to do everything.
What does the new pipeline look like?
The old system had exactly one job title doing three different jobs: receive the event, decide what it means, and deliver it. The new pipeline separates those concerns into two purpose-built pieces that hand work off deliberately instead of accidentally.
Sources feed a forwarder that validates and relays; the server owns everything after
On one side, a lightweight forwarder still sits behind event sources, a general notification queue, a queue dedicated to SMS delivery reports, and delivery events for outbound email, arriving through the same event-driven triggers Lambda has always been good at. Its job is narrow on purpose: check that a payload has the shape it claims to have, map it into the format the next stage expects, and hand it off. It doesn't decide who gets muted, which template applies, or which provider to use. Every one of those decisions, the ones that used to live tangled inside a single handler, now lives in exactly one place downstream.
On the other side is the notification server itself: a long-running, horizontally scalable service that owns channel routing, template resolution, delivery, and the record of what happened. Instead of the old fire-and-forget dispatch, the handoff from forwarder to server happens over a direct request, one notification, one call, and this time something on the receiving end writes down that it arrived, rather than assuming success and moving on.
The value of splitting the work this way isn't just tidiness. A bug in "did we parse this payload correctly" and a bug in "did we deliver this message" can no longer hide behind each other in the same function and the same log stream. Each piece has one job, and each job is small enough to reason about on its own, and to blame correctly.
What does the new service do differently?
A record from the moment it arrives
Every notification the server receives becomes a record before anything is sent: an identifier, a state, a timestamp, tied back to the event that triggered it. If someone asks "did this notification go out," there's now something to check that isn't a guess or a log search, there's a row that says what happened and when. That closes the fire-and-exit gap at the center of the old design: nothing about a notification's fate depends on remembering to write it down, because the writing down isn't optional anymore.
Bulk and scheduled sends still work through a list, but each item is claimed individually and progress is tracked as the list is worked through, not assumed at the end. A crash mid-run resumes where it left off instead of silently skipping ahead, and the final count of "sent" versus "failed" is an honest number, not an inferred one.
A request id on every line
Every request gets a correlation id, a single identifier stamped onto a request so every log line it produces can be tied back together, dropped into the logging context the moment it arrives and carried through every downstream log line for that request. Logs are structured, not loose console output scattered across stdout and stderr, which means the question "what happened to notification X" now has a literal answer: filter by its id and read every step it went through, in order, with the actual error attached instead of a guess.
Scaling that isn't a single invocation's problem
Concurrency no longer lives inside one function trying to do four things before a timeout fires. It lives in the platform: the number of running instances scales with load, and each instance handles many concurrent requests on its own. A slow voice-call provider on one request no longer has any bearing on whether the next email goes out on time, because the two are no longer sharing a clock.
A record that outlives the database
Every notification's lifecycle gets written to the database as it happens: created, sent, delivered, failed, with a timeline of when. That same trail is exported into a queryable analytics layer, so once a record's retention window closes and it's cleared from the live database, its history isn't gone with it, it's still there for an audit or a "what happened to this one month ago" question.
What goes into that trail is chosen deliberately: notifications carrying sensitive or regulated content are kept out of it entirely, so the system holds only what it needs to prove something was sent, not the regulated content itself. That's data minimisation as a design choice, not an afterthought.
Every outcome accounted for, including the one that didn't make it
What is next: knowing if it landed
Sending a message and knowing what happened to it afterward are two different problems, and this pipeline has really only solved the first one so far. The groundwork is already in place: a notification's recorded state has room for more than "sent", opened and clicked sit alongside delivered and failed, and the email path is already wired to a provider-side configuration capable of emitting exactly those signals.
What doesn't exist yet is the logic that turns a raw provider event into a state we trust, and an endpoint that lets anyone ask "was this one opened" and get a real answer back. That's the next layer, built the same way the rest of this pipeline was: plumbing and data model first, engagement tracking on top of a system that already accounts for every message, not bolted onto one that couldn't.
What we are not pretending is finished
We'd rather say this plainly than let the story round itself up. Channel dispatch within a single request still runs one channel after another, not in parallel, so a multi-channel request takes as long as its slowest channel. There's still no dead-letter queue or automatic retry wired into the server's delivery path; a failed send is recorded as failed, and someone still has to act on it. Both are next on the list, not solved by the rewrite alone. What changed is that we can now see these gaps clearly enough to prioritize them, which was never possible when the failures were invisible in the first place.
What we would tell a team about to do the same thing
Read the handler you inherited before you trust it. The silent validation skips and the missing persistence layer had been sitting in plain sight the whole time; nobody had reason to open those files until a customer asked where their message went.
A caught exception that only logs is a decision, not a safety net. Every silent failure in the old system came from the same instinct: catch the error so the function doesn't crash. That instinct protects uptime and sacrifices truth.
Give every unit of work a lifecycle, not just an invocation. The deepest fix wasn't a language, a framework, or a hosting model. It was treating a notification as a record that exists before it's sent and can be queried after.
Structured logs with a correlation id pay for themselves the first time you need them. The difference between "grep the logs and hope" and "filter by request id and read the story" is the difference between minutes and hours.
The old Lambda's real failure was never about Lambda as a platform. It was a system with no way to tell "delivered" from "disappeared". The fix wasn't a bigger function, it was a pipeline that treats every notification as something owed until it's proven sent, and says so, loudly, when it isn't.
The core fix is to stop treating a notification as something that either happens inside one invocation or never happened at all. Give it a stored lifecycle: write a record the moment the notification arrives, with an id, a state, and a timestamp, before anything is sent. Then a caught error or a skipped channel becomes a recorded "failed" state you can query, not a log line that scrolls away. The invocation stops being the only proof that anything happened.
Not because serverless is wrong. The problem was a single stateless function doing three jobs (receive, decide, deliver) with no memory of its own history, no dead-letter queue, and several synchronous provider calls racing one hard timeout. Splitting it into a lightweight forwarder plus a stateful, horizontally scalable server means each piece has one job, concurrency lives in the platform instead of inside one invocation, and delivery outcomes get written down instead of assumed.
A correlation id is a single identifier stamped onto a request so every log line it produces can be tied back together. In a notification pipeline it turns "grep thousands of unstructured log lines and hope" into "filter by one id and read the whole story of that message in order," with the real error attached. It is one of the cheapest changes with the highest payoff the first time you need to debug a specific missing message.
By minimising what the trail holds. Record the lifecycle facts that prove a message was sent (id, state, timestamps, channel) and deliberately keep notifications carrying sensitive or regulated content out of the analytics trail entirely. The system then holds only enough to answer "was this sent and when," not the regulated content itself, which is data minimisation applied as a design choice rather than a cleanup step.