Once a webhook exists, anyone who guesses or leaks a subscriber's URL can forge a payload that looks like it came from you. The fix depends on who owns the receiving route.
When the receiving route lives inside your own app, Laravel already solves this for you. Sign the callback URL with URL::signedRoute() and protect the route with the signed middleware:
// routes/api.php
Route::post('/webhooks/licenses/{license}', LicenseCallbackController::class)
->name('webhooks.licenses.receive')
->middleware('signed');
// app/Listeners/DeliverLicenseWebhookListener.php
$callback = URL::signedRoute(
name: 'webhooks.licenses.receive',
parameters: ['license' => $event->license->key],
expiration: now()->addHour(),
);
Anyone who hits that URL without the exact signature Laravel generated gets rejected before your controller runs. No secret to manage, no HMAC to hand-roll. That's the boring option, and boring is correct here.
When the receiving route belongs to someone else, a partner's server, a customer's Zapier integration, URL::signedRoute() can't help: you don't control what runs on the other end. The standard is to sign the payload with a secret both sides know, and send the signature as a header:
// app/Listeners/DeliverLicenseWebhookListener.php
$body = (string) json_encode($payload);
$signature = hash_hmac(algo: 'sha256', data: $body, key: $event->secret);
Http::withHeaders(['X-Webplo-Signature' => $signature])
->withBody($body, 'application/json')
->post($event->webhook);
// Bad: the subscriber's verification code, a timing side channel
if ($request->header('X-Webplo-Signature') === $expectedSignature) { /* ... */ }
// Good: constant-time comparison
if (hash_equals($expectedSignature, (string) $request->header('X-Webplo-Signature'))) { /* ... */ }
=== short-circuits at the first mismatched byte, so an attacker who measures response time closely enough can guess the signature one character at a time. hash_equals() always takes the same time regardless of where the strings diverge. Put this in your webhook docs, since it's the subscriber's code that needs to get it right, not just yours.
Batch Operations Without Timeouts
A partner wants to add fifty domains to a license in one request instead of fifty. The naive version wraps a loop of remote calls in a database transaction:
// Bad: N remote calls inside one DB transaction
public function bulkAddDomains(Request $request): JsonResponse
{
$domains = $request->input('domains', []);
return DB::transaction(function () use ($domains) {
$results = [];
foreach ($domains as $domain) {
$results[] = LicenseFacade::addLicense(name: $domain, domain: $domain);
}
return response()->json(['data' => $results]);
});
}
This holds a database connection open for as long as the slowest of fifty sequential HTTP calls to Statamic takes, and the whole request blocks on all of them before the client sees a byte back. One slow domain in the batch, and PHP-FPM's own timeout kills the request, the transaction rolls back, and the partner gets a 504 and no idea which of the fifty succeeded.
The fix is to stop doing it inside the request at all. Accept the batch, queue one job per item, and acknowledge immediately:
// app/Http/Controllers/LicenseController.php
public function bulkAddDomains(BulkAddDomainsRequest $request): MessageResponse
{
$batch = Bus::batch(
collect($request->validated('domains'))
->map(fn (string $domain) => new LicenseDomainAddJob(
license: (string) $request->route('license'),
domain: $domain,
))
)->allowFailures(false)->dispatch();
return new MessageResponse(
message: "Queued {$batch->totalJobs} domain additions.",
status: Response::HTTP_ACCEPTED,
);
}
// app/Jobs/LicenseDomainAddJob.php
final class LicenseDomainAddJob implements ShouldBeEncrypted, ShouldQueue
{
use Batchable, Queueable;
public int $tries = 3;
public array $backoff = [15, 60];
public function __construct(
public string $license,
public string $domain,
) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
LicenseFacade::addLicense(name: $this->domain, domain: $this->domain);
}
}
allowFailures(false) cancels the whole batch the moment one job fails: if domain twelve of fifty is rejected by Statamic, the other thirty-eight in-flight jobs need to notice and stop, not keep mutating state past the point the caller already knows something went wrong. $this->batch()?->cancelled() is that check, sitting at the top of handle() before any external call runs, so a job that was dispatched but hasn't executed yet is a no-op instead of an orphaned domain nobody asked for. MessageResponse at 202 tells the caller the batch was accepted, not completed, and pairs naturally with the webhook you already built: fire a license.batch.completed event from the batch's then() callback, and the caller finds out the way they'd find out about anything else.