Logo
Search
API Docs

Laravel Server Integration

Framework Integrations

Laravel Server Integration: Webhook Controller, Routing & CSRF Exclusion

Overview

If your assistant's server URL points at a Laravel application, there's one setup step vanilla webhook handlers on other frameworks don't need: excluding the webhook route from Laravel's CSRF protection. Without that exclusion, Laravel will reject incoming POST requests from the core system before your controller ever sees them. This page covers the CSRF exclusion (for both older and current Laravel versions), a worked controller and route, the response rules, and local testing.


Excluding the Webhook Route from CSRF Protection

By default, Laravel's CSRF middleware blocks POST requests that don't carry a valid CSRF token — which is exactly what an external webhook request looks like. You need to explicitly exclude your webhook route.

Laravel 11 and later (bootstrap/app.php):

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhook/sulus',
        'webhook/sulus/*',
    ]);
})

Laravel 10 and earlier (app/Http/Middleware/VerifyCsrfToken.php):

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'webhook/sulus',
        'webhook/sulus/*',
    ];
}

Alternatively, define the route in routes/api.php instead of routes/web.php — Laravel's API routes aren't protected by the CSRF middleware by default, so no exclusion is needed there.


Route & Controller

// routes/api.php (already CSRF-exempt)
Route::post('/webhook', [WebhookController::class, 'handle']);
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        $type = $request->input('type');
        $call = $request->input('call', []);

        Log::info("Webhook received: {$type}", ['call_id' => $call['id'] ?? null]);

        return match($type) {
            'call-started'   => $this->handleCallStarted($call),
            'call-ended'     => $this->handleCallEnded($call),
            'function-call'  => $this->handleFunctionCall($request->input('functionCall', [])),
            default          => response()->json(['status' => 'ignored'], 200),
        };
    }

    private function handleCallStarted(array $call): JsonResponse
    {
        Log::info("Call started: {$call['id']}");
        return response()->json(['status' => 'ok']);
    }

    private function handleCallEnded(array $call): JsonResponse
    {
        Log::info("Call ended: {$call['id']}, duration: {$call['duration']}s");
        return response()->json(['status' => 'ok']);
    }

    private function handleFunctionCall(array $functionCall): JsonResponse
    {
        $functionName = $functionCall['functionName'] ?? '';
        $parameters   = $functionCall['parameters'] ?? [];

        $result = $this->processFunction($functionName, $parameters);

        // The core system expects a result back for function-call events
        return response()->json(['result' => $result]);
    }

    private function processFunction(string $name, array $params): mixed
    {
        // Your custom function logic here
        return ['message' => "Processed {$name}"];
    }
}

Event Types & Response Codes

The controller above handles the most common categories — call lifecycle, speech, and assistant events. For the complete event catalog, see Webhooks & Events.

Your response's HTTP status code controls delivery behavior:

Status CodeMeaning
200–299Success, event processed
400–499Client error, event rejected (not retried)
500–599Server error (will be retried)

See Webhook Delivery: Retries, Timeouts & Debugging for the full retry mechanics and the 10-second response window.


Testing Locally

Use Laravel's built-in dev server together with the core system's CLI listener and a tunneling service:

# Terminal 1: Start the Laravel dev server
php artisan serve  # Runs on localhost:8000

# Terminal 2: Start a tunnel
ngrok http 4242

# Terminal 3: Start the webhook listener
core-system listen --forward-to localhost:8000/api/webhook

Then update your Sulus dashboard webhook URL to the tunnel's public URL. Remember core-system listen only forwards events — it doesn't create a public URL itself, which is why the separate tunnel is required. For CLI authentication across multiple accounts, see CLI Key Rotation & Multi-Account.