You're building the receiving side of a webhook integration β the endpoint that will one day catch events from Stripe, GitHub, or your own platform's event bus. To test it, you need someone to actually send you webhooks: real HTTP POSTs, with real signatures, on demand. Tools like webhook.site solve the opposite problem (inspecting webhooks you receive); hand-crafting curl commands gets old the moment signatures are involved.
Mockbird's mock APIs can fire HMAC-SHA256-signed webhooks at any URL every time a record changes β which turns any project into a free, scriptable webhook sender. No signup required. Here's the whole flow.
curl -X POST https://HOST/api/projects \
-H 'content-type: application/json' -d '{"name":"webhook-lab"}'
# β {"id":"abc123","adminKey":"...","baseUrl":"https://HOST/m/abc123"}
curl -X POST https://HOST/api/projects/abc123/resources \
-H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
-d '{"name":"orders","template":"orders","seed":10}'
curl -X PUT https://HOST/api/projects/abc123/webhook \
-H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
-d '{"url":"https://your-app.example.com/hooks/orders"}'
# β {"ok":true,"webhook":{"url":"...","secret":"whsec_...","events":["created","updated","deleted"]}}
Save the secret β that's your signing key. You can also restrict events to a subset, e.g. {"url":"...","events":["created"]}, and manage all of this from the dashboard's Webhook card instead of curl.
curl -X POST https://HOST/api/projects/abc123/webhook/test -H 'x-admin-key: YOUR_KEY'
# β {"ok":true,"status":200,"duration_ms":143}
The response is synchronous, so you immediately know whether your endpoint answered, what status it returned, and how long it took β no digging through logs to find out whether anything arrived.
Every write to the mock API produces a delivery. Create, update, or delete records β from curl, from your app, from a test script, or by clicking around the dashboard's data editor:
# fires "orders.created" at your endpoint
curl -X POST https://HOST/m/abc123/orders \
-H 'content-type: application/json' \
-d '{"product":"Test Plan","quantity":3,"status":"pending"}'
# fires "orders.updated"
curl -X PATCH https://HOST/m/abc123/orders/1 \
-H 'content-type: application/json' -d '{"status":"shipped"}'
# fires "orders.deleted" (payload includes the removed record)
curl -X DELETE https://HOST/m/abc123/orders/2
Each delivery is a POST with a JSON payload and three headers:
POST /hooks/orders HTTP/1.1
X-Mockbird-Event: orders.created
X-Mockbird-Delivery: evt_k2j9x8a1
X-Mockbird-Signature: sha256=8f3a2c...
{
"id": "evt_k2j9x8a1",
"event": "orders.created",
"project": "abc123",
"resource": "orders",
"action": "created",
"record": { "id": 11, "product": "Test Plan", "quantity": 3, "status": "pending" },
"ts": 1753430000
}
The signature is HMAC-SHA256 over the raw request body, keyed with your whsec_ secret β the same scheme Stripe and GitHub use, so the verification code you write against Mockbird transfers directly. Node:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" +
createHmac("sha256", secret).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
// Express: app.post("/hooks/orders", express.raw({type:"*/*"}), (req,res) => {
// if (!verify(req.body, req.get("x-mockbird-signature"), SECRET)) return res.sendStatus(401);
// ... })
Python:
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Classic bugs this setup will catch for you: verifying against the parsed-and-re-serialized JSON instead of the raw bytes, comparing with == instead of a constant-time compare, and forgetting that header prefixes like sha256= are part of the header, not the digest.
curl https://HOST/api/projects/abc123/webhook/deliveries -H 'x-admin-key: YOUR_KEY'
The last 20 delivery attempts are logged with status, error (timeout, non-2xx, connection refusedβ¦), and duration β also visible live in the dashboard. If your endpoint returned a 500 or took longer than the 5-second timeout, you'll see exactly that.
Deliveries come from the internet, so http://localhost:3000 won't work (private and loopback addresses are refused). Two easy options:
cloudflared tunnel --url http://localhost:3000 (or ngrok) gives you a public URL that forwards to your dev server β point the webhook at that.