A webhook payload lands as a wall of minified JSON: forty keys, half of them null, the interesting three buried in data.object, and every timestamp a ten-digit number. This viewer turns the wall back into information. It recognises who sent the event, pulls the fields you actually need to the top, converts every epoch timestamp into a readable date right next to the value, and renders the rest as a tree you can fold and search.
What the viewer does
Paste either the bare JSON body or the whole request dump with its headers; the tool splits a leading header block off by itself. With headers included, detection gets sharper (GitHub events, for instance, are named only in the X-GitHub-Event header, never in the body) and any signature headers are recognised and explained. Form-encoded bodies, which Twilio still sends, are decoded into key-value pairs instead of failing as broken JSON.
Detection covers the providers whose payloads end up pasted into editors most often: Stripe, GitHub, GitLab, Shopify, Slack, SendGrid, Twilio and PayPal. Everything runs in this tab; a payload full of customer emails and amounts never leaves your machine, which is worth caring about, since real webhook bodies are production data.
Reading the report
- Detected. Provider and event type first, then the summary card: for a Stripe event that is
type,id, live or test mode, the amount already divided out of cents, and the idempotency key; for GitHub it is event, action, repository, sender and the delivery id. These are the fields a handler switches on, and the ones you compare when two deliveries look suspiciously alike. - Signature headers. Recognised, named and explained, including what exactly each provider signs. The computation itself deliberately lives on the HMAC generator, one click away.
- Payload tree. The full body with objects and arrays foldable. The first two levels start open, deeper ones closed, and
--expand-allopens everything. Epoch timestamps carry their date inline, in grey next to the original number, so the value you copy is still the real one. - Search. Type into the search field and matching keys and values are highlighted, their parents unfolded, and the hit count lands in the stats bar. Searching for an email, an id fragment or "amount" in a 60 KB payload beats scrolling every time.
Event structure by provider
Every provider reinvents the envelope. The table is the ten-second orientation for the ones this tool recognises:
| Provider | Event type lives in | Dedupe id | Payload shape |
|---|---|---|---|
| Stripe | type in the body | id (evt_…) | envelope, resource in data.object, times in epoch seconds |
| GitHub | X-GitHub-Event header | X-GitHub-Delivery header | flat body per event, action refines, times as ISO 8601 |
| GitLab | object_kind in the body | X-Gitlab-Event-UUID header | flat body, project and user blocks |
| Shopify | X-Shopify-Topic header | X-Shopify-Webhook-Id header | the bare resource, no envelope at all |
| Slack | event.type, inside type: event_callback | event_id | envelope, times as epoch-second strings like 1721385600.000200 |
| SendGrid | event per array item | sg_event_id per item | an array: one POST batches many events |
| Twilio | MessageStatus / CallStatus | MessageSid / CallSid | form-encoded fields, not JSON |
| PayPal | event_type in the body | id (WH-…) | envelope, resource in resource |
Two of these trip people up disproportionately. Shopify sends no envelope, just the order or product itself, so the topic header is the only way to know what happened. And SendGrid batches: the body is an array, and a handler written for a single object silently processes only body[0] in some frameworks and crashes in others.
Epoch timestamps in webhooks
An epoch timestamp counts seconds since 1970-01-01 UTC, so 1721385600 means 2024-07-19 10:40:00 UTC and nothing in the number tells you that. The viewer annotates every value it can identify: integers between roughly 2001 and 2099 in seconds or milliseconds, plus the string form Slack uses for its ts fields. The unit is printed when it is milliseconds, because the seconds-vs-milliseconds mix-up is the classic off-by-a-factor-of-1000 bug that files events either in January 1970 or in the year 56390.
Reading several of them together is where the annotation pays off: a Stripe invoice carries created, period_start and period_end, and whether the period is one month or one hour is invisible in raw epoch but obvious once the dates sit next to the numbers. The same goes for judging whether a signature timestamp is inside your replay tolerance, or whether the event you are staring at is from today's incident or last week's.
Signature headers, explained
Nearly every provider signs its deliveries, each in its own dialect: Stripe puts t=…,v1=… into Stripe-Signature and signs t.body, GitHub sends sha256=… in X-Hub-Signature-256 over the raw body, Shopify Base64-encodes its HMAC, Slack signs v0:timestamp:body, and GitLab sends no HMAC at all, just the shared token verbatim. When the paste contains one of these headers, the report names the scheme and states exactly what gets signed with which secret.
What this page deliberately does not do is compute the HMAC. Signature debugging needs the raw request bytes and your signing secret, and it has its own set of failure modes (re-serialised bodies, encoding of the secret, comparing hex against Base64). All of that lives on the HMAC generator, which computes the keyed hash over exactly the bytes you give it and diffs the result against the signature you expected.
Getting webhooks onto your laptop
The provider needs a public HTTPS URL and your dev server is localhost:3000, so something has to bridge. Three approaches, in the order we reach for them:
- Provider CLIs.
stripe listen --forward-to localhost:4242/webhookneeds no tunnel, no dashboard config and prints every event as it forwards it;stripe trigger invoice.paidfabricates test events on demand. If your provider has this, it wins. - Tunnels.
ngrok http 3000or a Cloudflare tunnel gives you a public URL to paste into the webhook settings. ngrok's local inspector at127.0.0.1:4040also records every request for replay. - Relays. GitHub's docs point at smee.io: the provider posts to the relay, a small client streams the events down to your machine. No open port, works behind corporate NAT.
Whichever bridge you use, the payload you capture ends up in an editor sooner or later, ten minutes into debugging why the handler misfired. That paste is this page's job: drop the dump in, headers and all, and read it structured instead of squinting at minified JSON.
Retries, duplicates, idempotency
Webhook delivery is at-least-once by contract. Providers retry on timeouts and non-2xx responses, with schedules ranging from Stripe's exponential backoff over three days to Shopify's 19 tries in 48 hours before the subscription is dropped. The three habits that make a handler survive this:
- Answer fast, work later. Persist the event, return 200, process from a queue. The response deadline is seconds, not minutes.
- Dedupe by event id. The summary card surfaces it for every provider precisely because a unique constraint on that id is the whole dedupe implementation.
- Treat the payload as a doorbell. Events can arrive out of order, and the state they describe may be stale by the time you process them. For decisions that matter, fetch the resource fresh from the API; the event tells you that something changed, not what is true now.
The pending_webhooks and idempotency fields in a Stripe envelope, the redelivery counters in GitHub's delivery log: once you know they exist, the providers turn out to document their retry behaviour honestly. The viewer's job is to make sure you see those fields at all.
Debugging webhook deliveries
How do I test webhooks locally without a public URL?
Tunnel them. The Stripe CLI does it natively: stripe listen --forward-to localhost:4242/webhook subscribes to your account's events and forwards them to your dev server, printing each one as it passes. For providers without a CLI, ngrok http 3000 or cloudflared tunnel gives you a public HTTPS URL that proxies to localhost; paste that URL into the provider's webhook settings. GitHub additionally supports smee.io, a free relay the docs themselves recommend for development. The tunnel URL changes on every restart with free plans, so update the provider config or use a CLI that re-subscribes for you.
What does a Stripe webhook event look like?
A JSON envelope with the interesting part nested two levels down. The top level has id (evt_…), object: "event", type like "invoice.paid" or "checkout.session.completed", created as an epoch timestamp, livemode, and a request block with the idempotency key. The actual resource sits in data.object: for invoice.paid that is the full invoice with amount_paid in cents, currency, customer and status. Handlers switch on type and then read data.object; everything else in the envelope is metadata for logging and dedupe.
What is the X-GitHub-Event header?
The event name of a GitHub webhook delivery: push, pull_request, issues, release and so on. It is the only place the event type lives; the JSON body has no type field, so a handler that ignores the header cannot tell a push from a fork. The body shape changes per event, with action distinguishing sub-events like pull_request opened versus closed. Two more headers matter: X-GitHub-Delivery is a unique id per delivery, useful for dedupe and for finding the delivery in the repo's webhook log, and the very first delivery after creating a hook is a ping event carrying a zen string.
Why does my webhook endpoint receive the same event twice?
Because webhook delivery is at-least-once, not exactly-once. Providers retry when they do not get a 2xx response in time, and "in time" is short: Stripe and Shopify wait a few seconds before treating the delivery as failed, so a handler that does slow work before responding gets retried even though it eventually succeeded. Network timeouts, deploys mid-request and provider-side incidents all add duplicates. This is documented behaviour everywhere, not a bug, and the fix sits on your side: respond fast and deduplicate by the event id.
How do I make a webhook handler idempotent?
Record the event id and skip anything you have seen. Every serious provider sends one: Stripe's event id, GitHub's X-GitHub-Delivery, Shopify's X-Shopify-Webhook-Id, PayPal's WH-… id. Insert it into a table with a unique constraint before processing; a violation means a duplicate, answer 200 and stop. Beyond dedupe, write the handler so replaying is harmless: set states absolutely ("mark invoice paid") rather than relatively ("add 42 to balance"), and treat the payload as a notification, fetching the current resource from the API when the decision matters.
How do I replay or resend a webhook event?
From the provider's dashboard, without touching code. Stripe: Developers → Webhooks → select the endpoint → the event → Resend, or stripe events resend evt_… from the CLI. GitHub: repo Settings → Webhooks → Recent Deliveries → Redeliver, which also shows the exact request and your response. Shopify keeps redelivery in the notification settings, GitLab in the webhook's edit page. Replaying against production is also the standard test of your idempotency: the second delivery should change nothing.
Are webhook timestamps in seconds or milliseconds?
Mostly seconds, but not consistently. Stripe (created), Slack (event_time and the ts strings) and most OAuth-adjacent payloads use epoch seconds; JavaScript-centric APIs and anything built on Date.now() emit milliseconds; GitHub sidesteps the question by sending ISO 8601 strings like "2024-07-19T10:40:00Z". The reliable tell is magnitude: current times are around 1.7 billion in seconds and 1.7 trillion in milliseconds, so a 13-digit value is milliseconds. Passing one to a seconds API silently produces a date fifty thousand years out, which is why this tool annotates each value with its detected unit.
How do I convert an epoch timestamp like 1721385600 to a date?
In JavaScript: new Date(1721385600 * 1000).toISOString(), multiplying because Date expects milliseconds. Python: datetime.fromtimestamp(1721385600, tz=timezone.utc). On the shell, date -d @1721385600 on Linux and date -r 1721385600 on macOS. In the other direction, date +%s or Math.floor(Date.now() / 1000) gives the current epoch seconds. The value 1721385600 is 2024-07-19 10:40:00 UTC; epoch timestamps have no timezone of their own, they count seconds since 1970-01-01 UTC, and only the formatting step localises them.
What HTTP status should a webhook endpoint return?
A 2xx, and quickly. Providers only check the status class: 200 or 204 means delivered, anything else means retry, and a slow 200 counts as a timeout. The robust pattern is to validate, persist the raw event, respond 200, and do the real work asynchronously from a queue. Returning 500 on processing errors sounds honest but turns every bug into a retry storm; reserve non-2xx for cases where you actually want redelivery, and never redirect, since several providers refuse to follow a 3xx.
Why is my webhook request body empty or unparseable?
Usually a body-parsing mismatch. Twilio and some older providers send application/x-www-form-urlencoded, so a JSON parser sees a non-JSON body; check the Content-Type before choosing a parser. The opposite bite: framework middleware that consumes the stream (express.json(), body already read by another handler) leaves nothing for your code, which matters doubly because signature checks need the raw bytes. And a GET to the endpoint, which some providers send as a reachability probe, has no body at all; handle it with a plain 200.