LiveRecoverVYG Developer Docs
Custom IntegrationWebhooks

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

  1. Take the exact bytes of the JSON body you are about to send. Do not pretty-print, re-stringify, or normalize whitespace after signing.
  2. Compute HMAC-SHA256(secret, body).
  3. Hex-encode the result, lowercase.
  4. Send it in the x-vyg-signature header.

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:
  d9eea748ce5eff60b7f4edea5da4488ef750296f9ca6c145ea95b0337e580c6b

cURL + 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:

  1. Computes the expected signature with the current secret and compares it constant-time against your x-vyg-signature.
  2. 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.
  3. 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.

Because the old secret stays valid for 24 hours, you can deploy the new one without a coordinated cutover:

  1. Click Regenerate and copy the new webhook_secret (it is shown only once).
  2. 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.
  3. 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.

On this page