← Back to documentation

HMAC Inbound Verification

Configure HMAC verification for common webhook providers and custom integrations.

4 min read

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:

  • GITHUB for GitHub webhook signatures.
  • SHOPIFY for Shopify webhook signatures.
  • LINEAR for Linear webhook signatures.
  • SLACK for Slack event subscriptions.
  • STRIPE for Stripe webhook signatures.
  • CUSTOM for 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: open Settings, then Webhooks, then Secret.
  • Shopify: open Settings, then Notifications, then Webhooks, then Signing secret.
  • Linear: open Settings, then API, then Webhooks, then Signing secret.
  • Slack: open App settings, then Event Subscriptions, then Signing Secret.
  • Stripe: open Developers, then Webhooks, then Signing 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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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):

Code Example
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:

FieldOptionsNotes
Signature headerTextThe name of the header that contains the signature, for example, X-Signature
Signature prefixTextAn optional prefix. PayloadRelay removes it before it verifies the signature. For example, sha256=
AlgorithmSHA256, SHA1, SHA512The HMAC algorithm
EncodingHEX, BASE64The signature encoding
Replay windowNot availableCustom 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.