Skip to content

Commit 7cde2fc

Browse files
committed
feat: add webhook signature verification support
Implemented a `Webhook` class to verify webhook payloads with signature validation and timestamp tolerance. Added exception handling for invalid signatures, incorrect timestamps, and JSON decoding errors. Comprehensive tests covering various edge cases ensure reliability and robustness.
1 parent 35547ee commit 7cde2fc

7 files changed

Lines changed: 462 additions & 0 deletions

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "lettermint/lettermint-php",
3+
"version": "1.4.99",
34
"description": "Official Lettermint PHP SDK.",
45
"type": "library",
56
"keywords": [
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace Lettermint\Exceptions;
4+
5+
class InvalidSignatureException extends WebhookVerificationException {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace Lettermint\Exceptions;
4+
5+
class JsonDecodeException extends WebhookVerificationException {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Lettermint\Exceptions;
4+
5+
class TimestampToleranceException extends WebhookVerificationException
6+
{
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Lettermint\Exceptions;
4+
5+
use Exception;
6+
7+
class WebhookVerificationException extends Exception {}

src/Webhook.php

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
namespace Lettermint;
4+
5+
use Lettermint\Exceptions\InvalidSignatureException;
6+
use Lettermint\Exceptions\JsonDecodeException;
7+
use Lettermint\Exceptions\TimestampToleranceException;
8+
use Lettermint\Exceptions\WebhookVerificationException;
9+
10+
final class Webhook
11+
{
12+
private const SIGNATURE_HEADER = 'X-Lettermint-Signature';
13+
14+
private const DELIVERY_HEADER = 'X-Lettermint-Delivery';
15+
16+
private const DEFAULT_TOLERANCE = 300;
17+
18+
private string $secret;
19+
20+
private int $tolerance;
21+
22+
/**
23+
* Create a new webhook verifier instance.
24+
*
25+
* @param string $secret The webhook signing secret
26+
* @param int $tolerance Maximum allowed time difference in seconds (default: 300)
27+
*
28+
* @throws \InvalidArgumentException If secret is empty
29+
*/
30+
public function __construct(string $secret, int $tolerance = self::DEFAULT_TOLERANCE)
31+
{
32+
if ($secret === '') {
33+
throw new \InvalidArgumentException('Webhook secret cannot be empty');
34+
}
35+
36+
$this->secret = $secret;
37+
$this->tolerance = $tolerance;
38+
}
39+
40+
/**
41+
* Verify a webhook signature and return the decoded payload.
42+
*
43+
* @param string $payload The raw request body
44+
* @param string $signature The signature header value (format: t={timestamp},v1={hash})
45+
* @param int|null $timestamp Optional timestamp from delivery header for cross-validation
46+
* @return array<string, mixed> The decoded webhook payload
47+
*
48+
* @throws WebhookVerificationException If signature format is invalid or timestamps mismatch
49+
* @throws InvalidSignatureException If signature doesn't match
50+
* @throws TimestampToleranceException If timestamp is outside tolerance window
51+
* @throws JsonDecodeException If payload is not valid JSON
52+
*/
53+
public function verify(string $payload, string $signature, ?int $timestamp = null): array
54+
{
55+
$parsedSignature = $this->parseSignature($signature);
56+
57+
$signatureTimestamp = $parsedSignature['timestamp'];
58+
$expectedSignature = $parsedSignature['signature'];
59+
60+
if ($timestamp !== null && $timestamp !== $signatureTimestamp) {
61+
throw new WebhookVerificationException('Timestamp mismatch between signature and delivery headers');
62+
}
63+
64+
$this->validateTimestamp($signatureTimestamp);
65+
66+
$signedContent = $signatureTimestamp.'.'.$payload;
67+
$computedSignature = hash_hmac('sha256', $signedContent, $this->secret);
68+
69+
if (! hash_equals($computedSignature, $expectedSignature)) {
70+
throw new InvalidSignatureException('Signature verification failed');
71+
}
72+
73+
$data = json_decode($payload, true);
74+
75+
if (json_last_error() !== JSON_ERROR_NONE) {
76+
throw new JsonDecodeException('Failed to decode webhook payload: '.json_last_error_msg());
77+
}
78+
79+
return $data;
80+
}
81+
82+
/**
83+
* Verify a webhook using HTTP headers and return the decoded payload.
84+
*
85+
* @param array<string, string> $headers HTTP headers from the request
86+
* @param string $payload The raw request body
87+
* @return array<string, mixed> The decoded webhook payload
88+
*
89+
* @throws WebhookVerificationException If required headers are missing or verification fails
90+
* @throws InvalidSignatureException If signature doesn't match
91+
* @throws TimestampToleranceException If timestamp is outside tolerance window
92+
* @throws JsonDecodeException If payload is not valid JSON
93+
*/
94+
public function verifyHeaders(array $headers, string $payload): array
95+
{
96+
$headers = $this->normalizeHeaders($headers);
97+
98+
$signature = $headers[strtolower(self::SIGNATURE_HEADER)] ?? null;
99+
$timestamp = $headers[strtolower(self::DELIVERY_HEADER)] ?? null;
100+
101+
if ($signature === null) {
102+
throw new WebhookVerificationException('Missing signature header: '.self::SIGNATURE_HEADER);
103+
}
104+
105+
if ($timestamp === null) {
106+
throw new WebhookVerificationException('Missing delivery header: '.self::DELIVERY_HEADER);
107+
}
108+
109+
return $this->verify($payload, $signature, (int) $timestamp);
110+
}
111+
112+
/**
113+
* Static convenience method to verify a webhook signature.
114+
*
115+
* @param string $payload The raw request body
116+
* @param string $signature The signature header value (format: t={timestamp},v1={hash})
117+
* @param string $secret The webhook signing secret
118+
* @param int|null $timestamp Optional timestamp from delivery header for cross-validation
119+
* @param int $tolerance Maximum allowed time difference in seconds (default: 300)
120+
* @return array<string, mixed> The decoded webhook payload
121+
*
122+
* @throws \InvalidArgumentException If secret is empty
123+
* @throws WebhookVerificationException If signature format is invalid or timestamps mismatch
124+
* @throws InvalidSignatureException If signature doesn't match
125+
* @throws TimestampToleranceException If timestamp is outside tolerance window
126+
* @throws JsonDecodeException If payload is not valid JSON
127+
*/
128+
public static function verifySignature(
129+
string $payload,
130+
string $signature,
131+
string $secret,
132+
?int $timestamp = null,
133+
int $tolerance = self::DEFAULT_TOLERANCE
134+
): array {
135+
$webhook = new self($secret, $tolerance);
136+
137+
return $webhook->verify($payload, $signature, $timestamp);
138+
}
139+
140+
private function parseSignature(string $signature): array
141+
{
142+
$parts = explode(',', $signature);
143+
144+
$timestamp = null;
145+
$signatureHash = null;
146+
147+
foreach ($parts as $part) {
148+
$keyValue = explode('=', $part, 2);
149+
if (count($keyValue) !== 2) {
150+
continue;
151+
}
152+
153+
[$key, $value] = $keyValue;
154+
155+
if ($key === 't') {
156+
$timestamp = (int) $value;
157+
} elseif ($key === 'v1') {
158+
$signatureHash = $value;
159+
}
160+
}
161+
162+
if ($timestamp === null || $signatureHash === null) {
163+
throw new WebhookVerificationException('Invalid signature format. Expected format: t={timestamp},v1={signature}');
164+
}
165+
166+
return [
167+
'timestamp' => $timestamp,
168+
'signature' => $signatureHash,
169+
];
170+
}
171+
172+
private function validateTimestamp(int $timestamp): void
173+
{
174+
$currentTime = time();
175+
$difference = abs($currentTime - $timestamp);
176+
177+
if ($difference > $this->tolerance) {
178+
throw new TimestampToleranceException(
179+
sprintf(
180+
'Timestamp outside tolerance window. Difference: %d seconds, Tolerance: %d seconds',
181+
$difference,
182+
$this->tolerance
183+
)
184+
);
185+
}
186+
}
187+
188+
private function normalizeHeaders(array $headers): array
189+
{
190+
$normalized = [];
191+
192+
foreach ($headers as $key => $value) {
193+
$normalized[strtolower($key)] = $value;
194+
}
195+
196+
return $normalized;
197+
}
198+
}

0 commit comments

Comments
 (0)