Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Webhooks

Rustybin verifies and signs webhooks in three common schemes, so you can show a gateway's webhook signature validation (or signing) against a counterpart that explains exactly why a signature fails. It never sends webhooks itself: there is no outbound delivery, so these endpoints cannot be abused for SSRF.

SchemeHeadersSignature
Standard Webhooks (standard)webhook-id, webhook-timestamp (unix seconds), webhook-signaturespace-separated v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}; the secret is whsec_<base64 key> and the HMAC key is the decoded part
GitHub style (github)X-Hub-Signature-256sha256=<hex HMAC-SHA256(secret, body)>, the secret used as is
Stripe style (stripe)Stripe-Signaturet=<unix>,v1=<hex HMAC-SHA256(secret, "{t}.{body}")>, the full secret (with its whsec_ prefix) as key

Timestamps (Standard Webhooks and Stripe) must be within 300 seconds (replay protection).

RoutePurpose
GET /webhooksThe schemes and the demo secrets
POST /webhooks/verifyVerify a Standard Webhooks request
POST /webhooks/verify/{scheme}Verify standard, github or stripe
GET, POST /webhooks/signProduce a signed example: headers, body and a ready-to-run curl command
POST /webhooks/receive/{secret_id}A receiver with a demo secret: 204 when valid, 401 otherwise

Verifying

The secret comes from the X-Webhook-Secret header or ?secret=. Options: ?tolerance= (seconds) and ?ignore_timestamp=true. Valid requests get 200; failures get 401 (timestamp_too_old, timestamp_too_new, signature_mismatch, no_supported_signature) or 400 (missing_headers, missing_secret, invalid_secret, invalid_timestamp). The reply always includes the expected signature, which makes debugging a signing plugin quick.

GET {{base_url}}/webhooks
HTTP 200
[Asserts]
jsonpath "$.outbound_delivery" == false
jsonpath "$.demo_secrets[0].id" == "demo"
jsonpath "$.demo_secrets[0].secret" == "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
# The Standard Webhooks test vector (its timestamp is old, so skip the replay check).
POST {{base_url}}/webhooks/verify?ignore_timestamp=true
X-Webhook-Secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id: msg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp: 1614265330
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Content-Type: application/json
`{"test": 2432232314}`
HTTP 200
[Asserts]
jsonpath "$.valid" == true
jsonpath "$.signature_valid" == true
POST {{base_url}}/webhooks/verify
X-Webhook-Secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id: msg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp: 1614265330
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Content-Type: application/json
`{"test": 2432232314}`
HTTP 401
[Asserts]
jsonpath "$.error" == "timestamp_too_old"
jsonpath "$.signature_valid" == true
jsonpath "$.expected_signature" == "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
POST {{base_url}}/webhooks/verify/github
X-Webhook-Secret: demo-secret
X-Hub-Signature-256: sha256=a30169f10c965c2302e48cf57ce4d4b80cc383086b1f62f3ebeb54d21bab149a
Content-Type: application/json
`{"zen":"Keep it logically awesome."}`
HTTP 200
[Asserts]
jsonpath "$.valid" == true
POST {{base_url}}/webhooks/verify/stripe?ignore_timestamp=true
X-Webhook-Secret: whsec_test_secret
Stripe-Signature: t=1700000000,v1=13941114bb88ac44a76abcfddea5b92aa6182a4b63d8be3aae908a616083bd7e
Content-Type: application/json
`{"id":"evt_test"}`
HTTP 200
[Asserts]
jsonpath "$.valid" == true

Signing and receiving

/webhooks/sign takes ?scheme= (standard by default), ?secret= (default: the demo secret), the payload (POST body or ?payload=, else a sample order.created event), ?id= and ?timestamp=. Replay its output through a gateway to the receiver, which detects the scheme from the headers. Demo secrets:

secret_idSecret
demowhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
github-docsIt's a Secret to Everybody
# Ask Rustybin to sign a payload with the `demo` secret...
POST {{base_url}}/webhooks/sign
Content-Type: application/json
`{"type":"order.created","id":"ord_42"}`
HTTP 200
[Captures]
wh_id: jsonpath "$.headers['webhook-id']"
wh_ts: jsonpath "$.headers['webhook-timestamp']"
wh_sig: jsonpath "$.headers['webhook-signature']"
[Asserts]
jsonpath "$.scheme" == "standard-webhooks"
jsonpath "$.receive_path" == "/webhooks/receive/demo"
jsonpath "$.curl" contains "{{base_url}}/webhooks/verify"

# ...then deliver it (for example through a gateway) to the demo receiver.
POST {{base_url}}/webhooks/receive/demo
webhook-id: {{wh_id}}
webhook-timestamp: {{wh_ts}}
webhook-signature: {{wh_sig}}
Content-Type: application/json
`{"type":"order.created","id":"ord_42"}`
HTTP 204
POST {{base_url}}/webhooks/receive/demo
X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000
Content-Type: application/json
`{"type":"order.created"}`
HTTP 401
[Asserts]
jsonpath "$.error" == "signature_mismatch"

To capture webhooks a gateway or a third party sends, use a request bin.