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.

5 min readSume
All posts

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:

From the PHP manual, Laravel's Requests page, and Sume's Run webhooks and Webhooks pages, read 2026-09-27.
StepSume rulePHP
Raw bodyVerify the raw bytes before any JSON parsefile_get_contents('php://input'); in Laravel, $request->getContent()
Headersx-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
DigestHMAC-SHA256 over <timestamp>.<raw_body>, hexhash_hmac('sha256', $data, $secret), lowercase hex unless $binary is true
CompareConstant time; accept any sume-v1= entryhash_equals($expected, $entry) for each entry of explode(',', $header)
Replay windowReject timestamps outside it; five minutes is a reasonable defaultabs(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_hmac got $binary = true, which returns raw bytes instead of the lowercase hex that follows sume-v1=.
  • env() was called outside a config file. After php artisan config:cache, Laravel no longer loads .env, and env() returns only system-level environment variables, so the secret comes back empty and sume_verify refuses 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-fingerprint header; 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

Related posts

More in Integrations

All Integrations posts

Written by Sume