HTTP reference
MarkdownAI 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)
| Method | Path | Route name | Middleware |
|---|---|---|---|
POST | /ai/webhooks/collection-import | ai.webhooks.collection-import | throttle: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).
- 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). - If no acceptable token is configured → abort 401 (
Webhook signing is not configured.). - Require header
X-AI-Webhook-Timestamp(unix seconds). Abort 401 if missing/non-digit or skew over 300s vs server clock. - Read header
X-AI-Webhook-Signature(sha256=+ hex digest). - Compute HMAC-SHA256 over
"{timestamp}.{rawBody}"for each acceptable token (hash_equals). - Abort 401 if none match (
Invalid webhook signature.). - 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.
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.
| Option | Purpose |
|---|---|
--grace=24h | How long the previous token stays accepted (30m, 24h, 7d, …). Default 24h. |
--show | Print the new token once to stdout. |
--write-env | Persist AI_WEBHOOK_TOKEN, AI_WEBHOOK_TOKEN_PREVIOUS, and AI_WEBHOOK_TOKEN_PREVIOUS_EXPIRES_AT into .env. |
--force | Required 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
| Method | Path | Route name | Notes |
|---|---|---|---|
GET | /ai | ai.index | Inertia AI page shell |
GET | /ai/status | ai.status | Provider / readiness JSON (AiStatusController) |
Conversations list / create / show / destroy / pin / truncate / bulk-destroy
| Method | Path | Route name | Purpose |
|---|---|---|---|
GET | /ai/conversations | ai.conversations.index | List conversations |
POST | /ai/conversations | ai.conversations.store | Create conversation |
POST | /ai/conversations/bulk-destroy | ai.conversations.bulk-destroy | Delete many |
GET | /ai/conversations/{conversation} | ai.conversations.show | Show conversation + messages |
DELETE | /ai/conversations/{conversation} | ai.conversations.destroy | Delete one |
POST | /ai/conversations/{conversation}/pin | ai.conversations.pin | Toggle pin |
POST | /ai/conversations/{conversation}/truncate | ai.conversations.truncate | Truncate message history |
Persistence tables: agent_conversations, agent_conversation_messages (AI configuration).
Controller: AiConversationController.
Attachments
| Method | Path | Route name | Throttle |
|---|---|---|---|
POST | /ai/attachments | ai.attachments.store | throttle:30,1 |
Uploads chat attachments (CSV/TXT/XLSX/PDF per assistant instructions). Expired files are cleaned by ai:cleanup-attachments.
Controller: AiChatAttachmentController.
Chat
| Method | Path | Route name | Throttle |
|---|---|---|---|
POST | /ai/chat | ai.chat | throttle: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
| Method | Path | Route name | Extra permission |
|---|---|---|---|
GET | /ai/import-jobs/{jobId} | ai.import-jobs.show | can-create-collections |
jobId must be a UUID. Used to poll async collection import jobs started from AI tools.
Controller: ImportJobStatusController.
Conversation deep link
Registered last so static /ai/* paths are not captured:
| Method | Path | Route name | Constraint |
|---|---|---|---|
GET | /ai/{conversation} | ai.show | {conversation} UUID |
Inertia page for opening a specific conversation (AiPageController@show).
Throttles summary
| Endpoint | Limit |
|---|---|
| Webhook import | 30 requests / minute |
| Attachments store | 30 / minute |
| Chat | 30 / 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.