HMAC Inbound Verification
Configure HMAC signature validation for common webhook providers and custom integrations.
Use this guide to secure inbound relay endpoints with HMAC signature verification.
Purpose
This guide helps you:
- Understand why HMAC verification protects against spoofed requests.
- Configure HMAC validation for common webhook providers (GitHub, Shopify, Linear, Slack, Stripe).
- Set up custom HMAC schemes with flexible algorithm/encoding choices.
- Debug signature mismatches with pasteable verification code.
What HMAC verification protects against
Without signature verification, anyone who discovers your endpoint URL can send arbitrary payloads pretending to be your webhook provider. HMAC signatures prove that a request was signed by someone who knows the shared secret.
When enabled, PayloadRelay validates the signature on every inbound request and rejects requests with:
- Missing signature headers →
AUTH_FAILED(HTTP 401) - Mismatched signatures →
AUTH_FAILED(HTTP 401) - Expired timestamps (Slack/Stripe presets) →
AUTH_FAILED(HTTP 401)
Sign the exact raw request body bytes. For no-body methods or bodyless requests, the signed body is empty; query strings are not included in the HMAC input.
Prerequisites and permissions
- Endpoint edit access.
- Access to the webhook provider's secret/signing key configuration.
Step-by-step workflow
1. Choose a preset or custom configuration
Navigate to the endpoint edit page → Security tab → Inbound authentication section.
Select HMAC as the auth type, then choose a provider preset:
GITHUB— GitHub webhook signaturesSHOPIFY— Shopify webhook signaturesLINEAR— Linear webhook signaturesSLACK— Slack event subscriptionsSTRIPE— Stripe webhook signaturesCUSTOM— Generic HMAC with manual header/algorithm/encoding configuration
Each preset automatically configures the signature header, algorithm, encoding, and timestamp handling.
2. Provide the HMAC secret
Enter the secret key provided by your webhook source:
- GitHub: Settings → Webhooks → Secret
- Shopify: Settings → Notifications → Webhooks → Signing secret
- Linear: Settings → API → Webhooks → Signing secret
- Slack: App settings → Event Subscriptions → Signing Secret
- Stripe: Developers → Webhooks → Signing secret
The secret is stored securely and is not shown again after saving.
3. Save and test
After saving, send a test webhook from the provider. Check Activity to confirm ACCEPTED outcome. If you see AUTH_FAILED, verify:
- The secret is copied exactly (no extra whitespace).
- The provider is sending to the correct endpoint URL.
- For Slack/Stripe, the timestamp is within the replay window (default 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'];
const computed = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(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['X-Hub-Signature-256']
computed = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest()
if 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'];
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('base64');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(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['X-Shopify-Hmac-Sha256']
computed = base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode()
if 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'];
const computed = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(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['Linear-Signature']
computed = hmac.new(secret, body, hashlib.sha256).hexdigest()
if 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's scheme includes a timestamp in the signed payload to prevent replay attacks. PayloadRelay validates that the timestamp is within the configured window (default 5 minutes).
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'];
const sigBasestring = `v0:${timestamp}:${body}`;
const computed = 'v0=' + crypto
.createHmac('sha256', secret)
.update(sigBasestring, 'utf8')
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(computed))) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
console.log('Timestamp out of window');
} else {
console.log('Valid signature');
}
} else {
console.log('Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
import time
secret = b'YOUR_HMAC_SECRET'
body = b'{"type":"event_callback"}'
timestamp = request.headers['X-Slack-Request-Timestamp']
signature = request.headers['X-Slack-Signature']
sig_basestring = f'v0:{timestamp}:{body.decode()}'.encode()
computed = 'v0=' + hmac.new(secret, sig_basestring, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, computed):
now = int(time.time())
if abs(now - int(timestamp)) > 300:
print('Timestamp out of window')
else:
print('Valid signature')
else:
print('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 (defaults to 300 seconds)
Stripe embeds the timestamp in the signature header itself. PayloadRelay extracts the timestamp, validates the signature, and enforces 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'];
const parts = sigHeader.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = value;
return acc;
}, {});
const timestamp = parts.t;
const receivedSig = parts.v1;
const sigPayload = `${timestamp}.${body}`;
const computed = crypto
.createHmac('sha256', secret)
.update(sigPayload, 'utf8')
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(computed))) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
console.log('Timestamp out of window');
} else {
console.log('Valid signature');
}
} else {
console.log('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['Stripe-Signature']
parts = dict(item.split('=') for item in sig_header.split(','))
timestamp = parts['t']
received_sig = parts['v1']
sig_payload = f'{timestamp}.{body.decode()}'.encode()
computed = hmac.new(secret, sig_payload, hashlib.sha256).hexdigest()
if hmac.compare_digest(received_sig, computed):
now = int(time.time())
if abs(now - int(timestamp)) > 300:
print('Timestamp out of window')
else:
print('Valid signature')
else:
print('Invalid signature')CUSTOM
When no preset matches your provider, choose CUSTOM and configure:
| Field | Options | Notes |
|---|---|---|
Signature header | Text | Header name containing the signature (e.g., X-Signature) |
Signature prefix | Text | Optional prefix stripped before validation (e.g., sha256=) |
Algorithm | SHA256, SHA1, SHA512 | HMAC algorithm |
Encoding | HEX, BASE64 | Signature encoding |
Replay window | — | Not supported for custom HMAC because there is no custom timestamp-header setting. Use Slack or Stripe presets for timestamp replay protection. |
Custom configurations support the same signature schemes as the presets, but you control every parameter.
Common issues and fixes
- Signature mismatch: Verify the secret is exact. Ensure you're signing the raw request body, not parsed JSON.
- Missing header: Confirm the provider is sending the expected header name (case-insensitive in PayloadRelay).
- Replay window exceeded (Slack/Stripe): Ensure your server clock is synchronized and resend with a fresh provider signature.
- Encoding mismatch: Double-check whether the provider uses hex or base64 encoding.