HTTP reference

Markdown

AI API

AI HTTP routes live in routes/ai.php: one HMAC-signed webhook (no session) and an authenticated /ai/* group gated by can-use-ai.

Config: AI configuration. Product behaviour: AI assistant.

Webhook (HMAC auth)

MethodPathRoute nameMiddleware
POST/ai/webhooks/collection-importai.webhooks.collection-importthrottle:30,1

CSRF stays excepted for this path in bootstrap/app.php (machine clients have no session CSRF cookie). Auth is HMAC only — not a bare Bearer token.

Authentication

Verified in CollectionImportWebhookRequest::authorize() before validation runs (controller helper: CollectionImportWebhookController::sign). Signature check first — unsigned or mistimed requests never reach the body rules (core#185).

  1. Build acceptable tokens: current config('ai.webhook_token') ← AI_WEBHOOK_TOKEN, plus a non-expired previous token during rotation grace (AI_WEBHOOK_TOKEN_PREVIOUS / AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT).
  2. If no acceptable token is configured → abort 401 (Webhook signing is not configured.).
  3. Require header X-AI-Webhook-Timestamp (unix seconds). Abort 401 if missing/non-digit or skew over 300s vs server clock.
  4. Read header X-AI-Webhook-Signature (sha256= + hex digest).
  5. Compute HMAC-SHA256 over "{timestamp}.{rawBody}" for each acceptable token (hash_equals).
  6. Abort 401 if none match (Invalid webhook signature.).
  7. Cache the signature as a nonce for 300s — a second delivery with the same signature aborts 401 (Webhook replay detected.).

Unsigned / mistimed / replayed requests are rejected in every environment — including local. There is no empty-token bypass. Set AI_WEBHOOK_TOKEN wherever you call the webhook (local included).

Changing any body byte (including collection_id inside the JSON) or the timestamp invalidates the digest. Bearer / X-AI-Webhook-Token alone is rejected.

Body (validated)

Expects a collection id, associative records array, and optional upsert_key. On success returns JSON with ok, collection_id, url, and import summary fields. Validation failures / import errors return 4xx JSON.

Breaking change (1.2.2)

From externa-core 1.2.2 (#214): sign the raw request body only (sha256= + HMAC-SHA256). The previous body + newline + collection_id payload from #86 is no longer accepted. Invalid or missing signatures return 401 (was 403).

No local unsigned bypass (1.3.0)

From externa-core 1.3.0 (#185 / #207): an empty AI_WEBHOOK_TOKEN fails closed in every APP_ENV, including local. Unsigned requests are never accepted.

No user context

The webhook has no interactive user; AI mutation logging is skipped for this path. Protect AI_WEBHOOK_TOKEN like a production secret.

Timestamp-bound HMAC (1.4.0)

From externa-core 1.4.0 (#273): send X-AI-Webhook-Timestamp and sign "{timestamp}.{rawBody}". Body-only HMAC is no longer accepted. Replayed signatures within the skew window are rejected via nonce.

POST /ai/webhooks/collection-import HTTP/1.1
Host: app.example.com
X-AI-Webhook-Timestamp: 1730380800
X-AI-Webhook-Signature: sha256=…
Content-Type: application/json
$rawBody = json_encode($payload, JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$signature = 'sha256='.hash_hmac(
    'sha256',
    $timestamp.'.'.$rawBody,
    env('AI_WEBHOOK_TOKEN'),
);
// Also send header X-AI-Webhook-Timestamp: $timestamp

Rotate the webhook token

Use php artisan ai:rotate-webhook-token (core#207) when the HMAC secret may have leaked or on a regular rotation cadence.

OptionPurpose
--grace=24hHow long the previous token stays accepted (30m, 24h, 7d, …). Default 24h.
--showPrint the new token once to stdout.
--write-envPersist AI_WEBHOOK_TOKEN, AI_WEBHOOK_TOKEN_PREVIOUS, and AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT into .env.
--forceRequired with --write-env when APP_ENV looks like production.

Pass --show and/or --write-env (otherwise the command exits without rotating). During the grace window, both current and previous tokens verify successfully.

# Local / staging — write .env and print the new value once
php artisan ai:rotate-webhook-token --grace=24h --write-env --show

# Production — write .env only with --force; prefer secret store + env inject when possible
php artisan ai:rotate-webhook-token --grace=24h --write-env --force --show

Docker / config:cache

After changing .env, run php artisan config:clear (or rebuild config:cache) and restart PHP-FPM / queue workers / containers so the new token is loaded. The command prints the same reminder. Stale cached config keeps verifying with the old secret.

Env keys: Environment variables — AI.


Authenticated group

Middleware stack for all routes below:

auth + verified + permission:can-use-ai

Prefix: /ai, name prefix: ai..

Status and shell

MethodPathRoute nameNotes
GET/aiai.indexInertia AI page shell
GET/ai/statusai.statusProvider / readiness JSON (AiStatusController)

Conversations list / create / show / destroy / pin / truncate / bulk-destroy

MethodPathRoute namePurpose
GET/ai/conversationsai.conversations.indexList conversations
POST/ai/conversationsai.conversations.storeCreate conversation
POST/ai/conversations/bulk-destroyai.conversations.bulk-destroyDelete many
GET/ai/conversations/{conversation}ai.conversations.showShow conversation + messages
DELETE/ai/conversations/{conversation}ai.conversations.destroyDelete one
POST/ai/conversations/{conversation}/pinai.conversations.pinToggle pin
POST/ai/conversations/{conversation}/truncateai.conversations.truncateTruncate message history

Persistence tables: agent_conversations, agent_conversation_messages (AI configuration).

Controller: AiConversationController.

Attachments

MethodPathRoute nameThrottle
POST/ai/attachmentsai.attachments.storethrottle:30,1

Uploads chat attachments (CSV/TXT/XLSX/PDF per assistant instructions). Expired files are cleaned by ai:cleanup-attachments.

Controller: AiChatAttachmentController.

Chat

MethodPathRoute nameThrottle
POST/ai/chatai.chatthrottle:30,1

Invokes AppAssistant with tools filtered by the user’s effective permissions. Optional daily cap: AI_DAILY_PROMPT_LIMIT (0 = unlimited).

Controller: AiChatController.

Import job status

MethodPathRoute nameExtra permission
GET/ai/import-jobs/{jobId}ai.import-jobs.showcan-create-collections

jobId must be a UUID. Used to poll async collection import jobs started from AI tools.

Controller: ImportJobStatusController.

Registered last so static /ai/* paths are not captured:

MethodPathRoute nameConstraint
GET/ai/{conversation}ai.show{conversation} UUID

Inertia page for opening a specific conversation (AiPageController@show).

Throttles summary

EndpointLimit
Webhook import30 requests / minute
Attachments store30 / minute
Chat30 / minute

Other authenticated AI routes rely on session auth + Fortify login throttles unless additional limiters are added.

Tool permissions (not HTTP middleware)

Even with can-use-ai, individual tools still call ChecksAiPermissions::requirePermission() for domain actions (files, collections, users, …). Users without matching effective permissions get English machine-error strings from tools (the model should translate them into the user’s language) instead of silent success. See Extending Externa.