Webhooks — Signature Verification & Responding¶
Last updated October 20, 2026
Verifying the signature¶
Compute HMAC-SHA256(secret, raw_request_body), hex-encode it, prefix it with sha256=, and compare against X-Omnishelf-Signature using a constant-time comparison. Reject with 401 on mismatch.
secret is the signing secret of the webhook the delivery was sent for. Each webhook has its own, so a delivery verifies only with that one. With several webhooks, give each its own callback URL and keep a secret per URL; if two webhooks share a URL, accept a delivery that verifies with either of their secrets.
Sign the raw bytes, never a re-serialized body
This is the mistake nearly every integrator makes once. If your framework parses the JSON for you and you then re-serialize it to compute the HMAC, key order, whitespace and number formatting will differ from what Omnishelf signed — and every single signature will fail, with no clue as to why.
Capture the raw bytes before any parsing: request.get_data() in Flask, express.raw() in Express, php://input in PHP. Parse the JSON only after the signature has checked out.
Python (Flask)¶
import hmac, hashlib, os
# The signing secret of the webhook registered for this URL. Each webhook
# has its own secret; with several webhooks, keep one secret per URL.
SECRET = os.environ["OMNISHELF_WEBHOOK_SECRET"].encode()
def verify(raw_body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
from flask import Flask, request
app = Flask(__name__)
@app.post("/omnishelf/webhooks")
def receive():
raw = request.get_data() # raw bytes — do NOT use request.json here
if not verify(raw, request.headers.get("X-Omnishelf-Signature", "")):
return "", 401
event = request.get_json()
enqueue(event["event_id"], event) # process asynchronously
return "", 200
Responding¶
Answer 2xx within 10 seconds. Omnishelf aborts the request at 10 seconds and treats the timeout as a failed attempt. Any status outside 200–299 is a failure.
Acknowledge first, process afterwards. Verify the signature, put the event on a queue, return 200. Never do label fetching, database work or third-party calls inside the request.
Be idempotent. The same event_id can legitimately arrive more than once — a retry after your 200 was lost in transit, for example. Store processed event_id values and discard duplicates.
Do not rely on ordering. Events are dispatched independently. A cancelled event can reach you before you finished processing the created one.
Response bodies are captured for debugging (first 2000 characters), so a short plain-text reason on a failure helps Omnishelf staff help you.