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.

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?
| Detail | Do | Avoid |
|---|---|---|
| Body | $request->getContent() | json_decode then json_encode to rebuild it |
| Compare | hash_equals($known, $user) | === on the two strings |
| Timestamp | (int) of the header, then abs(time() - $ts) | Trusting a missing header |
| Rotation | Loop over the comma-separated entries | Comparing the whole header for equality |
| Reply | 204 quickly | Doing 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: expect204. - An empty
SUME_COM_WEBHOOK_SIGNING_SECRET: expect500, not a pass. - A
webhook.testbody from the dashboard's Send test: it has no job id, so it must not reach job logic.
Sources
Related posts
More in Developers
- Synthesia needs a Legacy (v2) key; Sume keys carry scopes per call
Synthesia's quickstart warns an Interactive Avatars key will not create videos. Sume uses one bearer key whose scopes gate Formats, jobs and webhooks.
- Synthesia says 3 to 5 minutes per video; how to watch a Sume job
Synthesia's quickstart says videos usually finish in 3 to 5 minutes. Sume avatar jobs run async; read status, then the events endpoint for a slow render.
- Hourly synthetic monitor for an image API on a cheap Sume model
A scheduled script that makes one real image call, checks status, URL and latency, and exits non-zero on failure. Budget it from the endpoint's cost_usd.
- End-user id on jobs: OpenAI safety identifier vs Sume metadata
OpenAI's Realtime guide asks for an OpenAI-Safety-Identifier header. Sume stores caller metadata on the job but does not send it to the provider. Use both.
Written by Sume