Files
landing-api/app/Http/Controllers/Api/V1/WebhookController.php
Josep Piguillem 9bdbf834f6 Send signed outgoing webhooks
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 09:42:39 +02:00

147 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\WebhookRequest;
use App\Http\Resources\WebhookDeliveryResource;
use App\Http\Resources\WebhookResource;
use App\Jobs\DeliverWebhook;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;
/**
* Outgoing webhooks: where to send, which events, and how it has gone.
*/
class WebhookController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Webhook::class);
return WebhookResource::collection(
Webhook::query()->latest('id')->paginate(perPage: min($request->integer('per_page', 25), 100)),
);
}
/**
* The secret is in the answer once, when it is made (or rotated), and
* never again.
*/
public function store(WebhookRequest $request): JsonResponse
{
$this->authorize('create', Webhook::class);
$secret = $request->input('secret') ?: Str::random(40);
$webhook = new Webhook([...$request->validated(), 'secret' => $secret]);
$webhook->forceFill(['created_by' => $request->user()->id])->save();
return WebhookResource::make($webhook->refresh())
->additional(['secret' => $secret])
->response()
->setStatusCode(201);
}
public function show(Webhook $webhook): WebhookResource
{
$this->authorize('view', $webhook);
return WebhookResource::make($webhook);
}
/**
* Switching it back on starts its failure count again.
*/
public function update(WebhookRequest $request, Webhook $webhook): JsonResponse
{
$this->authorize('update', $webhook);
$data = $request->validated();
$rotated = $request->boolean('rotate_secret') ? Str::random(40) : null;
unset($data['rotate_secret']);
$webhook->fill($data);
if ($rotated !== null) {
$webhook->secret = $rotated;
}
if ($webhook->isDirty('is_active') && $webhook->is_active) {
$webhook->consecutive_failures = 0;
}
$webhook->save();
return WebhookResource::make($webhook)
->additional($rotated !== null ? ['secret' => $rotated] : [])
->response();
}
public function destroy(Webhook $webhook): JsonResponse
{
$this->authorize('delete', $webhook);
$webhook->delete();
return response()->json(status: 204);
}
/**
* Newest first. Filter: status.
*/
public function deliveries(Request $request, Webhook $webhook): AnonymousResourceCollection
{
$this->authorize('view', $webhook);
return WebhookDeliveryResource::collection(
$webhook->deliveries()
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString(),
);
}
/**
* A `ping`, to check the receiving end before anything real happens.
*/
public function ping(Webhook $webhook): JsonResponse
{
$this->authorize('update', $webhook);
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => 'ping',
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
]);
DeliverWebhook::dispatch($delivery);
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
}
/**
* Send one again, as a new delivery of the same event and payload.
*/
public function redeliver(WebhookDelivery $delivery): JsonResponse
{
$this->authorize('update', $delivery->webhook);
$again = $delivery->webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $delivery->event,
'payload' => $delivery->payload,
]);
DeliverWebhook::dispatch($again);
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
}
}