HMAC Inbound Verification
Configure HMAC verification for common webhook providers and custom integrations.
Use this guide to protect an inbound relay endpoint with HMAC signature verification.
Purpose#
Use this guide to:
- Understand how HMAC verification blocks a forged request.
- Configure HMAC verification for GitHub, Shopify, Linear, Slack, or Stripe.
- Configure a custom HMAC scheme.
- Find the cause of a signature mismatch with the sample code.
What HMAC verification protects against#
Without signature verification, anyone who knows your endpoint URL can send a request that looks like a provider request. An HMAC signature shows that the sender knows the shared secret.
When you enable this setting, PayloadRelay verifies the signature of every inbound request. PayloadRelay rejects a request that has:
- No signature header, with
AUTH_FAILED(HTTP 401). - A signature that does not match, with
AUTH_FAILED(HTTP 401). - An expired timestamp for a Slack or Stripe preset, with
AUTH_FAILED(HTTP 401).
The provider must sign the exact raw request body bytes. For a request with no body, the signed body is empty. PayloadRelay does not include the query string in the HMAC input.
Before you start#
- Make sure that you can edit the endpoint.
- Make sure that you can access the provider secret or signing key.
Procedure#
1. Select a preset or custom configuration#
Open the endpoint edit page. Select the Security tab, then select Inbound authentication.
Select HMAC as the authentication type. Select one of these provider presets:
GITHUBfor GitHub webhook signatures.SHOPIFYfor Shopify webhook signatures.LINEARfor Linear webhook signatures.SLACKfor Slack event subscriptions.STRIPEfor Stripe webhook signatures.CUSTOMfor a generic HMAC configuration with manual header, algorithm, and encoding settings.
Each preset sets the signature header, algorithm, encoding, and timestamp handling.
2. Enter the HMAC secret#
Enter the secret key from the webhook provider:
GitHub: openSettings, thenWebhooks, thenSecret.Shopify: openSettings, thenNotifications, thenWebhooks, thenSigning secret.Linear: openSettings, thenAPI, thenWebhooks, thenSigning secret.Slack: openApp settings, thenEvent Subscriptions, thenSigning Secret.Stripe: openDevelopers, thenWebhooks, thenSigning secret.
PayloadRelay stores the secret securely. The secret is not shown after you save it.
3. Save the endpoint and send a test webhook#
After you save the endpoint, send a test webhook from the provider. Open Activity and look for Completed (ACCEPTED). If the result is Auth Failed (AUTH_FAILED), make sure that:
- The secret has no extra spaces.
- The provider uses the correct endpoint URL.
- For Slack or Stripe, the timestamp is in the replay window. The default window is 300 seconds.
Preset reference#
GITHUB#
- Header:
X-Hub-Signature-256 - Signature scheme:
sha256=<hex_hmac_sha256(secret, body)> - Algorithm: HMAC-SHA256
- Encoding: Hex
- Replay protection: None
Verification recipe (Node.js):
const crypto = require('crypto');
const secret = 'YOUR_HMAC_SECRET';
const body = '{"action":"opened"}'; // raw request body
const signature = req.headers['x-hub-signature-256'];
function signaturesMatch(received, expected) {
if (typeof received !== 'string') return false;
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
const computed = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
if (signaturesMatch(signature, computed)) {
console.log('Valid signature');
} else {
console.log('Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
secret = b'YOUR_HMAC_SECRET'
body = b'{"action":"opened"}'
signature = request.headers.get('X-Hub-Signature-256')
computed = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest()
if isinstance(signature, str) and hmac.compare_digest(signature, computed):
print('Valid signature')
else:
print('Invalid signature')SHOPIFY#
- Header:
X-Shopify-Hmac-Sha256 - Signature scheme:
<base64_hmac_sha256(secret, body)> - Algorithm: HMAC-SHA256
- Encoding: Base64
- Replay protection: None
Verification recipe (Node.js):
const crypto = require('crypto');
const secret = 'YOUR_HMAC_SECRET';
const body = '{"id":123}';
const signature = req.headers['x-shopify-hmac-sha256'];
function signaturesMatch(received, expected) {
if (typeof received !== 'string') return false;
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('base64');
if (signaturesMatch(signature, computed)) {
console.log('Valid signature');
} else {
console.log('Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
import base64
secret = b'YOUR_HMAC_SECRET'
body = b'{"id":123}'
signature = request.headers.get('X-Shopify-Hmac-Sha256')
computed = base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode()
if isinstance(signature, str) and hmac.compare_digest(signature, computed):
print('Valid signature')
else:
print('Invalid signature')LINEAR#
- Header:
Linear-Signature - Signature scheme:
<hex_hmac_sha256(secret, body)> - Algorithm: HMAC-SHA256
- Encoding: Hex
- Replay protection: None
Verification recipe (Node.js):
const crypto = require('crypto');
const secret = 'YOUR_HMAC_SECRET';
const body = '{"type":"Issue"}';
const signature = req.headers['linear-signature'];
function signaturesMatch(received, expected) {
if (typeof received !== 'string') return false;
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
if (signaturesMatch(signature, computed)) {
console.log('Valid signature');
} else {
console.log('Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
secret = b'YOUR_HMAC_SECRET'
body = b'{"type":"Issue"}'
signature = request.headers.get('Linear-Signature')
computed = hmac.new(secret, body, hashlib.sha256).hexdigest()
if isinstance(signature, str) and hmac.compare_digest(signature, computed):
print('Valid signature')
else:
print('Invalid signature')SLACK#
- Headers:
X-Slack-Signature,X-Slack-Request-Timestamp - Signature scheme:
v0=<hex_hmac_sha256(secret, "v0:" + timestamp + ":" + body)> - Algorithm: HMAC-SHA256
- Encoding: Hex
- Replay protection: Enforced with a timestamp window
Slack includes a timestamp in the signed value. PayloadRelay accepts the request only when the timestamp is within the configured window, which is 5 minutes by default.
Verification recipe (Node.js):
const crypto = require('crypto');
const secret = 'YOUR_HMAC_SECRET';
const body = '{"type":"event_callback"}';
const timestamp = req.headers['x-slack-request-timestamp'];
const signature = req.headers['x-slack-signature'];
function signaturesMatch(received, expected) {
if (typeof received !== 'string') return false;
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
const timestampInt = typeof timestamp === 'string' && /^\d+$/.test(timestamp)
? Number(timestamp)
: NaN;
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestampInt) || Math.abs(now - timestampInt) > 300) {
console.log('Timestamp missing or out of window');
} else {
const sigBasestring = `v0:${timestamp}:${body}`;
const computed = 'v0=' + crypto
.createHmac('sha256', secret)
.update(sigBasestring, 'utf8')
.digest('hex');
console.log(signaturesMatch(signature, computed) ? 'Valid signature' : 'Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
import time
secret = b'YOUR_HMAC_SECRET'
body = b'{"type":"event_callback"}'
timestamp = request.headers.get('X-Slack-Request-Timestamp')
signature = request.headers.get('X-Slack-Signature')
timestamp_int = int(timestamp) if (
isinstance(timestamp, str) and timestamp.isascii() and timestamp.isdecimal()
) else None
now = int(time.time())
if timestamp_int is None or abs(now - timestamp_int) > 300:
print('Timestamp missing or out of window')
else:
sig_basestring = b'v0:' + timestamp.encode('ascii') + b':' + body
computed = 'v0=' + hmac.new(secret, sig_basestring, hashlib.sha256).hexdigest()
print('Valid signature' if isinstance(signature, str) and hmac.compare_digest(signature, computed) else 'Invalid signature')STRIPE#
- Header:
Stripe-Signature - Signature scheme:
t=<unix_seconds>,v1=<hex_hmac_sha256(secret, timestamp + "." + body)> - Algorithm: HMAC-SHA256
- Encoding: Hex
- Replay protection: Enforced with a timestamp window that defaults to 300 seconds
Stripe puts the timestamp in the signature header. PayloadRelay reads the timestamp, verifies the signature, and applies a 5-minute replay window.
Verification recipe (Node.js):
const crypto = require('crypto');
const secret = 'YOUR_HMAC_SECRET';
const body = '{"id":"evt_123"}';
const sigHeader = req.headers['stripe-signature'];
function signaturesMatch(received, expected) {
if (typeof received !== 'string') return false;
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
const parts = {};
if (typeof sigHeader === 'string') {
for (const part of sigHeader.split(',')) {
const separator = part.indexOf('=');
if (separator > 0) {
parts[part.slice(0, separator)] = part.slice(separator + 1);
}
}
}
const timestamp = parts.t;
const receivedSig = parts.v1;
const timestampInt = typeof timestamp === 'string' && /^\d+$/.test(timestamp)
? Number(timestamp)
: NaN;
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestampInt) || Math.abs(now - timestampInt) > 300) {
console.log('Timestamp missing or out of window');
} else {
const sigPayload = `${timestamp}.${body}`;
const computed = crypto
.createHmac('sha256', secret)
.update(sigPayload, 'utf8')
.digest('hex');
console.log(signaturesMatch(receivedSig, computed) ? 'Valid signature' : 'Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
import time
secret = b'YOUR_HMAC_SECRET'
body = b'{"id":"evt_123"}'
sig_header = request.headers.get('Stripe-Signature', '')
parts = {}
for item in sig_header.split(','):
key, separator, value = item.partition('=')
if separator:
parts[key] = value
timestamp = parts.get('t')
received_sig = parts.get('v1')
timestamp_int = int(timestamp) if (
isinstance(timestamp, str) and timestamp.isascii() and timestamp.isdecimal()
) else None
now = int(time.time())
if timestamp_int is None or abs(now - timestamp_int) > 300:
print('Timestamp missing or out of window')
else:
sig_payload = timestamp.encode('ascii') + b'.' + body
computed = hmac.new(secret, sig_payload, hashlib.sha256).hexdigest()
print('Valid signature' if isinstance(received_sig, str) and hmac.compare_digest(received_sig, computed) else 'Invalid signature')CUSTOM#
If no preset matches your provider, select CUSTOM. Configure these fields:
| Field | Options | Notes |
|---|---|---|
Signature header | Text | The name of the header that contains the signature, for example, X-Signature |
Signature prefix | Text | An optional prefix. PayloadRelay removes it before it verifies the signature. For example, sha256= |
Algorithm | SHA256, SHA1, SHA512 | The HMAC algorithm |
Encoding | HEX, BASE64 | The signature encoding |
Replay window | Not available | Custom HMAC has no setting for a timestamp header. Use the Slack or Stripe preset for timestamp replay protection. |
With a custom configuration, you set each parameter.
Common issues and fixes#
- Signature mismatch: Make sure that the secret is exact. Sign the raw request body, not parsed JSON.
- Missing header: Make sure that the provider sends the expected header name. PayloadRelay treats header names as case-insensitive.
- Replay window exceeded for Slack or Stripe: Synchronize the server clock. Then send a new request with a new provider signature.
- Encoding mismatch: Make sure that the provider uses hexadecimal or Base64 encoding.