En cuanto existe un webhook, cualquiera que adivine o filtre la URL de un suscriptor puede falsificar una carga que parezca venir de ti. El arreglo depende de quién sea dueño de la ruta receptora.
Cuando la ruta receptora vive dentro de tu propia aplicación, Laravel ya lo resuelve por ti. Firma la URL de retorno con URL::signedRoute() y protege la ruta con el middleware signed:
// 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(),
);
Quien llegue a esa URL sin la firma exacta que Laravel generó es rechazado antes de que corra tu controlador. Sin secretos que gestionar, sin HMAC hecho a mano. Esa es la opción aburrida, y lo aburrido es lo correcto aquí.
Cuando la ruta receptora es de otra persona —el servidor de un socio, la integración de un cliente— URL::signedRoute() no puede ayudarte: no controlas qué corre al otro lado. Lo estándar es firmar la carga con un secreto que ambas partes conocen y enviar la firma como cabecera:
// 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'))) { /* ... */ }
=== se corta en el primer byte que no coincide, así que quien mida el tiempo de respuesta con suficiente precisión puede adivinar la firma carácter a carácter. hash_equals() tarda siempre lo mismo, diverjan donde diverjan las cadenas. Pon esto en tu documentación de webhooks, ya que es el código del suscriptor el que tiene que hacerlo bien, no sólo el tuyo.
Operaciones por lotes sin tiempos agotados
Un socio quiere añadir cincuenta dominios a una licencia en una petición en lugar de cincuenta. La versión ingenua envuelve un bucle de llamadas remotas en una transacción de base de datos:
// 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]);
});
}
Esto mantiene abierta una conexión de base de datos durante lo que tarde la más lenta de cincuenta llamadas HTTP secuenciales, y la petición entera se bloquea en todas ellas antes de que el cliente vea un solo byte. Un dominio lento en el lote y el propio tiempo de espera de PHP-FPM mata la petición, la transacción se revierte, y el socio recibe un 504 sin idea de cuáles de los cincuenta tuvieron éxito.
El arreglo es dejar de hacerlo dentro de la petición. Acepta el lote, encola un job por elemento y confirma de inmediato:
// 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,
);
}
allowFailures(false) cancela el lote entero en cuanto falla un job: si el dominio doce de cincuenta es rechazado, los otros treinta y ocho en vuelo tienen que enterarse y parar, no seguir mutando estado más allá del punto en que quien llamó ya sabe que algo salió mal. $this->batch()?->cancelled() es esa comprobación, al principio de handle() y antes de cualquier llamada externa, para que un job despachado pero aún no ejecutado sea una operación nula en lugar de un dominio huérfano que nadie pidió. Un MessageResponse con 202 le dice a quien llama que el lote fue aceptado, no completado, y encaja de forma natural con el webhook que ya construiste.