← Back to documentation

HMAC Inbound Verification

Configure HMAC signature validation for common webhook providers and custom integrations.

12 min read

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 signatures
  • SHOPIFY — Shopify webhook signatures
  • LINEAR — Linear webhook signatures
  • SLACK — Slack event subscriptions
  • STRIPE — Stripe webhook signatures
  • CUSTOM — 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):

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'];

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

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

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

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

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

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

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'];

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

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

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

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

FieldOptionsNotes
Signature headerTextHeader name containing the signature (e.g., X-Signature)
Signature prefixTextOptional prefix stripped before validation (e.g., sha256=)
AlgorithmSHA256, SHA1, SHA512HMAC algorithm
EncodingHEX, BASE64Signature encoding
Replay windowNot 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.

Related guides