Skip to main content

Glossary

Webhook signature

How an endpoint anyone on the internet can POST to tells real deliveries from forged ones.

By · Last updated: September 2026

TL;DR

A webhook signature is a hash-based message authentication code (HMAC) that the sender computes over a webhook's body with a secret shared with the receiver, and sends in a header. The receiver recomputes it; if the two match, the request came from the sender and was not altered on the way.

Free plan, no credit card. 1,000 credits a month.

How it works

How signing works

A webhook URL has to accept unauthenticated POSTs from the internet, so anyone who learns it can send it data. Signing fixes that. Sender and receiver share a secret. For each delivery the sender computes HMAC-SHA256(secret, message) and sends the hex digest in a header; the receiver computes the same thing and compares.

Two details make it safe. The timestamp is part of the signed message, so an old delivery replayed later fails a freshness check. The comparison is constant-time (hmac.compare_digest, crypto.timingSafeEqual), so the time it takes leaks nothing about the expected value. And it must run over the raw body: parse the JSON and re-serialise it, and key order or whitespace changes the digest.

URLpipe

URLpipe's scheme

Signing is enabled per project under Settings → Webhook Signing. Each signed delivery carries X-URLpipe-Timestamp (Unix seconds) and X-URLpipe-Signature, of the form v1=<hex>, computed over <timestamp>.<raw body>. During a secret rotation the header carries two comma-separated signatures for 24 hours, so accept the delivery if any one matches.

Verify a delivery (Flask)
import hashlib, hmac, os, time
from flask import request, abort

SECRET = os.environ["URLPIPE_WEBHOOK_SECRET"].encode()

def verify():
    body = request.get_data()                      # raw bytes, not request.json
    ts = request.headers.get("X-URLpipe-Timestamp", "")
    header = request.headers.get("X-URLpipe-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(401)
    expected = "v1=" + hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(s.strip(), expected) for s in header.split(",")):
        abort(401)

Why not simply put a secret token in the webhook URL? Because URLs end up in logs, proxies and error trackers, a leaked one works forever, and it proves nothing about the body. A signature is computed per delivery over the exact bytes sent, so a captured request cannot be altered, and cannot be replayed once its timestamp is outside your tolerance.

A delivery your endpoint rejects is retried with backoff, and any delivery can be re-sent by hand from the dashboard. The JavaScript and PHP versions are in the async & webhooks docs.

FAQ

Frequently asked questions

Almost always because the digest was computed over a parsed and re-serialised body rather than the raw bytes, or with the wrong secret. Use the raw request body.

See it on your own pages.

Free plan, no card. Confirm your email and your API key is live — you'll be making real requests in minutes.