Building & Shipping

Novu Said the Email Sent. That Doesn't Mean the Customer Got It.

Brett Ridenour Brett Ridenour · Published October 2026

An operator messaged me this morning. One of his customers had just booked a trip through the Freebo widget and said they never got the confirmation email. He wanted to know what happened on my end.

The reflex answer is to look in the dashboard, see “sent”, and send him a screenshot. I used to do that. It is wrong often enough that I had to stop.

Here is the thing I eventually learned the hard way: Novu, which is the orchestration layer I use for every transactional send on Freebo, will tell you an email “sent” from at least three different places, and those three places mean three different things. Only one of them means a message actually came into existence. None of them mean the customer’s inbox received it. If you confuse any of these layers with the one below it, you will confidently reassure an operator that his customer got an email that his customer did not get.

The three places that say “sent”

One: the workflow triggered. This is /v1/notifications in Novu’s API, their activity feed in the UI. It shows that Freebo’s API called novuTriggerService.trigger({ workflowId: 'reservation-confirmed', ... }) and Novu accepted it. That’s it. The workflow ran. Steps ran. The feed will show green checks next to each step.

A green check next to an email step in that feed does not mean an email was generated. It means “the step did its job given the configuration it had.” If no email integration is connected to that channel, the step successfully does nothing, and the feed happily agrees it worked. The whole first wave of Freebo bookings sent exactly zero emails and the activity feed had green checks the whole time.

Two: a message object exists. This is /v1/messages?channel=email. A message object only comes into being if there was actually an integration connected to render into. Recipient, subject, content, the provider it was handed to — all of that lives here. If this endpoint returns a row for the trigger you fired, an email was composed and handed off to Resend. That is the first point at which anything resembling an email has entered the world.

Three: the provider accepted it. The message object has a status field. Novu’s own definition of status = 'sent' is “handed to Resend without an error.” It is not “Resend delivered to the mail server.” It is not “the mail server accepted it.” It is a four-digit response code on an API call to the provider.

The three Novu endpoints and what each one actually proves

The actual investigation this morning

Four endpoints, in order, read-only. This is the sequence I ran. No access to the customer’s inbox — that part the operator has to confirm separately.

  1. Step 1
    Trigger fired
    GET /v1/notifications filtered by subscriberId. Found the reservation-confirmed trigger with the right transactionId. All steps green.
  2. Step 2
    Message exists
    GET /v1/messages?channel=email&limit=10. Found a row. Correct subject. Correct recipient address. Correct body with the confirmation number embedded.
  3. Step 3
    Provider accepted
    Message status = sent. Novu handed the payload to Resend without an error. Novu does not know what Resend did next.
  4. Step 4
    This is where observability ends
    No Resend webhook is wired to Freebo yet. Delivered, bounced, deferred, spam-flagged — Freebo has no way to see any of it in production. The post-handoff state is a black hole until I wire the webhook.

So the actual answer I sent to the operator was precise: “The email was composed and handed to our delivery provider with your customer’s correct address and the correct body. I can’t see from here whether the mail server bounced it or whether it landed in a spam folder — can you ask the customer to check promotions and spam, and have them whitelist noreply@freebo.ai?”

That is a much more honest answer than “yes it sent.”

What this is costing me and what I’m doing about it

The missing piece is Resend’s delivery webhook. Resend will post email.delivered, email.bounced, email.complained, email.opened to any URL I give them. Freebo doesn’t ingest those yet. Which means I have ground truth that an email was handed off but no ground truth that it was delivered — and when an operator asks me the question his customer asked him, I can only answer partially.

The build for this is straightforward. One webhook endpoint on the Freebo API, signature verification against the Resend signing secret, a communication_log row keyed on the message id Novu already hands me when it stores the message object. Then the operator view flips from “we tried” to “it was delivered to their mail server at 11:04 CDT” or “it bounced because the address doesn’t exist, here is the mailbox the customer typed.”

Every observability layer stops where the next provider begins. If you don’t wire the handoff, your own system will lie to you about what happened on the other side.

— A good lesson from a boring morning

The upstream lesson I keep relearning is more general than Novu or Resend. Every observability layer in a distributed system stops where the next provider begins. Your API logs stop at the outbound HTTP call. Your orchestrator stops at the provider handoff. The provider’s dashboard stops at the mail server response. The mail server’s logs stop at the recipient’s spam filter. If you want to know what happened all the way through, you have to wire each handoff into your own store so you can read it later. Otherwise the next time somebody says “I never got the email,” you’ll squint at a green check and lie to them without knowing.