Webhook Signature Verification Cookbook

Webhook Signature Verification Cookbook (Laravel/PHP + Test Vectors)

Webhook signature verification is critical for ensuring the authenticity of incoming requests. This guide provides Laravel/PHP developers with a cookbook of patterns for verifying webhook signatures using HMAC-SHA256, constant-time comparisons, and timestamp windows. This page includes webhook signature verification test vectors hmac sha256 and copy‑paste examples you can run locally. You’ll also find test vectors and troubleshooting tips to debug common issues.

Reference implementation: Signature verification test vectors (GitHub Pages)

SendPromptly signature contract (headers + algorithm)

This section includes the canonical webhook signature contract and example vectors — useful when you need webhook signature verification test vectors hmac sha256.

Required headers: X-SP-Timestamp, X-SP-Signature (format: v1=<hex>)

SendPromptly webhooks include these critical headers (use them for correlation and dedupe):

  • X-SP-Timestamp: The Unix timestamp when the webhook was generated.
  • X-SP-Signature: Signature header in the form v1=<hex>; value is HMAC-SHA256 over {timestamp}.{raw_body} (hex encoded).
  • X-SP-Message-Id: The unique message ID (ULID) for correlation and preferred dedupe key on receivers.
  • X-SP-Event-Key: The event identifier (e.g. order.created).

Learn more about signature headers and delivery rules.

HMAC-SHA256 over raw body + constant-time compare

The signature is computed as follows:

  1. Concatenate the timestamp and raw body with a . separator.
  2. Generate an HMAC-SHA256 hash using your webhook secret.
  3. Compare the computed signature with the X-SP-Signature header using hash_equals.

To prevent replay attacks, validate that the X-SP-Timestamp is within an acceptable window (e.g., 5 minutes). Reject requests outside this window.

Micro checklist:

  • Parse X-SP-Timestamp as an integer.
  • Compare it against the current Unix timestamp.
  • Allow a ±5-minute window.
  • Log rejected requests for auditing.

Laravel middleware (drop-in)

Raw body access

Laravel’s $request->getContent() provides the raw body of the request. Ensure no middleware consumes the stream before your signature check.

Common gotcha: Middleware that runs after body parsing can consume the request stream; put signature middleware early in the stack.

hash_hmac + hash_equals (handle v1= header)

SendPromptly signs webhooks as X-SP-Signature: v1=<hex>. Verify by extracting the hex portion and comparing with hash_equals.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ts = $request->header('X-SP-Timestamp');
$sigHeader = $request->header('X-SP-Signature'); // e.g. "v1=abc123..."
$raw = $request->getContent();

// Basic header validation
if (! $sigHeader || ! str_contains($sigHeader, '=')) {
    abort(401, 'invalid_signature_header');
}
[,$providedSig] = explode('=', $sigHeader, 2);

$signed = $ts . '.' . $raw;
$expected = hash_hmac('sha256', $signed, config('services.sendpromptly.webhook_secret'));

abort_unless(hash_equals($expected, $providedSig), 401, 'invalid_signature');

Use this middleware to validate incoming requests. Prefer using the X-SP-Message-Id header as a dedupe/correlation id when available.

Minimal example (exact):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ts = $request->header('X-SP-Timestamp');
$sigHeader = $request->header('X-SP-Signature');
$raw = $request->getContent();

if (! $sigHeader || ! str_contains($sigHeader, '=')) {
    abort(401, 'invalid_signature_header');
}
[, $providedSig] = explode('=', $sigHeader, 2);

$signed = $ts . '.' . $raw;
$expected = hash_hmac('sha256', $signed, config('services.sendpromptly.webhook_secret'));

abort_unless(hash_equals($expected, $providedSig), 401, 'invalid_signature');

Use Sample Project to trigger your first signed webhook and verify it passes.

Language snippets (for teams with multiple services)

Node / Python / Ruby / Java / C# (short patterns)

For teams working in polyglot environments, here are equivalent patterns:

Node.js

1
2
3
4
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', secret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

Python

1
2
3
import hmac
import hashlib
expected = hmac.new(secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()

Ruby

1
2
require 'openssl'
expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{raw_body}")

Java

1
2
3
4
5
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
String expected = bytesToHex(mac.doFinal((timestamp + "." + rawBody).getBytes()));

C#

1
2
3
4
using System.Security.Cryptography;
using System.Text;
var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = BitConverter.ToString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"))).Replace("-", "").ToLower();

Suggested diagram: A flowchart showing the signature verification process: headers → raw body → HMAC-SHA256 → constant-time compare.

Troubleshooting cookbook

Raw body pitfalls

Ensure you use the raw body, not the parsed JSON. Parsing can alter whitespace or key order, invalidating the signature.

Minimal test snippet:

1
2
3
4
TS=$(date +%s)
BODY='{"event_key":"order.created","payload":{"order_id":"O-1001"}}'
SIG=$(php -r '$ts=getenv("TS");$b=getenv("BODY");$s=getenv("SECRET");echo hash_hmac("sha256",$ts.".".$b,$s);' \
  TS="$TS" BODY="$BODY" SECRET="replace_me")
1
2
3
4
5
curl -i -X POST "http://localhost:8000/webhooks/sendpromptly" \
  -H "Content-Type: application/json" \
  -H "X-SP-Timestamp: $TS" \
  -H "X-SP-Signature: $SIG" \
  --data "$BODY"

Hex/base64 mismatch

Check whether the X-SP-Signature uses hex or base64 encoding. Mismatched encodings will cause verification to fail.

Wrong secret / wrong environment

Ensure the secret matches the environment (e.g., dev vs prod). Use environment variables to avoid hardcoding secrets.

Common failure modes

  1. Signing json_decode() output instead of raw body (whitespace/order changes).
  2. Comparing signatures with == (timing + type juggling).
  3. Using the wrong secret (prod vs dev mismatch).
  4. Timestamp drift / wrong timezone / NTP off.
  5. Assuming base64 while header uses hex (or vice versa).
  6. Middleware runs after something consumed the request stream.

Learn more about common mismatch causes.

Full security checklist.

Conclusion

If your webhook verification fails, open Message Log and compare headers + raw payload from the run.

Key takeaways

  • Always use hash_equals for constant-time comparisons.
  • Validate the X-SP-Timestamp within a ±5-minute window.
  • Use the raw body for signature generation.
  • Ensure secrets are environment-specific and never hardcoded.
  • Test with local curl vectors to debug issues.