| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/switchyard/current/app/Http/Controllers/ |
Upload File : |
<?php
namespace App\Http\Controllers;
use App\Http\Responses\ProblemResponse;
use App\Jobs\IngestLeadJob;
use App\Models\IdempotencyRecord;
use App\Models\LeadSource;
use App\Models\Scopes\WorkspaceScope;
use App\Services\WebhookSigner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* POST /webhooks/inbound/{workspace_id}/{source_slug}
*
* Per-source HMAC signature + Idempotency-Key dedup. Hands off to
* IngestLeadJob for the parsing/enrichment pipeline. Sources expose
* their HMAC secret one-time via Filament — that's what the caller signs
* with.
*/
class InboundWebhookController extends Controller
{
public function __construct(private readonly WebhookSigner $signer) {}
public function __invoke(Request $request, string $workspaceId, string $sourceSlug): Response
{
$source = LeadSource::withoutGlobalScope(WorkspaceScope::class)
->where('workspace_id', $workspaceId)
->where('slug', $sourceSlug)
->first();
if (! $source) {
return new ProblemResponse(
status: 404,
title: 'Source not found',
detail: "No lead source `{$sourceSlug}` configured for this workspace.",
);
}
if (! $source->enabled) {
return new ProblemResponse(
status: 409,
title: 'Source disabled',
detail: "Source `{$sourceSlug}` is currently disabled.",
);
}
$signatureHeader = (string) $request->header('X-Switchyard-Signature', '');
$body = $request->getContent();
if (! $source->inbound_secret || ! $this->signer->verify($source->inbound_secret, $body, $signatureHeader)) {
return new ProblemResponse(
status: 401,
title: 'Invalid signature',
detail: 'X-Switchyard-Signature is missing, malformed, expired, or does not match.',
);
}
$idempotencyKey = (string) $request->header('Idempotency-Key', '');
if ($idempotencyKey !== '') {
$existing = $this->lookupIdempotency($workspaceId, $idempotencyKey);
if ($existing !== null) {
return response()->json($existing->response ?? ['ok' => true], 200);
}
}
try {
$payload = $request->json()->all();
} catch (Throwable) {
$payload = [];
}
IngestLeadJob::dispatch(
workspaceId: $workspaceId,
sourceId: $source->id,
rawPayload: $payload,
externalRef: $idempotencyKey ?: null,
);
if ($idempotencyKey !== '') {
$this->recordIdempotency($workspaceId, $idempotencyKey);
}
return response()->json(['ok' => true, 'accepted' => true], 202);
}
private function lookupIdempotency(string $workspaceId, string $key): ?IdempotencyRecord
{
$record = IdempotencyRecord::withoutGlobalScope(WorkspaceScope::class)
->where('workspace_id', $workspaceId)
->where('idempotency_key', $key)
->first();
if ($record === null) {
return null;
}
if ($record->isExpired()) {
$record->delete();
return null;
}
return $record;
}
private function recordIdempotency(string $workspaceId, string $key): void
{
IdempotencyRecord::create([
'workspace_id' => $workspaceId,
'idempotency_key' => $key,
'response' => ['ok' => true, 'accepted' => true],
'expires_at' => now()->addHours(24),
'created_at' => now(),
]);
}
}