HMAC Verification
How to sign Custom E-Com webhook requests so LiveRecover accepts them.
LiveRecover authenticates every Custom E-Com webhook with an
HMAC-SHA256 signature you compute over the raw request body using
the per-brand secret. If the signature doesn't match, the request is
rejected with 401 Unauthorized and is not retried for you — your
sender must produce a valid signature.
HMAC SHA-256 is the default and recommended way to authenticate your webhooks, but it is one of several validation options. If your platform can't compute an HMAC signature, see Validation Methods for the alternatives (static header, source-IP allowlist, or none) and how to choose.
The algorithm
- Take the exact bytes of the JSON body you are about to send. Do not pretty-print, re-stringify, or normalize whitespace after signing.
- Compute
HMAC-SHA256(secret, body). - Hex-encode the result, lowercase.
- Send it in the
x-vyg-signatureheader.
On the receiver side LiveRecover does the same computation and compares constant-time against what you sent.
Footgun: sign the bytes you actually send
The single most common cause of 401 from this endpoint is signing one
representation of the body and sending another. Two equivalent JSON
documents — say, the same fields in a different order, or with extra
whitespace — produce different signatures.
The safe pattern is to serialize the body once into a string or byte
buffer, sign that, and send that. Do not call JSON.stringify a second
time, do not pass the body through middleware that re-encodes it, do not
let your HTTP client re-serialize a parsed object.
If you find yourself calling JSON.stringify(JSON.parse(body)) to "make
sure" — stop. That changes the bytes and breaks the signature.
Worked examples
All three examples below produce the same signature for the same body and secret. You can paste them locally to verify.
Fixture
secret = whsec_demo_secret
body = {"type":"checkout_abandoned","event_id":"evt_demo_1","data":{"id":"ck_001"}}
expected x-vyg-signature:
d9eea748ce5eff60b7f4edea5da4488ef750296f9ca6c145ea95b0337e580c6bcURL + OpenSSL
SECRET='whsec_demo_secret'
BODY='{"type":"checkout_abandoned","event_id":"evt_demo_1","data":{"id":"ck_001"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
echo "$SIG"
# d9eea748ce5eff60b7f4edea5da4488ef750296f9ca6c145ea95b0337e580c6b
curl -sS -X POST "https://<your-vyg-webhook-host>/custom/<webhookId>" \
-H 'content-type: application/json' \
-H 'x-vyg-topic: checkout_abandoned' \
-H 'x-vyg-event-id: evt_demo_1' \
-H "x-vyg-signature: $SIG" \
--data "$BODY"Note the printf '%s' (not echo) — echo adds a trailing newline that
would invalidate the signature.
Node.js
import { createHmac } from 'node:crypto';
const secret = 'whsec_demo_secret';
const rawBody = '{"type":"checkout_abandoned","event_id":"evt_demo_1","data":{"id":"ck_001"}}';
const signature = createHmac('sha256', secret).update(rawBody).digest('hex');
// d9eea748ce5eff60b7f4edea5da4488ef750296f9ca6c145ea95b0337e580c6b
await fetch('https://<your-vyg-webhook-host>/custom/<webhookId>', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-vyg-topic': 'checkout_abandoned',
'x-vyg-event-id': 'evt_demo_1',
'x-vyg-signature': signature,
},
body: rawBody,
});The string passed to .update() and the string passed as body must be
the same value. If you build the payload as an object, stringify it
once into a const rawBody, sign that, and send that.
Python
import hmac
import hashlib
import urllib.request
secret = b'whsec_demo_secret'
raw_body = b'{"type":"checkout_abandoned","event_id":"evt_demo_1","data":{"id":"ck_001"}}'
signature = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
# d9eea748ce5eff60b7f4edea5da4488ef750296f9ca6c145ea95b0337e580c6b
req = urllib.request.Request(
'https://<your-vyg-webhook-host>/custom/<webhookId>',
data=raw_body,
method='POST',
headers={
'content-type': 'application/json',
'x-vyg-topic': 'checkout_abandoned',
'x-vyg-event-id': 'evt_demo_1',
'x-vyg-signature': signature,
},
)
urllib.request.urlopen(req)If you are using requests, build the body with json.dumps(...) into a
string variable, sign that string, and pass it as data= (not json=,
which would re-serialize and may change the bytes).
Rotating your secret
When you regenerate your webhook secret from Settings → Providers → Custom E-Com, LiveRecover does not cut over instantly. It keeps the previous secret valid alongside the new one for a 24-hour grace window, so deliveries that are in flight or being retried while you roll out the new secret are not rejected.
For each incoming request, the verifier:
- Computes the expected signature with the current secret and compares
it constant-time against your
x-vyg-signature. - If that does not match, and the integration has a previous secret whose 24-hour window has not yet expired, computes the expected signature with that previous secret and compares it constant-time.
- Accepts the request if either comparison matches; otherwise returns
401.
Once the 24-hour window elapses, the previous secret is no longer tried —
signatures computed with it are rejected with 401. Only one previous
secret is held at a time: rotating again replaces it and restarts the
24-hour clock from the newest rotation.
Recommended rollout
Because the old secret stays valid for 24 hours, you can deploy the new one without a coordinated cutover:
- Click Regenerate and copy the new
webhook_secret(it is shown only once). - Deploy the new secret to your sender. Any time within the next 24 hours is safe — requests signed with the old secret still verify until then.
- After your deploy is live, confirm new requests succeed with
200 OK. The old secret expires on its own; there is nothing to remove.
Note: the API key issued alongside the webhook secret is not graced — it is invalidated immediately on rotation. The 24-hour grace window applies to the webhook secret only.