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
- Overview
- How loop signs webhooks
- The signature header
- Key rotation - multiple signatures
- Step-by-step verification
- Security best practices
- Code examples
- 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:
- Read the raw (unparsed) request body.
- Compute the expected HMAC-SHA256 signature using your secret key(s).
- Compare your computed signature against the signatures Loop sent.
- 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()
| Parameter | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Input | The raw JSON request body (UTF-8 encoded bytes) |
| Output encoding | Lowercase hexadecimal |
| Signature prefix | v1= |
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:
- Generate a new secret key in Loop — both old and new keys are now active.
- Deploy your updated application that knows both keys.
- 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 401orHTTP 403and do not process the payload.
Security Best Practices
| Practice | Why |
|---|---|
| Always use the raw body bytes for HMAC input | Parsing and re-serialising alters whitespace and key ordering, breaking the signature |
| Use a constant-time comparison function | Prevents timing attacks that could allow an attacker to guess the signature byte-by-byte |
| Serve your webhook endpoint over HTTPS only | Protects the payload and headers from interception |
| Reject requests with a missing or empty signature header | Unsigned requests should never be trusted in production |
| Rotate your secret keys periodically | Limits the blast radius if a key is ever exposed |
Return 2xx only after you have verified the signature | Loop retries on non-2xx responses; verify first, then process |
Code Examples
Node.js (built-in crypto)
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()(orexpress.text()) — notexpress.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
endRuby — 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'
endRails body parsing note: Rails buffers the raw body in
request.bodybefore routing, sorequest.body.readgives you the original bytes. However, if you have middleware that consumes the body stream, callrequest.body.rewindbefore reading.
Webhook Headers Reference
Every Loop webhook delivery includes the following HTTP headers:
| Header | Description | Example |
|---|---|---|
X-Loop-Webhook-Id | Unique identifier (UUID) for this webhook delivery | a1b2c3d4-e5f6-... |
X-Loop-Webhook-Api-Version | API version used to format the payload | 2022-10 |
X-Loop-Webhook-Topic | Event topic that triggered this delivery | subscription/created |
X-Loop-Webhook-Created-At | ISO 8601 timestamp when the webhook was first created | 2026-03-19T10:00:00.000Z |
X-Loop-Webhook-Delivery-At | ISO 8601 timestamp of this specific delivery attempt | 2026-03-19T10:00:05.000Z |
X-Loop-Webhook-Retry-Count | Number of previous delivery attempts (0 on first attempt) | 0 |
X-Loop-Webhook-Signature | Comma-separated HMAC-SHA256 signatures (v1=...) | v1=3b2d6a1f...,v1=9f1a3c... |
X-Loop-Webhook-Signatureis 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": { }
}| Field | Description |
|---|---|
payload | The current state of the resource that triggered the event |
metaData | Contextual information about the store and event source |
previousPayload | The previous state of the resource (where applicable) |
The HMAC signature is computed over the entire serialised JSON object (all three top-level fields).