Handle webhooks
Verifying inbound webhook deliveries with the SUQO PHP SDK.
Suqo\Webhook::verify() confirms an inbound delivery genuinely came from SUQO and has not been tampered with or replayed. It needs no client, no API key and no network, so it can be called straight from a serverless handler. It never throws: every failure path, malformed input included, returns false.
A complete handler
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Suqo\Webhook;
$raw = file_get_contents('php://input'); // the exact bytes received
if ($raw === false) {
http_response_code(400);
exit;
}
$verified = Webhook::verify(
rawBody: $raw,
signature: $_SERVER['HTTP_X_SUQO_SIGNATURE'] ?? null,
timestamp: $_SERVER['HTTP_X_SUQO_TIMESTAMP'] ?? null,
secret: (string) getenv('SUQO_WEBHOOK_SECRET'),
);
if (!$verified) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true); // parse only after verifying
// Acknowledge fast, then process out of band.
http_response_code(200);SuqoClient::verifyWebhook() is a static alias with identical parameters and behaviour, for a handler that already imports the client. Prefer Webhook::verify() where it does not — it pulls in one small class instead of the whole entry point.
Pass the raw bytes
A body re-serialised from a parse verifies only by luck: the signature covers bytes, not structure, and a round-trip through a JSON decoder changes whitespace, key order, escaping and number formatting. A trivial payload can survive that round-trip and pass in a test, then fail on the first real event with a nested object or unicode in it.
// Wrong: verification will fail even for a genuine event.
Webhook::verify(rawBody: json_encode($request->all()), /* … */);Reach for the untouched body: $request->getContent() in Symfony or Laravel, (string) $request->getBody() on a PSR-7 request, php://input in plain PHP. If a middleware has already consumed the stream, capture the bytes before it does.
Replay protection
The signed payload is the timestamp, a literal ., then the body — "{timestamp}.{rawBody}" — and the comparison is hash_equals on decoded bytes, which is constant-time.
maxAge (default 300 seconds) widens only the backward window. An event whose timestamp is more than 60 seconds in the future fails regardless of maxAge, which is what catches a badly skewed sender rather than a slow queue.
// A retry queue that can sit for ten minutes.
Webhook::verify($raw, $sig, $ts, $secret, maxAge: 600);What is checked, in order
Each step returns false on failure; none raises.
signatureandtimestampare both non-null.timestampis numeric after trimming, and finite.now - timestamp <= maxAge— not too old.timestamp - now <= 60— not too far in the future.signaturestarts withsha256=.- The part after it is exactly 64 characters, and valid hex.
- HMAC-SHA256 over the signed payload matches.
Because every failure is a false rather than an exception, treat a false as "reject the delivery" — it does not tell you which of the seven checks failed, deliberately.