sondahub

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

Empty: the playground secret.

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.
A ticket that calls you back, Stripe-style
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

StyleHeadersSigned content
Standard Webhookswebhook-id, webhook-timestamp, webhook-signature: v1,<base64>HMAC-SHA256 of id.timestamp.body, keyed with the base64-decoded secret after whsec_
StripeStripe-Signature: t=<unix>,v1=<hex>HMAC-SHA256 of t.body, keyed with the secret string
GitHubX-Hub-Signature-256: sha256=<hex>, X-GitHub-DeliveryHMAC-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))
POST/v1/utils/webhooks/verifyPaste what your receiver got — {"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.