The problem
Svix sends svix-id, svix-timestamp and svix-signature (or the unbranded webhook-* names). The PHP and Ruby SDKs looked them up with exact lower-case keys:
isset($headers['svix-id'])HTTP field names are case-insensitive (RFC 9110), and many frameworks hand headers to application code re-cased, for example as Svix-Id. Such a caller got "Missing required headers" although every header was there. Issue #1013, open since 2023, asked for a case-insensitive fallback that only runs when the fast lower-case lookup fails.
The proposed fix
PHP lowercases the keys only on the fallback path and then repeats the same branded-or-unbranded check:
if (is_array($headers)) {
$lowerHeaders = array_change_key_case($headers, CASE_LOWER);
} elseif (is_iterable($headers)) {
$lowerHeaders = [];
foreach ($headers as $k => $v) {
$lowerHeaders[strtolower($k)] = $v;
}
}Ruby does the same with transform_keys { |k| k.to_s.downcase }. Both have tests with mixed-case names.
What I found in review
PSR-7 headers: right keys, wrong value type
A common way to call the PHP SDK is with PSR-7 headers: $wh->verify($body, $request->getHeaders()). In that shape the keys keep their original case, and every value is an array of strings, because a header can appear more than once.
Before the pull request this input failed clearly with "Missing required headers". After it, the keys match, but the values are arrays:
$timestamp = intval(['1700000000'], 10); // int(1)
if ($timestamp < ($now - $this->tolerance)) {
throw new Exception\WebhookVerificationException("Message timestamp too old");
}intval on a non-empty array returns 1, so the caller now sees "Message timestamp too old", which points in completely the wrong direction. Had execution reached the signature, explode(' ', $array) would throw a TypeError. I checked both on PHP 8.4. The suggestion was to normalize values in both fallback branches and add a PSR-7 shaped test:
$v = is_array($v) ? reset($v) : $v;Two SDKs, two fallback rules
The PHP fallback requires all three headers from one brand: three svix-* or three webhook-*. The Ruby fallback picks each field independently (svix-id || webhook-id, and so on), so it can combine svix-id with webhook-signature. That is not a security problem, since every value still goes through the HMAC, but the SDKs now behave differently on mixed input. Grouping by brand in Ruby would keep them aligned. The k.to_s in the Ruby version also handles symbol keys, which is a nice detail.
What happened next
Takeaways
- Fixing the lookup is half of header handling. The value shape (string or list) is the other half.
- PHP's loose conversions (
intvalon an array) turn type errors into wrong numbers. Validate types at the boundary. - When SDKs in several languages implement one protocol, keep their edge cases identical, including on malformed input.