sondahub / Webhook tester
Webhook tester
Real APIs call you back, so this one does too: signed the way Standard Webhooks, Stripe and GitHub sign theirs, and retried when your endpoint fails. Send one test event from this page, or put a header on any write and get an event for the record it creates, changes or deletes.
Send a test event
The answer shows what your endpoint said, the signature headers, the exact string that was signed and the body — everything a receiver needs to get its check right.
Webhooks from real writes
Add a header to any write — REST, GraphQL, gRPC, SCIM or an MCP tool — and the record it creates, changes or deletes is POSTed to your endpoint as an event:
| Header (or query) | What |
|---|---|
X-Sondahub-Webhook (_webhook) | Where to send. Public http(s) addresses only. |
X-Sondahub-Webhook-Style (_webhook_style) | standard (default), stripe or github. |
X-Sondahub-Webhook-Secret (_webhook_secret) | Your secret; the playground one otherwise. |
X-Sondahub-Webhook-Mode (_webhook_mode) | async (default): sent after the answer, with retries. sync: sent before it, each outcome in the body's _webhooks. |
curl -X POST https://api.sondahub.com/v1/helpdesk/tickets \
-H "Content-Type: application/json" \
-H "X-Sondahub-Webhook: https://your-endpoint.example/hooks" \
-H "X-Sondahub-Webhook-Style: stripe" \
-d '{"customer_id": 1, "subject": "Printer on fire", "priority": "urgent"}'
The answer's X-Sondahub-Webhook header says what was queued. Each delivery is a POST with the event as JSON:
{
"id": "evt_3f9a1c27d4b08e6a5c12e07b",
"type": "helpdesk.ticket.created",
"created_at": "2026-10-01T12:00:00Z",
"api": "helpdesk",
"data": {
"object": {
"id": 2001,
"number": "HD-12001",
"subject": "Printer on fire",
"status": "open",
"priority": "urgent",
"customer_id": 1,
"…": "…"
}
},
"livemode": false,
"session": false
}
Every delivery also carries X-Sondahub-Event, X-Sondahub-Delivery (the event id, the same on every retry) and X-Sondahub-Attempt.
The three signatures
| Style | Headers | Signed content |
|---|---|---|
| Standard Webhooks | webhook-id, webhook-timestamp, webhook-signature: v1,<base64> | HMAC-SHA256 of id.timestamp.body, keyed with the base64-decoded secret after whsec_ |
| Stripe | Stripe-Signature: t=<unix>,v1=<hex> | HMAC-SHA256 of t.body, keyed with the secret string |
| GitHub | X-Hub-Signature-256: sha256=<hex>, X-GitHub-Delivery | HMAC-SHA256 of the body, keyed with the secret string |
A receiver in Node, all three — always over the raw body, never a re-serialised one:
import crypto from 'node:crypto'
// Standard Webhooks: the key is the secret after "whsec_", base64-decoded
function verifyStandard(rawBody, headers, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`
const expected = 'v1,' + crypto.createHmac('sha256', key).update(signed).digest('base64')
const fresh = Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) < 300
return fresh && headers['webhook-signature'].split(' ').some((s) => safeEqual(s, expected))
}
// Stripe: HMAC of "t.body" with the whole secret string
function verifyStripe(rawBody, header, secret) {
const p = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${p.t}.${rawBody}`).digest('hex')
return safeEqual(p.v1, expected)
}
// GitHub: HMAC of the body alone
function verifyGitHub(rawBody, header, secret) {
return safeEqual(header, 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'))
}
const safeEqual = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
{"style", "secret", "body", "headers"} — and it says whether the signature is valid, and if not, what was expected.Questions
Can I receive webhooks here, like a request bin?
No. An inbox would have to store what arrives, and sondahub stores nothing. It sends: to your endpoint, to a tunnel to your laptop, or to any request-bin service you like — and shows you exactly what it sent.
Which events are sent?
One per record the write itself creates, changes or deletes, named {api}.{record}.{created|updated|deleted}: store.order.created for an order (its lines are inside the order), bank.transfer.created for a transfer, one event per record for a SCIM Bulk or a batch of MCP calls. What the rules do around a write — stock, counters, balances — rides along in the answer, not as events of its own. Up to ten events per request; an update carries the record before the change in data.previous.
What is the secret?
whsec_c29uZGFodWItcGxheWdyb3VuZC13ZWJob29rLWtleSE= unless you send your own in X-Sondahub-Webhook-Secret. Public on purpose: it proves your verification works, nothing more.
What if my endpoint is down or slow?
Each attempt waits four seconds for an answer. A timeout, a network error, a 408, a 429 or a 5xx is retried after 1, 3 and 8 seconds — all within the half minute the hub may keep working after it has answered you; any other answer is final. With X-Sondahub-Webhook-Mode: sync there is one attempt per event, made before the answer, and the outcome is in the answer.
Can it call localhost or a private address?
Not from the public hub — only public http(s) addresses. Run the hub on your own machine (it is MIT-licensed) and it will call anything.