PHP · Code recipe
Verify a webhook signature in PHP
Prove a delivery came from URLpipe before you act on it, in PHP 8.1+ with the curl extension. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a URLpipe webhook in PHP, compute HMAC-SHA256 over the X-URLpipe-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Prefix it with v1= and compare it with hash_equals against each comma-separated value of X-URLpipe-Signature; reject timestamps more than five minutes off.
Free plan, no credit card. 1,000 credits a month.
Your report_to URL accepts a POST from anyone who learns it. Turn on webhook signing for the project and every delivery carries X-URLpipe-Timestamp and X-URLpipe-Signature, so the receiver can prove the body came from URLpipe, unchanged, in the last five minutes.
The PHP receiver below does the whole check: it reads the raw body (file_get_contents("php://input"), or $request->getContent() in Laravel), recomputes the HMAC, compares it in constant time with hash_equals, and rejects stale timestamps. To send it a signed test delivery, use the shell script on the cURL page.
Setup
Before you start
Nothing to install. Turn signing on under Settings → Webhook Signing and copy the secret (it starts with whsec_). PHP's built-in server is enough to try the receiver; in production the same file sits behind PHP-FPM.
export URLPIPE_WEBHOOK_SECRET="whsec_your_signing_secret"
Receiver
A receiver that verifies every delivery
verify() is the part to copy into your app — in Laravel pass it $request->getContent(). php://input is the raw body; $_POST is empty for JSON, and a json_decode/json_encode round trip changes the bytes. hash_equals() is the constant-time compare: never === on a signature.
<?php
const TOLERANCE = 5 * 60; // seconds
// True when the delivery was signed with $secret in the last five minutes.
function verify(string $body, string $timestamp, string $signatureHeader, string $secret): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE) {
return false;
}
$expected = "v1=" . hash_hmac("sha256", "{$timestamp}.{$body}", $secret);
// One signature normally, two during a secret rotation: accept any match.
foreach (explode(",", $signatureHeader) as $signature) {
if (hash_equals($expected, trim($signature))) {
return true;
}
}
return false;
}
$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if ($_SERVER["REQUEST_METHOD"] !== "POST" || $path !== "/webhooks/urlpipe") {
http_response_code(404);
exit;
}
// The raw bytes, exactly as sent.
$body = file_get_contents("php://input");
$verified = verify(
$body,
$_SERVER["HTTP_X_URLPIPE_TIMESTAMP"] ?? "",
$_SERVER["HTTP_X_URLPIPE_SIGNATURE"] ?? "",
getenv("URLPIPE_WEBHOOK_SECRET"),
);
if (!$verified) {
http_response_code(401);
exit;
}
$delivery = json_decode($body, true);
error_log("Verified delivery for {$delivery["token"]}");
http_response_code(200);
Run it: php -S localhost:8000 webhook.php
Details
What to know about signed deliveries
- Signing is off until you turn it on under Settings → Webhook Signing; the secret starts with
whsec_. Enabling it is safe at any time — the body does not change — so enable it first and deploy the check after. - Rotating the secret opens a 24-hour window in which every delivery carries two signatures, the new one first. That is why the header is a list and any match is accepted.
- Each attempt is signed with a fresh timestamp, so a retry passes the five-minute check like the first delivery did.
- A delivery your endpoint rejects is retried with backoff, up to six attempts over roughly twenty minutes, and can be resent by hand from the dashboard afterwards.
- Make the handler idempotent on
token: the same result can arrive more than once.
Other languages
Verify a webhook signature in another language
More PHP: every PHP recipe · how signing works, in the docs
FAQ
Frequently asked questions
Why verify against the raw body?
Why can the signature header hold more than one value?
What should a receiver answer when the check fails?
Why a five-minute tolerance?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.