Symfony 7 controller for Sume webhooks: getContent and hash_equals

A Symfony controller that reads getContent(), recomputes hash_hmac over timestamp.raw_body, compares with hash_equals, and returns 204 for a Sume delivery.

5 min readSume
All posts

In Symfony, read the body with $request->getContent(), which returns the raw string, then compute hash_hmac('sha256', $ts . '.' . $raw, $secret), prefix it with sume-v1=, and compare each comma-separated entry of x-sume-webhook-signature using hash_equals. Return 204 on success and 401 on a bad signature. Do not decode the JSON before you have verified it.

Sume signs the raw JSON body with HMAC-SHA256 over <timestamp>.<raw_body> and sends x-sume-webhook-timestamp plus x-sume-webhook-signature: sume-v1=<hex>; during a secret rotation the header carries one sume-v1= entry per live secret, comma-separated, newest first. Reject a timestamp more than five minutes from now, and refuse to run at all when the secret is empty.

What does the controller look like?

This is a single-action controller for Symfony 7 with attribute routing. Read the secret through your normal config rather than getenv in production.

<?php
namespace App\Controller;

use Symfony\Component\HttpFoundation\{Request, Response};
use Symfony\Component\Routing\Attribute\Route;

final class SumeWebhookController
{
    #[Route('/hooks/sume', methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        $secret = (string) getenv('SUME_COM_WEBHOOK_SIGNING_SECRET');
        if ($secret === '') return new Response('not configured', 500);
        $raw = $request->getContent();
        $ts = (int) $request->headers->get('x-sume-webhook-timestamp', '0');
        if (abs(time() - $ts) > 300) return new Response('stale', 401);
        $want = 'sume-v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
        $header = (string) $request->headers->get('x-sume-webhook-signature', '');
        foreach (explode(',', $header) as $entry) {
            if (hash_equals($want, trim($entry))) {
                $event = json_decode($raw, true);
                // insert $event['request_id'] ?? $event['job_id'] once, then dispatch
                return new Response('', 204);
            }
        }
        return new Response('bad signature', 401);
    }
}

What do the PHP details change?

PHP and Symfony details for a Sume receiver, Sume docs read 2026-10-04
DetailDoAvoid
Body$request->getContent()json_decode then json_encode to rebuild it
Comparehash_equals($known, $user)=== on the two strings
Timestamp(int) of the header, then abs(time() - $ts)Trusting a missing header
RotationLoop over the comma-separated entriesComparing the whole header for equality
Reply204 quicklyDoing the media work inside the request

What happens after the signature passes?

Dedupe before you act. Job events carry job_id; run events carry request_id, equal to run_id, and it is the same on every retry. A unique index on that value turns a redelivery into a no-op. Then push the work to Symfony Messenger and return, because a handler slower than 10 seconds burns an attempt and Sume retries up to 10 times.

Disable any CSRF or session middleware on this one route. A webhook has no cookie and no form token, and its authenticity comes from the signature.

What should you test?

  • A body with the right signature but a timestamp 10 minutes old: expect 401.
  • A header with two sume-v1= entries, only the second valid: expect 204.
  • An empty SUME_COM_WEBHOOK_SIGNING_SECRET: expect 500, not a pass.
  • A webhook.test body from the dashboard's Send test: it has no job id, so it must not reach job logic.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume