Verify Webhook Signatures Securely: Common Pitfalls

Securely Verify SendPromptly Webhook Signatures (Common Pitfalls)

SendPromptly webhooks should only be trusted after verification. This guide shows how to verify webhook signature hmac sha256 in laravel middleware using raw request bytes, timestamp checks, and constant-time comparison.

You will implement a Laravel middleware, wire it to your webhook route, and test both valid and invalid requests with copy/paste commands.

If your endpoint is returning intermittent 401 invalid_signature responses, this page focuses on the exact byte-level mismatches that cause those failures.

Signature contract you must verify

Required headers (X-SP-Timestamp, X-SP-Signature)

Every webhook request must include:

  • X-SP-Timestamp
  • X-SP-Signature

Reject requests with missing headers before business logic. See Webhook delivery rules & signature headers.

Signed content format: {timestamp}.{raw_body}

Build the signed content exactly as:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
{timestamp}.{raw_body}

Then compute HMAC-SHA256 with your webhook secret and compare the hex digest to `X-SP-Signature`.

### Why "raw body" matters (and how it gets accidentally changed)

Use the request body exactly as received. If **webhook signature verification fails after json_decode in php**, the payload was likely transformed before verification (whitespace, escaping, ordering, or newline differences).

---

> Common gotcha: Verifying `json_encode(json_decode($body, true))` instead of raw bytes breaks valid signatures. Identical-looking JSON is not always identical bytes.

## Laravel implementation pattern (middleware)

Use this pattern to verify webhook signature hmac sha256 in laravel middleware without mutating request content.

### Reading the raw body safely

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifySendPromptlySignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $timestamp = $request->headers->get('X-SP-Timestamp');
        $signature = $request->headers->get('X-SP-Signature');

        if (!$timestamp || !$signature) {
            return response()->json(['error' => 'missing_signature_headers'], 400);
        }

        // Replay protection: 5-minute tolerance
        $now = time();
        $skew = abs($now - (int)$timestamp);
        if ($skew > 300) {
            return response()->json(['error' => 'timestamp_out_of_range'], 401);
        }

        // IMPORTANT: raw body (do not json_decode before signing)
        $rawBody = $request->getContent();

// SendPromptly signing format: "{timestamp}.{raw_body}" and header format: X-SP-Signature: v1=<hex>
$signedContent = $timestamp . '.' . $rawBody;

$secret = config('services.sendpromptly.webhook_secret');
if (! $secret) {
    return response()->json(['error' => 'webhook_secret_not_configured'], 500);
}

// Extract hex value from header (support v1= prefix)
$signatureHeader = $request->headers->get('X-SP-Signature');
if (! $signatureHeader || ! str_contains($signatureHeader, '=')) {
    return response()->json(['error' => 'invalid_signature_header'], 401);
}
[, $providedSignature] = explode('=', $signatureHeader, 2);

// HMAC-SHA256, hex encoded
$expected = hash_hmac('sha256', $signedContent, $secret);

if (! hash_equals($expected, $providedSignature)) {
        return $next($request);
    }
}

Computing HMAC-SHA256 (hex)

1
2
3
4
// ...
'sendpromptly' => [
    'webhook_secret' => env('SENDPROMPTLY_WEBHOOK_SECRET'),
],

Constant-time compare with hash_equals

For a php hash_equals webhook signature comparison example, use hash_equals($expected, $signature) exactly as shown above.

1
2
3
4
5
6
7
use App\Http\Middleware\VerifySendPromptlySignature;
use Illuminate\Http\Request;

Route::post('/webhooks/sendpromptly', function (Request $request) {
    // enqueue processing here (see DLQ guide)
    return response()->json(['ok' => true], 200);
})->middleware(VerifySendPromptlySignature::class);

After wiring this route, Send a test event and confirm delivery runs.

Minimal test snippet (expected behavior):

A) Generate a valid signature (bash + php one-liner)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export WEBHOOK_SECRET="replace_me"
BODY='{"event_key":"order.created","payload":{"order_id":"O-1001"}}'
TS=$(date +%s)

SIG=$(php -r '$ts=getenv("TS"); $body=getenv("BODY"); $secret=getenv("WEBHOOK_SECRET"); echo hash_hmac("sha256",$ts.".".$body,$secret);' \
  TS="$TS" BODY="$BODY" WEBHOOK_SECRET="$WEBHOOK_SECRET")

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"

Expected response: HTTP/1.1 200 OK with {"ok":true}.

B) Confirm failure on modified body

Change one character in BODY and re-run with the old SIG.

Expected response: 401 with {"error":"invalid_signature"}.

Try it now: Open your Sample Project, copy the sample token, send one event, then verify your endpoint receives a signed webhook.

Timestamp tolerance (replay protection)

5-minute window recommendation

Use a strict replay window. A 5-minute tolerance (300 seconds) is a practical default for webhook receivers.

What to do if your servers drift (NTP)

Keep receiver clocks synced with NTP. Clock drift causes valid signatures to fail timestamp checks.

Timestamp skewOutcomeAction
<= 300sRequest can pass signature verificationProcess normally
> 300sReject with timestamp_out_of_rangeFix time sync immediately
Frequent near-limit skewIntermittent verification failuresAudit NTP on all nodes

Response strategy (stop retries, keep processing async)

Return 2xx fast, queue real work

Verify signature, validate timestamp, then return 2xx quickly and queue downstream work. This avoids retry storms and keeps throughput stable. Pair this with How idempotency works on ingestion (24-hour TTL).

When to intentionally return non-2xx

Return non-2xx only when verification fails (missing headers, invalid signature, stale timestamp) or when your system cannot safely enqueue work yet.

Mini incident: A team processed webhooks inline and timed out on a slow dependency. Retries multiplied load and duplicated work. Moving to fast 2xx + queue processing stabilized delivery.

Troubleshooting signature mismatches

A step-by-step “diff” checklist

Use this flow to debug webhook signature mismatch wrong secret vs wrong payload.

Common failure modes checklist (run top to bottom):

  1. Confirm X-SP-Timestamp and X-SP-Signature are present.
  2. Rebuild the exact string to sign: {timestamp}.{raw_body} with no extra newline.
  3. Verify raw bytes come from $request->getContent() (not decoded/re-encoded JSON).
  4. Confirm digest format is hex (not base64).
  5. Check timestamp skew is within 300 seconds.
  6. Verify the active environment secret is correct and fully rotated.
  7. Ensure comparison uses hash_equals, never ==.

Logging safely (don’t log secrets)

Log only safe diagnostics: timestamp, skew, signature length, request ID, and verification result. Do not log webhook secrets, full signatures, or sensitive payload fields. Use API + delivery error codes reference when mapping failures to response behavior.

Key takeaways

  • Verify both required headers before processing.
  • Sign and verify the exact "{timestamp}.{raw_body}" byte sequence.
  • Use hash_hmac('sha256', ...) and hash_equals(...) for secure comparison.
  • Enforce a 5-minute timestamp window and keep NTP healthy.
  • Return 2xx quickly after verification, then process asynchronously.

Debug faster: Go to Message Log -> open the delivery run -> confirm your endpoint is returning 2xx and signature validation is passing.