Security and signature verification.
What the signing secret protects, and how to verify it in PHP, Node.js, and Python.
The signing secret is a password only you and Reward Loyalty know. Every webhook delivery carries a signature, a code computed from the message and the secret, in the X-RewardLoyalty-Signature header. Your receiving code recomputes that same signature from the message it just received and compares the two. If they match, the message came from Reward Loyalty and nothing changed it on the way. If they do not match, reject it.
Without this check, anyone who finds your endpoint's URL could post a fake "voucher redeemed" or "points earned" event to it and have your system act on it as if it were real. The signature is what stops that.
Verifying the signature
Compute the HMAC over the exact bytes received, before any framework parses them into an object, and compare the result against the header with a constant-time comparison. Parsing the body into JSON and re-encoding it will not reproduce the same bytes, and the signature will not match.
PHP
<?php
// Read the exact bytes sent. Do not use $_POST or a re-encoded payload;
// the signature covers the raw body only.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_REWARDLOYALTY_SIGNATURE'] ?? '';
$signature = str_starts_with($header, 'sha256=') ? substr($header, 7) : $header;
$expected = base64_encode(hash_hmac('sha256', $rawBody, $webhookSecret, true));
if (! hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// $event['id'], $event['event'], $event['data'] are ready to use.
php://input gives you the message exactly as it arrived, byte for byte. That "raw body" matters because json_decode() followed by re-encoding can reorder keys or change spacing, which produces different bytes and a signature that no longer matches, even though the data is identical. Compute the signature first, on the untouched bytes, and only decode afterward. hash_equals() compares in constant time: it always takes the same amount of time regardless of where the strings first differ, so an attacker timing your responses cannot use that timing to guess the secret one character at a time.
Node.js
const crypto = require('crypto');
// rawBody must be the exact bytes received, captured before any JSON body
// parser touches the request (for example, express.raw() on this route).
function isValidSignature(rawBody, signatureHeader, secret) {
const provided = signatureHeader.startsWith('sha256=')
? signatureHeader.slice(7)
: signatureHeader;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const expectedBuffer = Buffer.from(expected, 'utf8');
const providedBuffer = Buffer.from(provided, 'utf8');
return expectedBuffer.length === providedBuffer.length
&& crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
Express parses JSON automatically by default, which throws away the raw bytes before your code ever sees them. Mount this specific route with express.raw() (or read the body before any JSON middleware runs) so rawBody is the untouched buffer, not a re-serialized object. crypto.timingSafeEqual() is the constant-time comparison: it takes the same time no matter which byte differs first, so it gives no timing clue about the secret.
Python
import base64
import hashlib
import hmac
def is_valid_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# raw_body is the exact bytes received: request.data in Flask, or
# await request.body() in FastAPI, read before any JSON parsing.
provided = signature_header[7:] if signature_header.startswith("sha256=") else signature_header
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, provided)
request.data (Flask) or await request.body() (FastAPI) hand you the raw bytes before your framework turns them into a parsed object; use those, not a dictionary you built from the parsed body. hmac.compare_digest() is the constant-time comparison built into Python's standard library, so you never need to write your own.
HTTPS and public addresses
An endpoint address must be a public HTTPS address. Plain HTTP, localhost, and private network addresses are all rejected, both when you save the endpoint and again every time an event is sent, because a URL that resolves publicly today could resolve privately tomorrow. HTTPS keeps the payload, which contains a member identifier that is a code, not a name, encrypted in transit; a public address is required because Reward Loyalty has to reach your server from the internet, the same way any other outside caller would.
Personal data and deletion notices
A webhook payload identifies members by id and unique_identifier only. That is pseudonymous, and it is still personal data: anyone holding the identifier and your systems' records can connect it to a person. Treat every system that receives your webhooks as a system that holds personal data. Secure the endpoint, limit who can read its logs, and cover the flow in your own privacy notices; the platform's data minimization does not remove your responsibilities as the receiver.
When a member deletes their data for your business, or their whole account, the platform hard-deletes their data for you and then sends member.relationship_removed to endpoints subscribed to it. The notice exists so your connected systems can delete their copy too:
- It is best effort. Deletion inside the platform completes whether or not the notice can be delivered. A paused endpoint or a network failure never delays or blocks a member's deletion.
- The stored copy is minimal and short-lived. After the notice is delivered, the platform redacts the stored payload; after a final delivery failure it redacts the payload 72 hours after the event. A notice that could never be attempted, for example because the endpoint stayed paused, keeps its minimal payload no longer than the normal 30-day delivery retention.
- Deduplicate on
removal_id. One deletion sends one notice per subscribed endpoint; retries repeat the same delivery id. - Acting on it is your job. The platform cannot verify what your systems did with the notice. You remain responsible for lawful retention and deletion of the member data your systems hold.
A privacy deletion never sends member.unenrolled; the tombstone is the only signal. See the Webhook event field reference for the event's field table and a generated example.
Rotating a secret
Suspect a secret has leaked, or just want a clean break, for example after someone who had access to it leaves? Rotate secret issues a new one immediately; the old one stops verifying straight away. See Rotating the secret for where to click and how the one-time reveal works.
Related topics
- Webhooks overview and setup: Turn on Webhooks, add an endpoint, and copy the signing secret
- Events and member synchronization: The envelope, headers, and the full event catalog
- Deliveries, retries, and troubleshooting: What happens when your endpoint rejects a delivery