Webhook HMAC Signing
With signing enabled, every webhook carries an HMAC-SHA256 signature over the exact bytes we sent, plus the time we sent them. Verifying it proves both authenticity (it came from PaymentHood) and integrity (nothing was modified in transit), and the timestamp lets you reject replays.
1. Enable signing and get your secret
In the Console: Settings → Webhook → Enable webhook signing. Or call the App API:
curl -X POST https://appapi.paymenthood.com/api/apps/YOUR_APP_ID/webhook-signing-secret \
-H "Authorization: Bearer YOUR_API_KEY"
# 200 OK
# { "signingSecret": "whsec_9Xy2...redacted..." }
The secret looks like whsec_ followed by 32 random bytes in base64url. It is returned once — store it in your secret manager or environment immediately; it is never shown again. Calling the endpoint again rotates the secret and invalidates the old one. DELETE on the same path turns signing off, after which webhooks are sent unsigned.
The signing secret is a credential, not a setting. Keep it out of source control, out of client-side code and out of logs. Rotate it if it is ever exposed — and see rotating without downtime.
2. The headers we send
| Header | Example | Meaning |
|---|---|---|
X-PaymentHood-Signature |
t=1785000000,v1=6f1a…c3 |
Signing timestamp and the version-1 signature, comma separated. |
X-PaymentHood-Timestamp |
1785000000 |
The same timestamp on its own, for convenience. |
The t value is Unix time in seconds, UTC. The v1 value is lowercase hex, 64 characters. New signature versions would arrive as additional vN= pairs in the same header, so parse by key rather than by position.
3. The algorithm
signed_value = "{t}" + "." + raw_request_body # a literal dot between them
signature = hex_lowercase( HMAC_SHA256( signed_value, signing_secret ) )
header = "t={t},v1={signature}"
To verify a request:
- Read the raw body as bytes, before any JSON parsing.
- Parse
tandv1from the signature header. - Reject the request if
tis further from your clock than your tolerance — five minutes is a good default. - Recompute the HMAC over
t + "." + raw_bodywith your secret. - Compare with
v1in constant time. - Only then parse the JSON and act on it.
The single most common cause of failed verification is a re-serialized body. If your framework parses JSON and you re-encode it, key order and whitespace change and the signature will never match. Capture the raw bytes first — every sample below shows how.
Worked example
Use these values to check your implementation offline. With secret whsec_test_secret, timestamp 1720000000 and body:
{"payment":{"paymentId":42,"paymentState":"Captured"}}
the signed value is 1720000000.{"payment":{"paymentId":42,"paymentState":"Captured"}} and your HMAC-SHA256, hex-encoded lowercase, must equal the v1 value we send. Any implementation that agrees on this string agrees on every webhook.