Verifying webhook signatures

Loop signs every webhook delivery with an HMAC-SHA256 signature so you can confirm the request genuinely came from Loop and that the payload has not been tampered with in transit. This guide explains how the signature is constructed and how to verify it in your application.


Table of Contents

  1. Overview
  2. How loop signs webhooks
  3. The signature header
  4. Key rotation - multiple signatures
  5. Step-by-step verification
  6. Security best practices
  7. Code examples
  8. Webhook headers reference

Overview

When you register a webhook endpoint with Loop, you receive one or more secret keys. Loop uses each active secret key to compute an HMAC signature over the raw request body and sends all resulting signatures in a single HTTP header. Your server must:

  1. Read the raw (unparsed) request body.
  2. Compute the expected HMAC-SHA256 signature using your secret key(s).
  3. Compare your computed signature against the signatures Loop sent.
  4. Reject the request if none of the signatures match.

Important: Always use the raw, unmodified request body bytes for signature computation. Parsing the body as JSON and then re-serialising it — even if the content is logically identical — will produce a different byte sequence and cause verification to fail.


How Loop Signs Webhooks

Loop computes the signature as follows:

signature = "v1=" + HMAC-SHA256(secret_key, raw_request_body).hexdigest()
ParameterValue
AlgorithmHMAC-SHA256
InputThe raw JSON request body (UTF-8 encoded bytes)
Output encodingLowercase hexadecimal
Signature prefixv1=

The v1= prefix is a versioning scheme. If Loop ever introduces a new signing algorithm, it will use a different prefix (e.g. v2=) so you can detect and handle it independently without breaking existing verification logic.


The Signature Header

Loop sends all computed signatures in the X-Loop-Webhook-Signature HTTP header. When a store has a single active secret key the header looks like:

X-Loop-Webhook-Signature: v1=3b2d6a1f9c4e8b0d5a7f2e1c3d9b4a8f0e6c2d7b1a5f9e3c8d0b4a2f7e1c6d

Key Rotation — Multiple Signatures

A store may have up to three active secret keys at the same time. This is intentional: it lets you rotate your secret key without downtime by keeping the old key active while you deploy the new one.

When multiple secret keys are active, Loop signs the payload with every active key and sends all signatures in the same header, separated by commas:

X-Loop-Webhook-Signature: v1=3b2d6a1f9c4e8b0d...,v1=9f1a3c7e2b4d8f0a...

Your verification logic must succeed if any one of the signatures in the header matches any one of your known secret keys. This means:

  • Split the header value on , to get individual signature tokens.
  • For each of your active secret keys, compute the expected signature.
  • The request is authentic if at least one token matches at least one of your computed signatures.

This design ensures zero-downtime key rotation:

  1. Generate a new secret key in Loop — both old and new keys are now active.
  2. Deploy your updated application that knows both keys.
  3. Delete the old secret key from Loop.

Step-by-Step Verification

1. Capture the raw request body

You must buffer the raw bytes before any JSON parsing middleware touches the body. Most frameworks make this available as a raw bytes option — see the code examples below for how to do this in each language.

2. Read the signature header

X-Loop-Webhook-Signature: v1=<hex>,v1=<hex>,...

If the header is absent, Loop does not have an active secret key configured for your store. Whether you accept unsigned webhooks is your choice, but rejecting them is strongly recommended.

3. Split and filter by version prefix

Split the header value on ,. Only process tokens that start with v1= — ignore any tokens with an unrecognised prefix so you remain forward-compatible when Loop introduces new signing versions.

4. Compute your expected signature

For each of your active secret keys, compute:

expected = "v1=" + HMAC-SHA256(key, raw_body).hexdigest()

5. Compare using a constant-time function

Use a constant-time (timing-safe) comparison when checking whether any expected value matches any token from the header. Do not use a regular equality operator (== / ===) — it short-circuits on the first differing byte and leaks information about how close the guess was.

6. Accept or reject

  • Match found → the webhook is authentic; proceed with processing.
  • No match → return HTTP 401 or HTTP 403 and do not process the payload.

Security Best Practices

PracticeWhy
Always use the raw body bytes for HMAC inputParsing and re-serialising alters whitespace and key ordering, breaking the signature
Use a constant-time comparison functionPrevents timing attacks that could allow an attacker to guess the signature byte-by-byte
Serve your webhook endpoint over HTTPS onlyProtects the payload and headers from interception
Reject requests with a missing or empty signature headerUnsigned requests should never be trusted in production
Rotate your secret keys periodicallyLimits the blast radius if a key is ever exposed
Return 2xx only after you have verified the signatureLoop retries on non-2xx responses; verify first, then process

Code Examples

Node.js (built-in crypto)

const http = require('http');
const crypto = require('crypto');

const SECRET_KEYS = [process.env.LOOP_WEBHOOK_SECRET]; // add more keys during rotation

function verifyLoopWebhook(rawBody, signatureHeader) {
  if (!signatureHeader) return false;

  const receivedSignatures = signatureHeader
    .split(',')
    .map(s => s.trim())
    .filter(s => s.startsWith('v1='));

  for (const secret of SECRET_KEYS) {
    const expected = 'v1=' + crypto
      .createHmac('sha256', secret)
      .update(rawBody, 'utf8')
      .digest('hex');

    for (const received of receivedSignatures) {
      const expectedBuf = Buffer.from(expected);
      const receivedBuf = Buffer.from(received);
      if (
        expectedBuf.length === receivedBuf.length &&
        crypto.timingSafeEqual(expectedBuf, receivedBuf)
      ) {
        return true;
      }
    }
  }

  return false;
}

const server = http.createServer((req, res) => {
  if (req.method !== 'POST') {
    res.writeHead(405).end();
    return;
  }

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks).toString('utf8');
    const signatureHeader = req.headers['x-loop-webhook-signature'];

    if (!verifyLoopWebhook(rawBody, signatureHeader)) {
      res.writeHead(401, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid signature' }));
      return;
    }

    // Safe to parse and process the body
    const payload = JSON.parse(rawBody);
    console.log('Verified webhook:', payload.metaData?.source);

    res.writeHead(200).end();
  });
});

server.listen(3000, () => console.log('Listening on :3000'));

Express.js

Key requirement: You must use express.raw() (or express.text()) — not express.json() — on the webhook route so the raw body bytes are preserved.

const express = require('express');
const crypto = require('crypto');

const app = express();

const SECRET_KEYS = [process.env.LOOP_WEBHOOK_SECRET]; // add more keys during rotation

function verifyLoopWebhook(rawBody, signatureHeader) {
  if (!signatureHeader) return false;

  const receivedSignatures = signatureHeader
    .split(',')
    .map(s => s.trim())
    .filter(s => s.startsWith('v1='));

  for (const secret of SECRET_KEYS) {
    const expected = 'v1=' + crypto
      .createHmac('sha256', secret)
      .update(rawBody) // rawBody is already a Buffer when using express.raw()
      .digest('hex');

    for (const received of receivedSignatures) {
      const expectedBuf = Buffer.from(expected);
      const receivedBuf = Buffer.from(received);
      if (
        expectedBuf.length === receivedBuf.length &&
        crypto.timingSafeEqual(expectedBuf, receivedBuf)
      ) {
        return true;
      }
    }
  }

  return false;
}

// Use express.raw() to preserve the raw body bytes
app.post('/webhooks/loop', express.raw({ type: 'application/json' }), (req, res) => {
  const signatureHeader = req.headers['x-loop-webhook-signature'];

  if (!verifyLoopWebhook(req.body, signatureHeader)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Safe to parse now
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log('Verified webhook topic:', req.headers['x-loop-webhook-topic']);

  res.sendStatus(200);
});

app.listen(3000, () => console.log('Listening on :3000'));

Python — Flask

import hashlib
import hmac
import os
from flask import Flask, request, abort

app = Flask(__name__)

# Add more keys during key rotation
SECRET_KEYS = [os.environ['LOOP_WEBHOOK_SECRET'].encode('utf-8')]


def verify_loop_webhook(raw_body: bytes, signature_header: str) -> bool:
    if not signature_header:
        return False

    received_signatures = [
        s.strip() for s in signature_header.split(',')
        if s.strip().startswith('v1=')
    ]

    for secret in SECRET_KEYS:
        expected = 'v1=' + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()

        for received in received_signatures:
            if hmac.compare_digest(expected, received):
                return True

    return False


@app.route('/webhooks/loop', methods=['POST'])
def loop_webhook():
    raw_body = request.get_data()  # raw bytes, before any parsing
    signature_header = request.headers.get('X-Loop-Webhook-Signature', '')

    if not verify_loop_webhook(raw_body, signature_header):
        abort(401)

    payload = request.get_json(force=True)
    print('Verified webhook topic:', request.headers.get('X-Loop-Webhook-Topic'))

    return '', 200


if __name__ == '__main__':
    app.run(port=3000)

Python — Django

# views.py
import hashlib
import hmac
import json
import os

from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

# Add more keys during key rotation
SECRET_KEYS = [os.environ['LOOP_WEBHOOK_SECRET'].encode('utf-8')]


def verify_loop_webhook(raw_body: bytes, signature_header: str) -> bool:
    if not signature_header:
        return False

    received_signatures = [
        s.strip() for s in signature_header.split(',')
        if s.strip().startswith('v1=')
    ]

    for secret in SECRET_KEYS:
        expected = 'v1=' + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()

        for received in received_signatures:
            if hmac.compare_digest(expected, received):
                return True

    return False


@csrf_exempt
@require_POST
def loop_webhook(request):
    raw_body = request.body  # Django exposes the raw body here
    signature_header = request.headers.get('X-Loop-Webhook-Signature', '')

    if not verify_loop_webhook(raw_body, signature_header):
        return HttpResponseForbidden('Invalid signature')

    payload = json.loads(raw_body)
    print('Verified webhook topic:', request.headers.get('X-Loop-Webhook-Topic'))

    return HttpResponse(status=200)
# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('webhooks/loop/', views.loop_webhook),
]

Go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

// secretKeys holds all active secret keys.
// Add more keys here during key rotation.
var secretKeys = []string{os.Getenv("LOOP_WEBHOOK_SECRET")}

func verifyLoopWebhook(rawBody []byte, signatureHeader string) bool {
	if signatureHeader == "" {
		return false
	}

	var receivedSignatures []string
	for _, s := range strings.Split(signatureHeader, ",") {
		s = strings.TrimSpace(s)
		if strings.HasPrefix(s, "v1=") {
			receivedSignatures = append(receivedSignatures, s)
		}
	}

	for _, secret := range secretKeys {
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(rawBody)
		expected := "v1=" + hex.EncodeToString(mac.Sum(nil))

		for _, received := range receivedSignatures {
			// Use hmac.Equal for constant-time comparison
			if hmac.Equal([]byte(expected), []byte(received)) {
				return true
			}
		}
	}

	return false
}

func loopWebhookHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read body", http.StatusInternalServerError)
		return
	}
	defer r.Body.Close()

	signatureHeader := r.Header.Get("X-Loop-Webhook-Signature")
	if !verifyLoopWebhook(rawBody, signatureHeader) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	topic := r.Header.Get("X-Loop-Webhook-Topic")
	fmt.Printf("Verified webhook topic: %s\n", topic)

	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhooks/loop", loopWebhookHandler)
	fmt.Println("Listening on :3000")
	http.ListenAndServe(":3000", nil)
}

Ruby — Sinatra

require 'sinatra'
require 'openssl'

# Add more keys during key rotation
SECRET_KEYS = [ENV.fetch('LOOP_WEBHOOK_SECRET')]

def verify_loop_webhook(raw_body, signature_header)
  return false if signature_header.nil? || signature_header.empty?

  received_signatures = signature_header
    .split(',')
    .map(&:strip)
    .select { |s| s.start_with?('v1=') }

  SECRET_KEYS.each do |secret|
    digest = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
    expected = "v1=#{digest}"

    received_signatures.each do |received|
      # ActiveSupport::SecurityUtils.secure_compare or manual constant-time check
      return true if Rack::Utils.secure_compare(expected, received)
    end
  end

  false
end

post '/webhooks/loop' do
  request.body.rewind
  raw_body = request.body.read

  signature_header = request.env['HTTP_X_LOOP_WEBHOOK_SIGNATURE']

  unless verify_loop_webhook(raw_body, signature_header)
    halt 401, { error: 'Invalid signature' }.to_json
  end

  payload = JSON.parse(raw_body)
  puts "Verified webhook topic: #{request.env['HTTP_X_LOOP_WEBHOOK_TOPIC']}"

  status 200
end

Ruby — Rails

# app/controllers/webhooks_controller.rb
class WebhooksController < ActionController::API
  # Skip Rails' default CSRF protection; we do our own verification
  skip_before_action :verify_authenticity_token

  # Add more keys during key rotation
  SECRET_KEYS = [ENV.fetch('LOOP_WEBHOOK_SECRET')].freeze

  def loop
    raw_body = request.body.read
    signature_header = request.headers['X-Loop-Webhook-Signature']

    unless verify_signature(raw_body, signature_header)
      render json: { error: 'Invalid signature' }, status: :unauthorized
      return
    end

    payload = JSON.parse(raw_body)
    Rails.logger.info "Verified webhook topic: #{request.headers['X-Loop-Webhook-Topic']}"

    head :ok
  end

  private

  def verify_signature(raw_body, signature_header)
    return false if signature_header.blank?

    received_signatures = signature_header
      .split(',')
      .map(&:strip)
      .select { |s| s.start_with?('v1=') }

    SECRET_KEYS.each do |secret|
      digest = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
      expected = "v1=#{digest}"

      received_signatures.each do |received|
        return true if ActiveSupport::SecurityUtils.secure_compare(expected, received)
      end
    end

    false
  end
end
# config/routes.rb
Rails.application.routes.draw do
  post '/webhooks/loop', to: 'webhooks#loop'
end

Rails body parsing note: Rails buffers the raw body in request.body before routing, so request.body.read gives you the original bytes. However, if you have middleware that consumes the body stream, call request.body.rewind before reading.


Webhook Headers Reference

Every Loop webhook delivery includes the following HTTP headers:

HeaderDescriptionExample
X-Loop-Webhook-IdUnique identifier (UUID) for this webhook deliverya1b2c3d4-e5f6-...
X-Loop-Webhook-Api-VersionAPI version used to format the payload2022-10
X-Loop-Webhook-TopicEvent topic that triggered this deliverysubscription/created
X-Loop-Webhook-Created-AtISO 8601 timestamp when the webhook was first created2026-03-19T10:00:00.000Z
X-Loop-Webhook-Delivery-AtISO 8601 timestamp of this specific delivery attempt2026-03-19T10:00:05.000Z
X-Loop-Webhook-Retry-CountNumber of previous delivery attempts (0 on first attempt)0
X-Loop-Webhook-SignatureComma-separated HMAC-SHA256 signatures (v1=...)v1=3b2d6a1f...,v1=9f1a3c...

X-Loop-Webhook-Signature is only present when at least one active secret key is configured for your store.


Payload Structure

Every webhook payload is a JSON object with the following top-level fields:

{
  "payload": { },
  "metaData": {
    "myshopifyDomain": "your-store.myshopify.com",
    "source": "loop",
    "entity": "subscription"
  },
  "previousPayload": { }
}
FieldDescription
payloadThe current state of the resource that triggered the event
metaDataContextual information about the store and event source
previousPayloadThe previous state of the resource (where applicable)

The HMAC signature is computed over the entire serialised JSON object (all three top-level fields).