Add typed error handling, retries, and webhook receiver

- Typed exceptions: LeadMailException base with LeadMailRequestException
  (structured statusCode/errorCode/logId/validationErrors) and
  LeadMailConnectionException; sendEmail/getDomains now throw these instead
  of raw Guzzle exceptions, and a malformed body is no longer a silent null.
- Automatic retry with exponential backoff on idempotent calls
  (getDomains, verifyEmail); sends are never retried to avoid duplicates.
- Webhook receiver: auto-registered route + LeadMailWebhookController that
  verifies the HMAC signature, logs failures, and dispatches a
  LeadMailWebhookReceived event. WebhookSignature/WebhookEvent/LeadMailWebhook
  helpers for manual handling.
- Webhook self-registration client methods (registerWebhook/getWebhook/
  deleteWebhook) and a promptless `leadmail:install` command that registers
  the URL and writes LEADMAIL_WEBHOOK_SECRET to .env.
- Null-safe client binding when LEADMAIL_TOKEN is unset.
- Test suite (Pest + Testbench) covering all of the above.
This commit is contained in:
netlas
2026-06-09 11:14:10 +03:00
parent bf8aac48e6
commit b2b61a26ed
24 changed files with 1467 additions and 21 deletions

View File

@@ -0,0 +1,96 @@
<?php
namespace LeadM\LeadMail\Console;
use Illuminate\Console\Command;
use LeadM\LeadMail\Exceptions\LeadMailException;
use LeadM\LeadMail\LeadMailClient;
class InstallCommand extends Command
{
protected $signature = 'leadmail:install
{--url= : Override the webhook URL (defaults to APP_URL + the configured webhook route)}
{--rotate : Force generation of a new signing secret}
{--no-publish : Skip publishing the config file}';
protected $description = 'Publish config and connect this app to the leadMail webhook service';
public function handle(LeadMailClient $client): int
{
if (! $this->option('no-publish')) {
$this->call('vendor:publish', ['--tag' => 'leadmail-config']);
}
if (blank(config('leadmail.token'))) {
$this->components->error('LEADMAIL_TOKEN is not set. Add it to your .env, then re-run this command.');
return self::FAILURE;
}
$url = $this->option('url') ?: $this->defaultWebhookUrl();
try {
$result = $client->registerWebhook($url, regenerateSecret: (bool) $this->option('rotate'));
} catch (LeadMailException $e) {
$this->components->error('Could not register the webhook: '.$e->getMessage());
return self::FAILURE;
}
if (! empty($result['secret'])) {
$written = $this->writeEnv('LEADMAIL_WEBHOOK_SECRET', $result['secret']);
$written
? $this->components->info('Webhook registered. Signing secret written to your .env file.')
: $this->components->warn('Webhook registered. Add this to your .env: LEADMAIL_WEBHOOK_SECRET='.$result['secret']);
} else {
$this->components->info('Webhook URL updated. The existing signing secret was kept (use --rotate to replace it).');
}
$this->components->bulletList(array_filter([
'Webhook URL: '.($result['url'] ?? $url),
'This URL is served automatically by the SDK — failures are verified and logged out of the box.',
'Add a LeadMailWebhookReceived listener if you want custom handling.',
]));
return self::SUCCESS;
}
/**
* Derive the webhook URL from the app URL and the configured webhook route.
*/
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
$path = '/'.ltrim((string) (config('leadmail.webhook_route') ?: '/webhooks/leadmail'), '/');
return $base.$path;
}
/**
* Write or replace a key in the application's .env file.
*/
protected function writeEnv(string $key, string $value): bool
{
$path = $this->laravel->environmentFilePath();
if (! is_file($path) || ! is_writable($path)) {
return false;
}
$contents = (string) file_get_contents($path);
$line = $key.'='.$this->escapeEnvValue($value);
$pattern = '/^'.preg_quote($key, '/').'=.*$/m';
$contents = preg_match($pattern, $contents)
? (string) preg_replace($pattern, $line, $contents)
: rtrim($contents, "\n")."\n".$line."\n";
return file_put_contents($path, $contents) !== false;
}
protected function escapeEnvValue(string $value): string
{
return preg_match('/\s|[#"\']/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace LeadM\LeadMail\Events;
use LeadM\LeadMail\Webhooks\WebhookEvent;
/**
* Dispatched after the auto-registered webhook route verifies and parses an
* incoming LeadMail webhook. Listen for this to add custom handling:
*
* Event::listen(LeadMailWebhookReceived::class, function ($received) {
* if ($received->event->isFailure()) {
* // ...
* }
* });
*/
class LeadMailWebhookReceived
{
public function __construct(public readonly WebhookEvent $event) {}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace LeadM\LeadMail\Exceptions;
/**
* Thrown when an incoming webhook request fails signature verification,
* meaning it cannot be trusted to have originated from the LeadMail service.
*/
class InvalidWebhookSignatureException extends LeadMailException {}

View File

@@ -0,0 +1,23 @@
<?php
namespace LeadM\LeadMail\Exceptions;
use Throwable;
/**
* Thrown when the LeadMail service could not be reached at all
* (DNS failure, connection refused, timeout) — i.e. no HTTP response.
*
* These are the failures worth retrying; the client already retries them
* automatically up to the configured limit before throwing.
*/
class LeadMailConnectionException extends LeadMailException
{
public static function from(Throwable $e): self
{
return new self(
'Could not reach the LeadMail service: '.$e->getMessage(),
previous: $e,
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace LeadM\LeadMail\Exceptions;
use RuntimeException;
use Throwable;
/**
* Base exception for every error surfaced by the LeadMail SDK.
*
* Catch this to handle any LeadMail failure, or catch the more specific
* {@see LeadMailRequestException} / {@see LeadMailConnectionException} subtypes.
*/
class LeadMailException extends RuntimeException
{
/**
* @param array<string, array<int, string>> $validationErrors
* @param array<string, mixed>|null $response
*/
public function __construct(
string $message,
protected readonly ?int $statusCode = null,
protected readonly ?string $errorCode = null,
protected readonly ?int $logId = null,
protected readonly array $validationErrors = [],
protected readonly ?array $response = null,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
/**
* The HTTP status code returned by the service, when the failure was a response.
*/
public function statusCode(): ?int
{
return $this->statusCode;
}
/**
* The machine-readable error code from the API envelope (e.g. "TRANSPORT_ERROR").
*/
public function errorCode(): ?string
{
return $this->errorCode;
}
/**
* The email log id, when the API associated the failure with a log row.
*/
public function logId(): ?int
{
return $this->logId;
}
/**
* Field-level validation errors from a 422 response.
*
* @return array<string, array<int, string>>
*/
public function validationErrors(): array
{
return $this->validationErrors;
}
/**
* The full decoded response body, when available.
*
* @return array<string, mixed>|null
*/
public function response(): ?array
{
return $this->response;
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace LeadM\LeadMail\Exceptions;
use Throwable;
/**
* Thrown when the LeadMail service returned an HTTP error response (4xx/5xx).
*
* The structured details from the API envelope are parsed onto the exception so
* callers can branch on them without re-parsing the response body themselves.
*/
class LeadMailRequestException extends LeadMailException
{
/**
* Build the exception from a decoded error response, tolerating the
* several envelope shapes the API can emit:
*
* - send errors: {"success": false, "error": {"code", "message"}, "data": {"log_id"}}
* - auth errors: {"error": "Invalid API token."}
* - validation errors: {"message": "...", "errors": {"field": ["..."]}}
*
* @param array<string, mixed>|null $body
*/
public static function fromResponse(int $statusCode, ?array $body, ?Throwable $previous = null): self
{
$errorCode = null;
$message = null;
$validationErrors = [];
if (is_array($body)) {
$error = $body['error'] ?? null;
if (is_array($error)) {
$errorCode = isset($error['code']) ? (string) $error['code'] : null;
$message = isset($error['message']) ? (string) $error['message'] : null;
} elseif (is_string($error)) {
$message = $error;
}
if ($message === null && isset($body['message']) && is_string($body['message'])) {
$message = $body['message'];
}
if (isset($body['errors']) && is_array($body['errors'])) {
/** @var array<string, array<int, string>> $validationErrors */
$validationErrors = $body['errors'];
}
}
$logId = is_array($body) && isset($body['data']['log_id'])
? (int) $body['data']['log_id']
: null;
$message ??= "The LeadMail service returned an error (HTTP {$statusCode}).";
return new self(
message: $message,
statusCode: $statusCode,
errorCode: $errorCode,
logId: $logId,
validationErrors: $validationErrors,
response: is_array($body) ? $body : null,
previous: $previous,
);
}
/**
* Whether the request failed because of input validation (HTTP 422 with field errors).
*/
public function isValidationError(): bool
{
return $this->statusCode() === 422 && $this->validationErrors() !== [];
}
/**
* Whether the request failed because of authentication/authorization (HTTP 401/403).
*/
public function isAuthenticationError(): bool
{
return in_array($this->statusCode(), [401, 403], true);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace LeadM\LeadMail\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use LeadM\LeadMail\Events\LeadMailWebhookReceived;
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
/**
* Default receiver for LeadMail webhooks, auto-registered by the service
* provider. Verifies the signature, logs failures, and dispatches a
* LeadMailWebhookReceived event for custom handling.
*/
class LeadMailWebhookController
{
public function __invoke(Request $request, LeadMailWebhook $webhook): Response
{
try {
$event = $webhook->parse($request);
} catch (InvalidWebhookSignatureException) {
Log::warning('LeadMail webhook rejected: invalid or missing signature.');
return response('Invalid signature.', 403);
}
if ($event->isFailure()) {
Log::warning('LeadMail reported a failed email.', [
'log_id' => $event->logId,
'to' => $event->to,
'error_code' => $event->errorCode,
'error_message' => $event->errorMessage,
]);
}
event(new LeadMailWebhookReceived($event));
return response()->noContent();
}
}

View File

@@ -3,8 +3,14 @@
namespace LeadM\LeadMail;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use Illuminate\Support\Facades\Log;
use LeadM\LeadMail\Exceptions\LeadMailConnectionException;
use LeadM\LeadMail\Exceptions\LeadMailException;
use LeadM\LeadMail\Exceptions\LeadMailRequestException;
class LeadMailClient
{
@@ -16,12 +22,21 @@ class LeadMailClient
protected readonly int $timeout = 30,
protected readonly bool $verifySsl = true,
protected readonly bool $autoTenant = true,
protected readonly int $retries = 2,
protected readonly int $retryDelayMs = 200,
?HandlerStack $handlerStack = null,
) {
$this->http = new Client([
$config = [
'base_uri' => rtrim($this->baseUrl, '/').'/api/v1/',
'timeout' => $this->timeout,
'verify' => $this->verifySsl,
]);
];
if ($handlerStack !== null) {
$config['handler'] = $handlerStack;
}
$this->http = new Client($config);
}
/**
@@ -30,16 +45,17 @@ class LeadMailClient
* @param array{from: array, to: array, cc?: array, bcc?: array, reply_to?: array, subject: string, html_body?: string, text_body?: string, attachments?: array, metadata?: array, options?: array} $data
* @return array{success: bool, data: array{log_id: int, status: string}}
*
* @throws GuzzleException
* @throws LeadMailRequestException when the service returns an error response
* @throws LeadMailConnectionException when the service cannot be reached
*/
public function sendEmail(array $data): array
{
$response = $this->http->post('emails/send', [
// Sends are never retried automatically: a retry after a 5xx or a
// dropped connection could deliver the same email twice.
return $this->request('POST', 'emails/send', [
'headers' => $this->headers(),
'json' => $data,
]);
return json_decode($response->getBody()->getContents(), true);
], retryable: false);
}
/**
@@ -47,37 +63,97 @@ class LeadMailClient
*
* @return string[]
*
* @throws GuzzleException
* @throws LeadMailRequestException when the service returns an error response
* @throws LeadMailConnectionException when the service cannot be reached
*/
public function getDomains(): array
{
$response = $this->http->get('domains', [
$data = $this->request('GET', 'domains', [
'headers' => $this->headers(),
]);
$data = json_decode($response->getBody()->getContents(), true);
], retryable: true);
return $data['data']['domains'] ?? [];
}
/**
* Register (or update) the URL leadMail should POST failure webhooks to.
*
* The service returns the signing secret in plaintext exactly once — on the
* first registration, or when $regenerateSecret is true. Read it from the
* returned array's "secret" key when present.
*
* @return array{url: string, has_secret: bool, secret?: string}
*
* @throws LeadMailRequestException when the service returns an error response
* @throws LeadMailConnectionException when the service cannot be reached
*/
public function registerWebhook(string $url, bool $regenerateSecret = false): array
{
$response = $this->request('PUT', 'webhook', [
'headers' => $this->headers(),
'json' => [
'url' => $url,
'regenerate_secret' => $regenerateSecret,
],
], retryable: false);
return $response['data'] ?? [];
}
/**
* Get the current webhook configuration (never includes the secret).
*
* @return array{url: ?string, has_secret: bool}
*
* @throws LeadMailRequestException when the service returns an error response
* @throws LeadMailConnectionException when the service cannot be reached
*/
public function getWebhook(): array
{
$response = $this->request('GET', 'webhook', [
'headers' => $this->headers(),
], retryable: true);
return $response['data'] ?? [];
}
/**
* Remove the registered webhook URL and signing secret.
*
* @return array{url: ?string, has_secret: bool}
*
* @throws LeadMailRequestException when the service returns an error response
* @throws LeadMailConnectionException when the service cannot be reached
*/
public function deleteWebhook(): array
{
$response = $this->request('DELETE', 'webhook', [
'headers' => $this->headers(),
], retryable: false);
return $response['data'] ?? [];
}
/**
* Verify an email address through the leadMail service.
*
* Fails open: if the verification service is unreachable or errors, the
* address is treated as valid (status "unknown") so sign-ups are never
* blocked by a verification outage.
*
* @return array{success: bool, data: array{email: string, valid: bool, status: string, reason: ?string, cached: bool}}
*/
public function verifyEmail(string $email, bool $allowDisposable = false): array
{
try {
$response = $this->http->post('emails/verify', [
return $this->request('POST', 'emails/verify', [
'headers' => $this->headers(),
'json' => [
'email' => $email,
'allow_disposable' => $allowDisposable,
],
]);
return json_decode($response->getBody()->getContents(), true);
} catch (GuzzleException $e) {
], retryable: true);
} catch (LeadMailException $e) {
Log::warning('LeadMail verification failed, failing open', [
'email' => $email,
'error' => $e->getMessage(),
@@ -96,6 +172,98 @@ class LeadMailClient
}
}
/**
* Perform a request, retrying transient failures, and normalise every
* failure into a typed {@see LeadMailException}.
*
* @param array<string, mixed> $options
* @return array<string, mixed>
*
* @throws LeadMailException
*/
protected function request(string $method, string $uri, array $options, bool $retryable = false): array
{
$attempt = 0;
while (true) {
try {
$response = $this->http->request($method, $uri, $options + ['http_errors' => true]);
return $this->decode((string) $response->getBody());
} catch (ConnectException $e) {
if ($retryable && $attempt < $this->retries) {
$this->pause($attempt++);
continue;
}
throw LeadMailConnectionException::from($e);
} catch (RequestException $e) {
$response = $e->getResponse();
if ($response === null) {
throw LeadMailConnectionException::from($e);
}
$status = $response->getStatusCode();
if ($retryable && $this->isRetryable($status) && $attempt < $this->retries) {
$this->pause($attempt++);
continue;
}
throw LeadMailRequestException::fromResponse(
$status,
$this->tryDecode((string) $response->getBody()),
$e,
);
} catch (GuzzleException $e) {
throw LeadMailConnectionException::from($e);
}
}
}
protected function isRetryable(int $status): bool
{
return in_array($status, [429, 500, 502, 503, 504], true);
}
protected function pause(int $attempt): void
{
if ($this->retryDelayMs <= 0) {
return;
}
usleep($this->retryDelayMs * 1000 * (2 ** $attempt));
}
/**
* @return array<string, mixed>
*
* @throws LeadMailException when the body is not valid JSON
*/
protected function decode(string $raw): array
{
$decoded = $this->tryDecode($raw);
if ($decoded === null) {
throw new LeadMailException('Received a malformed response from the LeadMail service.');
}
return $decoded;
}
/**
* @return array<string, mixed>|null
*/
protected function tryDecode(string $raw): ?array
{
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
/**
* @return array<string, string>
*/

View File

@@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Facade;
* @method static array sendEmail(array $data)
* @method static string[] getDomains()
* @method static array verifyEmail(string $email, bool $allowDisposable = false)
* @method static array registerWebhook(string $url, bool $regenerateSecret = false)
* @method static array getWebhook()
* @method static array deleteWebhook()
*
* @see \LeadM\LeadMail\LeadMailClient
*/

View File

@@ -3,9 +3,13 @@
namespace LeadM\LeadMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
use LeadM\LeadMail\Console\InstallCommand;
use LeadM\LeadMail\Http\Controllers\LeadMailWebhookController;
use LeadM\LeadMail\Rules\LeadMailVerify;
use LeadM\LeadMail\Webhooks\LeadMailWebhook;
class LeadMailServiceProvider extends ServiceProvider
{
@@ -19,13 +23,19 @@ class LeadMailServiceProvider extends ServiceProvider
$this->app->singleton(LeadMailClient::class, function () {
return new LeadMailClient(
baseUrl: config('leadmail.url'),
token: config('leadmail.token', ''),
timeout: config('leadmail.timeout', 30),
baseUrl: (string) config('leadmail.url'),
token: (string) config('leadmail.token'),
timeout: (int) config('leadmail.timeout', 30),
verifySsl: config('leadmail.verify_ssl', true),
autoTenant: config('leadmail.auto_tenant', true),
retries: (int) config('leadmail.retries', 2),
retryDelayMs: (int) config('leadmail.retry_delay', 200),
);
});
$this->app->singleton(LeadMailWebhook::class, function () {
return new LeadMailWebhook((string) config('leadmail.webhook_secret', ''));
});
}
public function boot(): void
@@ -34,8 +44,12 @@ class LeadMailServiceProvider extends ServiceProvider
$this->publishes([
__DIR__.'/../config/leadmail.php' => config_path('leadmail.php'),
], 'leadmail-config');
$this->commands([InstallCommand::class]);
}
$this->registerWebhookRoute();
Mail::extend('leadmail', function () {
return new LeadMailTransport(
$this->app->make(LeadMailClient::class),
@@ -53,4 +67,20 @@ class LeadMailServiceProvider extends ServiceProvider
return $passed;
}, 'The :attribute email address could not be verified.');
}
/**
* Auto-register the webhook receiver route unless disabled in config.
*/
protected function registerWebhookRoute(): void
{
$path = config('leadmail.webhook_route');
if (blank($path)) {
return;
}
Route::post($path, LeadMailWebhookController::class)
->middleware((array) config('leadmail.webhook_middleware', []))
->name('leadmail.webhook');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace LeadM\LeadMail\Webhooks;
use Illuminate\Http\Request;
use LeadM\LeadMail\Exceptions\InvalidWebhookSignatureException;
/**
* Entry point for receiving LeadMail webhooks in a client application.
*
* Resolve it from the container (the secret is wired from config) and call
* {@see parse()} inside your webhook route:
*
* public function __invoke(Request $request, LeadMailWebhook $webhook)
* {
* $event = $webhook->parse($request); // throws on a bad signature
*
* if ($event->isFailure()) {
* // react to the failed send ($event->logId, $event->errorMessage, ...)
* }
*
* return response()->noContent();
* }
*/
final class LeadMailWebhook
{
public function __construct(private readonly string $secret) {}
/**
* Verify the request signature without throwing.
*/
public function verify(Request $request): bool
{
return WebhookSignature::isValid(
$request->getContent(),
$request->header(WebhookSignature::HEADER),
$this->secret,
);
}
/**
* Verify the signature and return the typed event.
*
* @throws InvalidWebhookSignatureException when the signature is missing,
* invalid, or the body cannot be decoded.
*/
public function parse(Request $request): WebhookEvent
{
if (! $this->verify($request)) {
throw new InvalidWebhookSignatureException(
'The LeadMail webhook signature is missing or invalid.',
);
}
$payload = json_decode($request->getContent(), true);
if (! is_array($payload)) {
throw new InvalidWebhookSignatureException(
'The LeadMail webhook payload could not be decoded.',
);
}
return WebhookEvent::fromArray($payload);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace LeadM\LeadMail\Webhooks;
/**
* Typed representation of a LeadMail webhook payload.
*
* Mirrors the payload emitted by the service's SendWebhookJob. Unknown or
* missing fields degrade gracefully to null so a payload change never fatals
* the receiver; the untouched payload is always available via {@see $payload}.
*/
final class WebhookEvent
{
/**
* @param array<int, string> $to
* @param array<string, mixed>|null $metadata
* @param array<string, mixed> $payload
*/
public function __construct(
public readonly string $event,
public readonly ?string $deliveryId,
public readonly ?string $occurredAt,
public readonly ?int $logId,
public readonly ?string $tenantId,
public readonly ?string $status,
public readonly ?string $errorCode,
public readonly ?string $errorMessage,
public readonly ?string $from,
public readonly array $to,
public readonly ?string $subject,
public readonly ?array $metadata,
public readonly array $payload,
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
$error = is_array($payload['error'] ?? null) ? $payload['error'] : [];
return new self(
event: (string) ($payload['event'] ?? 'unknown'),
deliveryId: isset($payload['delivery_id']) ? (string) $payload['delivery_id'] : null,
occurredAt: isset($payload['occurred_at']) ? (string) $payload['occurred_at'] : null,
logId: isset($payload['log_id']) ? (int) $payload['log_id'] : null,
tenantId: isset($payload['tenant_id']) ? (string) $payload['tenant_id'] : null,
status: isset($payload['status']) ? (string) $payload['status'] : null,
errorCode: isset($error['code']) ? (string) $error['code'] : null,
errorMessage: isset($error['message']) ? (string) $error['message'] : null,
from: isset($payload['from']) ? (string) $payload['from'] : null,
to: is_array($payload['to'] ?? null) ? array_values($payload['to']) : [],
subject: isset($payload['subject']) ? (string) $payload['subject'] : null,
metadata: is_array($payload['metadata'] ?? null) ? $payload['metadata'] : null,
payload: $payload,
);
}
/**
* Whether this event reports a failed send (event "email.failed").
*/
public function isFailure(): bool
{
return $this->event === 'email.failed';
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace LeadM\LeadMail\Webhooks;
/**
* Computes and verifies the HMAC signature LeadMail attaches to every webhook.
*
* The scheme mirrors the service exactly: the signature is
* "sha256=" . hash_hmac('sha256', <raw request body>, <webhook secret>).
* Always verify against the raw, unparsed request body.
*/
final class WebhookSignature
{
/**
* The header the service sends the signature in.
*/
public const HEADER = 'X-LeadMail-Signature';
public static function compute(string $payload, string $secret): string
{
return 'sha256='.hash_hmac('sha256', $payload, $secret);
}
/**
* Constant-time comparison of the expected signature against the received one.
*/
public static function isValid(string $payload, ?string $signature, string $secret): bool
{
if ($secret === '' || $signature === null || $signature === '') {
return false;
}
return hash_equals(self::compute($payload, $secret), $signature);
}
}