Blog · · 5 min read

Slack webhook failed at 2am? Nothing retried it

A pasted Slack incoming webhook has no retries, no history, no filters and no fan-out. What that costs at 2am, and what one POST to a router gives you instead.

You did the sensible thing. Somewhere in your app there is a function called notifySlack, and inside it is a fetch to an incoming-webhook URL. A payment lands, a message appears in #payments, everyone feels good. It took ten minutes to build and it has worked for months.

This post is not about that being wrong. It is about the night it stops working and nobody finds out.

The message that was never sent

Tuesday, 2:07 AM. A customer's card is declined. Your webhook handler catches the error, and in the catch block it calls notifySlack("Payment failed for …"). That call makes an HTTP request to Slack.

Slack answers 429. Or your host has a thirty-second DNS hiccup. Or the request just times out, because it is 2am and something upstream is being restarted. The fetch rejects. Your catch block was not written to catch its own notification failing, so either the exception escapes and takes the rest of the handler with it, or it is swallowed by a second catch you added after the first time this happened.

Either way, the message is gone. There is no queue, no retry, no record that anything was ever attempted. In the morning, #payments looks like a quiet night.

What a pasted URL cannot do

An incoming webhook is a fire-and-forget HTTP call. That is the whole feature, and it is why it is so easy to set up. It is also why it has none of the properties you would want from anything that carries the words payment failed:

None of that is a criticism of Slack. A webhook URL is a mailbox, not a mail carrier.

Put a router between your app and the channel

EventSend is the mail carrier. Your app makes one HTTP POST, and everything above happens on the other side of it.

curl -X POST https://eventsend.io/api/events \
-H "Authorization: Bearer $EVENTSEND_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event": "payment-failed",
"message": "Payment failed: card declined for [email protected]",
"level": "error",
"payload": {"customer": "cus_123", "reason": "card_declined"},
"unique_key": "evt_1Q2w3e"
}'

A 202 means the event is queued. From there:

And because a notification is a claim about now, a delivery that is more than 24 hours old is skipped as stale rather than sent, and the log says so. A Slack critical expires after fifteen minutes. An alert about a problem that is no longer happening is a second problem.

The 2am replay

Same Tuesday, same declined card, same 429 from Slack. This time the catch block posted one event to EventSend and got its 202 in a few milliseconds. The Slack attempt failed, the log says 429, and the next attempt thirty seconds later succeeded. The message is in #payments, timestamped 2:08 AM. Because the event was error, quiet hours held it off your phone until seven. Because the topic also feeds the finance webhook, a signed copy is sitting in your own system, verified with the signature EventSend put on it.

You still wrote a notifySlack function. It just stopped being the thing that decides whether anyone hears about the payment.

It is still one HTTP call

If you already have an incoming webhook wired in, moving is not a project. The request above is the whole API. Swap the URL and the body, keep the token in an environment variable, and delete the retry loop you were going to write one day. The free plan includes 2,500 events a month, three team destinations and one source, with no card and no trial clock.

The one thing to change in your head is smaller than the code: the channel is where the message ends up. It should never have been the thing that decides whether it arrives.