"Delivered" Is a Lie. Here's How to Prove Your Webhook Was Actually Applied.
Your monitoring says green. Stripe says delivered. Your customers say their Pro access never showed up.
Every webhook tool on the market tracks the same thing: did the HTTP request land? Did your server return a 200? That is delivery confirmation. It tells you the envelope arrived. It says nothing about whether anyone read the letter, acted on it, or filed it correctly.
Outcome receipts are different. They answer the only question that actually matters in production: did your application code commit the side-effect?
The Gap Between 200 OK and Business Logic Executing
When a webhook arrives at your handler, you return 200 OK to acknowledge receipt — and that's the last thing any monitoring tool sees. Everything that happens after that response is invisible to your webhook provider, your observability stack, and every inspection tool available today.
That gap is where revenue disappears.
Consider a Stripe customer.subscription.updated event. Your handler receives it, acknowledges it immediately (correctly — you should always acknowledge fast), then kicks off your business logic:
app.post('/webhooks/stripe', async (req, res) => {
// Acknowledge immediately — correct pattern
res.status(200).json({ received: true });
// Everything below here is invisible to Stripe
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_SECRET
);
const subscription = event.data.object;
const userId = await getUserByStripeId(subscription.customer);
// This database write is what actually matters
await db.query(
'UPDATE users SET tier = $1 WHERE id = $2',
[subscription.plan.nickname, userId]
);
});
The response has already gone. Stripe sees 200. Your webhook dashboard sees delivered. Now consider what can go wrong after that line:
Connection pool exhaustion. Your database pool is at maximum connections. The await db.query call waits, then times out. Exception thrown. No database write. Stripe shows delivered. Customer has no Pro access.
Unhandled promise rejection. Your getUserByStripeId function throws on a null customer ID from a race condition with account deletion. Node.js swallows it (in older versions) or your global error handler logs it somewhere no one reads. Stripe shows delivered. Customer has no Pro access.
Partial transaction rollback. Your handler does three database writes in a transaction. Write two succeeds. Write three fails. The transaction rolls back. All three writes are gone. Your application never retried because it returned 200 before the transaction completed. Stripe shows delivered. Customer has no Pro access.
Queue drop. Your handler pushes a job onto a Redis queue for async processing. Redis accepts the push (returns OK). The queue processor crashes 400 milliseconds later before picking it up. Job lost. Stripe shows delivered. Customer has no Pro access.
Every one of these failure modes is invisible to every existing tool. They all look like success. Your error rate dashboard shows 0%. Your P99 latency looks fine. Your on-call engineer is asleep.
This is not a theoretical edge case. If you have more than 200 active customers and you have been running for more than six months, this has happened to you. You probably fixed it manually in the database at some point. You may not know which customers were affected.
The Monday Morning Call
You are a founder. Two hundred paying customers. Revenue growing. You shipped a refactor Thursday afternoon — cleaned up the subscription handler, made it more readable, moved some async logic around.
Over the weekend, 14 customers upgraded from Free to Pro. They paid. Stripe charged them. Everything looked fine.
Monday morning: six emails in your inbox. "I upgraded Saturday but I still can't access Pro features." "My invoice shows I'm being billed for Pro but the app still shows Free."
Your stomach drops.
You open Stripe. Six customers, all paid. All webhook events show Delivered. You open your webhook inspector — every event shows a 200 response. You look at your error tracking — nothing. You check your database. Eight customers have their tier set to pro. Six do not.
Without outcome receipts, you are about to spend four hours in a debugging session that goes roughly like this: read the handler code (looks fine), check the database logs (they only keep 24 hours), try to reproduce locally (can't because it depends on real Stripe event payloads), grep through application logs (they're structured JSON, you're not sure what you're looking for), eventually find a log entry about a database connection timeout on Saturday afternoon, connect it to a deployment that was happening around the same time, realize the connection pool was misconfigured, fix it, manually update the six customers' tiers, email them apologies.
Four hours. Six angry customers who spent the weekend on Free tier they paid to escape. A manual database edit you might get wrong.
With outcome receipts: HookTunnel received the six events. Returned 200. Your handler processed and returned. No receipt came back for six of those events within the SLA window. HookTunnel flagged them as Applied Unknown at 11am Saturday. An alert fired. Your on-call engineer (or you, on your phone) saw six events with no confirmed outcome. Replayed them with one click. Four succeeded. Two failed with a new error code. Those two got a PagerDuty alert. The engineer fixed the underlying connection issue, replayed the two remaining. All six customers had Pro access by 1pm Saturday. They never emailed you. You never had a Monday morning.
That is what proof of application changes. Not just operationally — emotionally. You sleep on weekends.
How Outcome Receipts Work
HookTunnel supports two receipt patterns: sync (embedded in your response body) and async (a POST callback after your database write commits).
Sync Receipts
The fastest path. Instead of returning { received: true }, you return the receipt inline:
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_SECRET
);
const subscription = event.data.object;
const userId = await getUserByStripeId(subscription.customer);
await db.query(
'UPDATE users SET tier = $1 WHERE id = $2',
[subscription.plan.nickname, userId]
);
// Database write committed — now send the receipt
res.status(200).json({
received: true,
_hooktunnel: {
receipt: {
event_id: event.id,
status: 'applied',
committed_at: new Date().toISOString()
}
}
});
});
HookTunnel extracts the receipt from the response body. Because the await db.query completed before the response was sent, the receipt represents a real database commit.
Async Receipts
For handlers that return 200 immediately and process asynchronously, use the callback pattern:
app.post('/webhooks/stripe', async (req, res) => {
// Acknowledge immediately
res.status(200).json({ received: true });
// Process asynchronously
const event = stripe.webhooks.constructEvent(...);
const hookId = req.headers['x-hooktunnel-hook-id'];
const eventId = req.headers['x-hooktunnel-event-id'];
try {
await processSubscriptionUpdate(event.data.object);
// Send receipt after commit
await fetch(`https://hooks.hooktunnel.com/api/v1/receipts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.HOOKTUNNEL_RECEIPT_SECRET}`
},
body: JSON.stringify({
hook_id: hookId,
event_id: eventId,
status: 'applied',
committed_at: new Date().toISOString()
})
});
} catch (err) {
// Send failure receipt so the team knows immediately
await fetch(`https://hooks.hooktunnel.com/api/v1/receipts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.HOOKTUNNEL_RECEIPT_SECRET}`
},
body: JSON.stringify({
hook_id: hookId,
event_id: eventId,
status: 'failed',
error: err.message
})
});
}
});
Receipts are authenticated with HMAC-SHA256. Your receipt secret rotates automatically on a 24-hour grace window — the previous key remains valid during rotation so you never have a gap. Only receipts that pass signature verification flip an event to Applied Confirmed. Spoofed receipts are rejected and flagged.
All receipts are idempotent — sending the same receipt twice has no effect. You can safely retry your receipt POST without creating phantom confirmations.
What "Applied Confirmed" Actually Means
Every other webhook tool collapses all of this into a single state: delivered. HookTunnel has three:
Applied Confirmed: A cryptographically signed receipt from your application code arrived after the database write completed. This is not an assumption. It is not inference. It is your code saying, in a verifiable message, "I committed this write."
Applied Unknown: The event was delivered. The handler returned 200. No receipt arrived within the SLA window. This could be a silent failure, an async handler that never posted back, or a host that processed it without the receipt code. The event is real. The outcome is unknown.
Applied Failed: Your handler sent an explicit failure receipt. The write did not occur. The event needs to be replayed or manually resolved.
The distinction between Confirmed and Unknown is the entire point. A tool that calls everything "delivered" and lets you draw your own conclusions is not accountability infrastructure. Applied Confirmed is a contract between your application and your operations team.
The Reason Code Taxonomy
When receipts carry failure codes, HookTunnel surfaces them in plain English:
| Code | What it means |
|---|---|
| RCPT_AUTH_INVALID | Receipt signature failed HMAC check — possible tampering or key rotation issue |
| RCPT_AUTH_MISSING | Receipt arrived with no signature header |
| RCPT_EVENT_NOT_FOUND | Receipt references an event ID not in HookTunnel's records |
| RCPT_SCHEMA_INVALID | Receipt body was malformed — check your serialization |
| RCPT_DUPLICATE_IGNORED | Idempotency: identical receipt already recorded, this one skipped |
| RCPT_ACCEPTED_PROCESSED | Application confirmed the event was applied successfully |
| RCPT_ACCEPTED_FAILED | Application confirmed the event was not applied — failure receipt |
| RCPT_ACCEPTED_QUEUED | Application queued the event for async processing |
| RCPT_MISSING_SLA | No receipt arrived within the configured SLA window — alert triggered |
| RCPT_TRANSITION_BLOCKED | Receipt attempted an invalid state transition (e.g., confirmed back to unknown) |
These codes appear in the HookTunnel dashboard, in alert payloads, and in the audit log. When your on-call engineer gets paged, they see exactly which code fired and why — not a generic "something went wrong."
Complementary Features
Outcome receipts are most powerful when combined with the rest of HookTunnel's stack:
Stripe Reconciliation — Compare every Stripe payment event against its confirmed outcome. Find the gap between "paid" and "applied" across your entire customer base in one view. Receipts make this meaningful; without confirmed outcomes, reconciliation is guesswork.
Guardrailed Replay — Replay only events that have not been confirmed applied. When you recover from an outage and have 150 events to replay, guardrailed replay uses receipt state to skip the 60 that already confirmed, replay the 90 that are unknown, and flag the failures. No duplicates. No manual triage.
Investigations — When a customer reports an issue, HookTunnel's investigation workflow links receipt state directly to the timeline. You see: event arrived, handler returned 200, receipt never came, SLA elapsed, alert fired. The full story in one view.
FAQ
Does my application need to change?
Yes, to get Applied Confirmed status. You need to add a receipt call after your database write commits. The integration is one POST request. For most handlers, this is a 10-minute change. If you cannot modify the handler, events will remain in Applied Unknown, which still gives you SLA alerting and gap detection — you just cannot get cryptographic confirmation.
What if I can't modify my webhook handler?
You get Applied Unknown by default, which is still significantly more visibility than every other tool provides. You can configure SLA timers to alert you when no receipt arrives. You lose the ability to distinguish "probably worked" from "definitely worked," but you gain the SLA alert layer and the gap analysis that other tools don't have.
What's the difference between Applied Unknown and Applied Failed?
Applied Unknown means no receipt arrived. The event may have succeeded — your handler might not have receipt code, or the receipt POST might have failed independently of the business logic. Applied Failed means your application code explicitly sent a failure receipt: it ran, it tried, it failed, it told HookTunnel. Applied Failed is actionable and unambiguous. Applied Unknown requires investigation.
Is the receipt protocol open?
Yes. The receipt schema is documented and the signing algorithm is standard HMAC-SHA256. You can implement it in any language. We provide SDKs for Node.js, Python, and Go, but the raw protocol is sufficient — it's a POST request with a JSON body and a signature header.
Can receipts be spoofed?
No. Receipts are signed with a shared secret using HMAC-SHA256. The signature is verified server-side before any state transition occurs. An unsigned receipt or a receipt with an invalid signature is rejected with RCPT_AUTH_INVALID and logged. The signing secret rotates on a 24-hour grace window so rotation does not create a gap.
What event types support receipts?
All of them. Receipts are event-agnostic. The protocol works for any inbound webhook — Stripe, GitHub, Twilio, your own internal systems. The receipt links back to the event by ID, not by event type.
What is the SLA timer?
You configure a per-hook SLA window (default: 60 seconds for sync handlers, configurable up to 24 hours for async). If no receipt arrives within that window, HookTunnel fires an alert and marks the event Applied Unknown. The SLA timer is the backstop: even if your receipt code has a bug, you will know within your configured window that something did not confirm.
Set up outcome receipts in 10 minutes
Add one POST call to your webhook handler. Get proof that every payment was applied.
Get started free →