PHP webhook signature verification in plain PHP and Laravel
Verify a Sume webhook in PHP: hash_hmac sha256 over timestamp.raw_body, split the sume-v1 entries, and compare each one with hash_equals.

To verify a Sume webhook signature in PHP, read the raw body with file_get_contents('php://input') (or $request->getContent() in Laravel), reject a timestamp outside your replay window, and compute hash_hmac('sha256', $timestamp . '.' . $raw, $secret). Prefix the digest with sume-v1= and compare it with each comma-separated entry of x-sume-webhook-signature using hash_equals, known string first.
PHP facts come from the PHP manual's hash_hmac, hash_equals, and php:// pages; Laravel facts from its 12.x Requests, CSRF Protection, Routing, and Configuration docs and Symfony's HttpFoundation page. Sume facts come from Run webhooks, Webhooks, and Verifying webhooks. All were read on 2026-09-27. Sume publishes no PHP package: its SDK is TypeScript, and the docs spell out the scheme for receivers in other languages. The delivery contract is in Signed webhooks for Sume video runs.
How does Sume's signature map to PHP functions?
Each step of Sume's check has a direct PHP call:
| Step | Sume rule | PHP |
|---|---|---|
| Raw body | Verify the raw bytes before any JSON parse | file_get_contents('php://input'); in Laravel, $request->getContent() |
| Headers | x-sume-webhook-timestamp and x-sume-webhook-signature | $_SERVER['HTTP_X_SUME_WEBHOOK_TIMESTAMP'] and $_SERVER['HTTP_X_SUME_WEBHOOK_SIGNATURE']; in Laravel, $request->header(), which returns null when the header is absent |
| Digest | HMAC-SHA256 over <timestamp>.<raw_body>, hex | hash_hmac('sha256', $data, $secret), lowercase hex unless $binary is true |
| Compare | Constant time; accept any sume-v1= entry | hash_equals($expected, $entry) for each entry of explode(',', $header) |
| Replay window | Reject timestamps outside it; five minutes is a reasonable default | abs(time() - (int) $ts) > 300 |
How do I verify a Sume webhook in plain PHP?
hash_equals is PHP's timing-attack-safe string comparison. The manual explains that a regular === comparison takes more or less time depending on where the strings differ, and warns to pass the user-supplied string as the second parameter. The function below checks every entry, because for 24 hours after a secret rotation the header carries one sume-v1= entry per live secret, newest first. It also refuses an empty secret, as Sume's TypeScript verifier does, so a missing setting cannot become an empty HMAC key that anyone could compute.
<?php
function sume_verify(string $raw, ?string $ts, ?string $header, string $secret): bool
{
if ($secret === '' || $ts === null || $header === null || !ctype_digit($ts)) return false;
if (abs(time() - (int) $ts) > 300) return false; // five-minute replay window
$expected = 'sume-v1=' . hash_hmac('sha256', (int) $ts . '.' . $raw, $secret);
$matched = false;
foreach (explode(',', $header) as $entry) { // two entries during a rotation
// Known string first, user-supplied string second; check every entry.
$matched = hash_equals($expected, trim($entry)) || $matched;
}
return $matched;
}
$raw = file_get_contents('php://input'); // raw body, before json_decode
if (!sume_verify($raw, $_SERVER['HTTP_X_SUME_WEBHOOK_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_SUME_WEBHOOK_SIGNATURE'] ?? null,
(string) getenv('SUME_COM_WEBHOOK_SIGNING_SECRET'))) {
http_response_code(401);
exit;
}
record_once(json_decode($raw, true)); // your table or queue, keyed on request_id or job_id
http_response_code(204);How do I receive it in Laravel?
Laravel's ValidateCsrfToken middleware runs in the web middleware group by default, and a webhook sender cannot know your CSRF token. Laravel's advice for webhook routes is to keep them outside the web group, or to list their URIs in validateCsrfTokens(except: [...]) in bootstrap/app.php. Routes in routes/api.php, created by php artisan install:api, are stateless, belong to the api group, and are served under /api.
Illuminate\Http\Request extends Symfony's Request, whose getContent() returns the raw body. Keep the secret in .env, read it with env('SUME_COM_WEBHOOK_SIGNING_SECRET') in config/services.php, and use config() in the route, with sume_verify loaded from a helper file.
<?php // routes/api.php: stateless, api group, served at /api/hooks/sume
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/hooks/sume', function (Request $request) {
$raw = $request->getContent(); // the raw body, before any JSON parsing
$ok = sume_verify(
$raw,
$request->header('X-Sume-Webhook-Timestamp'), // null when absent
$request->header('X-Sume-Webhook-Signature'),
(string) config('services.sume.webhook_secret'),
);
if (!$ok) {
return response('', 401);
}
record_once(json_decode($raw, true)); // then hand slow work to a queue
return response('', 204);
});Why does every signature fail?
Check these PHP and Laravel mistakes first:
- The body was re-encoded.
json_encode(json_decode($raw))is not what Sume signed: key order and whitespace are part of the signed bytes. Verify$raw, then decode it. hash_hmacgot$binary = true, which returns raw bytes instead of the lowercase hex that followssume-v1=.env()was called outside a config file. Afterphp artisan config:cache, Laravel no longer loads.env, andenv()returns only system-level environment variables, so the secret comes back empty andsume_verifyrefuses every delivery.- The header or the secret. Comparing the whole header fails every delivery during a rotation, and a mismatched secret shows in the
x-sume-webhook-secret-fingerprintheader; debugging webhook delivery covers both.
What should the endpoint do after it verifies?
Record the event, answer 204 inside Sume's 10-second attempt window, and hand slow work to a queue; dedupe on request_id for runs and job_id for jobs. Signed webhooks for Sume video runs covers the other delivery rules, and the Go version runs the same check with hmac.Equal.
Sources
- Run webhooks
- Webhooks
- Verifying webhooks
- TypeScript SDK
- Format cookbook
- PHP manual: hash_hmac (read 2026-09-27)
- PHP manual: hash_equals (read 2026-09-27)
- PHP manual: php:// wrappers (read 2026-09-27)
- PHP manual: $_SERVER (read 2026-09-27)
- Laravel 12.x: HTTP Requests (read 2026-09-27)
- Laravel 12.x: CSRF Protection (read 2026-09-27)
- Laravel 12.x: Routing (read 2026-09-27)
- Laravel 12.x: Configuration (read 2026-09-27)
- Symfony: The HttpFoundation Component (read 2026-09-27)
Related posts
More in Integrations
- Pipedream wait for webhook callback from a Sume video run
Call $.flow.suspend() in the step that starts a Sume video run, pass resume_url as webhook_url, and Pipedream resumes when Sume POSTs the result.
- Power Automate HTTP request API: start and poll a Sume run
Call the Sume API from a Power Automate HTTP action: start a Format run, poll it in a Do until loop, and read the key from a Key Vault secret.
- Pydantic AI MCP server: give an agent Sume's hosted tools
Connect a Pydantic AI agent to Sume's hosted MCP server with MCPToolset and an API-key header, filter the tools, and hold paid calls for approval.
- Shopify product video AI API with products/create webhooks
Answer Shopify's products/create webhook within five seconds, run a Sume Format from a queue, then upload the MP4 to Shopify with a staged upload.
Written by Sume