Zendesk AI Integration with Laravel
Published: June 27, 2026
- #dev
- #laravel
- #zendesk
- #ai
- #api
- #sanctum
The situation: a customer writes to support asking why the hell their order still hasn’t shipped. The support person opens the ticket in Zendesk, then goes into the admin panel, looks up the order by email or some other ritual, checks the status, goes back to Zendesk, and replies with something like “your order is currently in status blah-blah-blah… so please wait another week.” The customer says they don’t want to wait, they want to cancel the order. The support person goes back into admin again, cancels the order, and then returns to Zendesk to write yet another message saying the order has been cancelled. Slow, tedious, spiritually draining.
This is where AI comes in: delegate the routine to an agent that can query the backend, check the status, write the reply, and cancel the order on its own. And the support person is no longer needed - we fire them can focus on more complicated tickets.
How do you wire that up? Every second large SaaS platform now ships its own AI agent with MCP or third-party tool support. Zendesk now also lets you build custom agents and workflows vaguely reminiscent of n8n. You can train the agent on the knowledge base and let it talk to external services - for example, an online store - through custom actions. But for that to work, your application needs to provide the other half: an API.
And I happened to have exactly this task recently: integrate an existing Laravel app with a Zendesk AI agent.
High-level flow
The scheme looks like this:
- In the application - say, an online store - an admin creates tokens with the necessary permissions.
- A Zendesk connection stores those tokens and uses them for API calls.
- Zendesk custom actions describe the available API operations and use the connection for authorization.
- Zendesk custom agents describe the agents and the actions they can invoke.
- Zendesk workflows describe scenarios for those agents. For example, run the agent when a new ticket is created.
- During execution, the agent invokes the required custom actions and sends requests to the API.
- The application validates the token and its permissions.
- The API returns JSON to the agent, which then continues the scenario. For example, it writes a reply to the user.
Why an API instead of MCP? For this type of task, either could work, but at the time I implemented it Zendesk AI only worked with APIs. Which is honestly fine. MCP is still young and not particularly polished, while APIs are ancient and battle-tested. They are easier to reuse for other purposes too, unlike MCP, which mostly exists for AI agents.
Another nice thing about a regular API is that you can test it without Zendesk at all. Take the token, hit the endpoint with curl or Postman, inspect the JSON.
Minimal backend side
Now we get to the technical bit. This is not a full tutorial - I just want to show the main layers in this kind of integration: tokens, abilities, routes, controllers, and JSON responses.
For authentication in Laravel, we’ll use Sanctum:
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
The model that issues the tokens should use HasApiTokens:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
Yes, technically the tokens are tied to users or some other model, and that may feel slightly awkward. For a simple e-commerce app it might seem like it would be easier to issue detached tokens and call it a day. But tying them to a model has useful advantages later - for example, you can log which user a request was executed under.
The next layer is permissions. Build them with future growth in mind: each operation gets its own ability.
enum ApiAbility: string
{
case ORDERS_READ = 'orders.read';
case ORDERS_CANCEL = 'orders.cancel';
}
That way a token can be limited to only the actions a specific agent actually needs. You can separate access so that one agent can only read data through a safe token, while another, more trusted one, can perform side effects - writes, emails, cancellations, and so on.
Token creation looks roughly like this:
$token = $user->createToken(
name: 'Zendesk AI',
abilities: [
ApiAbility::ORDERS_READ->value,
ApiAbility::ORDERS_CANCEL->value,
],
);
$plainTextToken = $token->plainTextToken;
Sanctum only shows the plaintext token once. The database stores a hash, so in the admin UI you need to display the token immediately after creation and let people copy it. The token management UI can be anything - in our case it was a Nova resource. No point describing it here. What’s important is that the UI should have checkboxes or something similar to limit abilities. For a simple integration, you can just generate the token once in code and skip a UI entirely.
It’s also useful to store the token name, creation date, last-used date, and a revoke button. A month later nobody will remember which exact token is sitting inside the Zendesk connection and whether it’s safe to delete.
After that, we can add the API endpoints. For the example, let’s use two tool types: reading order data and canceling an order.
Route::prefix('v1')
->middleware('auth:sanctum')
->group(function () {
Route::get('orders/{number}', [OrderController::class, 'show'])
->middleware('abilities:orders.read');
Route::post(
'orders/{number}/cancel',
[OrderCancelController::class, 'store'],
)->middleware('abilities:orders.cancel');
});
A resource endpoint should return only the data the agent actually needs. Dumping the whole model into the response is a bad idea. If the model later grows a field the client shouldn’t see - for example, an internal comment that the customer is insufferable - it may leak through the agent. In Laravel, Resources are convenient here, but we’ll skip extra layers for brevity:
public function show(string $number): JsonResponse
{
$order = Order::query()
->where('number', $number)
->first();
if (! $order) {
return response()->json([
'success' => false,
'message' => "Order with number {$number} not found",
], 404);
}
return response()->json([
'data' => [
'number' => $order->number,
'status' => $order->status,
],
]);
}
Example response:
{
"data": {
"number": "AAA111",
"status": "active"
}
}
An action endpoint describes the result of an operation. In the simplest version, the API performs the action immediately, and the agent gets a success flag plus a human-readable message it can reuse when replying to the user.
public function store(string $number): JsonResponse
{
$order = Order::query()
->where('number', $number)
->firstOrFail();
try {
CancelOrder::dispatch($order);
return response()->json([
'success' => true,
'message' => 'The order cancellation has been queued.',
]);
} catch (Throwable $exception) {
report($exception);
return response()->json([
'success' => false,
'message' => 'Order cancellation failed.',
], 500);
}
}
One important disclaimer here: the safety of the actions your API exposes is entirely the responsibility of whoever writes the code, not the agent that calls it. In the example above, we accept only an order number, but we do not validate which user is requesting the cancellation. In a real application, you need to verify that the order actually belongs to the user on whose behalf the agent is acting. How exactly to do that depends on your case and your workflow setup.
It is also worth normalizing errors into a predictable JSON format:
{
"success": false,
"message": "Order with number AAA111 not found"
}
To force the API to always return JSON, use a ForceJsonResponse middleware in app/Http/Kernel.php:
protected $middlewareGroups = [
'api' => [
\App\Http\Middleware\ForceJsonResponse::class,
...
],
];
The minimum set of expected statuses is usually something like this:
401- token is missing or invalid;403- token lacks the required ability;404- order or other entity was not found;200- request or action completed successfully;500- unexpected server-side error.
For an AI agent, 403 and 404 are especially important. If everything turns into the same vague “something went wrong,” the agent may start retrying or inventing bizarre replies for the user. Better to distinguish the cases immediately: the token isn’t allowed to perform the action, the order doesn’t exist, or the action is blocked by business rules.
If you look at it as a list of application changes, the set is roughly this: install and configure Sanctum, add abilities for the external API, implement token issuance, add protected API endpoints, and prepare controllers/resources for specific scenarios. You may also need minor Sanctum config tuning for custom guards if you use them:
// config/sanctum.php
return [
'guard' => ['office'],
...
];
Zendesk-side setup
Once the API is ready, on the Zendesk side you need to connect two layers: the connection and the custom actions. The connection handles authentication, and the custom actions describe the concrete tools the AI agent can use.
First create a personal token in Laravel and add it in Zendesk Admin Center: Admin center -> Apps and integrations -> Connections. For this case we use the bearer token authentication type. The form also requires an Allowed Domain with mandatory https, so for testing you may want ngrok or Cloudflare Tunnel. Then, while creating custom actions, you simply pick the ready-made connection with the token. You don’t need to paste the same token over and over for each endpoint, which is convenient.
Now you can describe the agent actions under Admin center -> Apps and integrations -> Custom actions.
In the custom action form, you fill in the URL and describe the input parameters. This part matters because the agent actually sees those descriptions, names, and help texts. That’s how it understands which value to extract from the ticket or user message and put into the request. It’s basically tool-use parameter descriptions, just rendered through a GUI.
Parameter descriptions should be written as if for an intern who has never seen the ticket before. Not just id, but order number, usually looks like AAA111. The more specific the description, the lower the chance that the agent inserts nonsense.
For fetching order data, the setup might look like this:
Input:
- name:
number; - description:
order number, e.g. AAA111.
Request:
- method:
GET; - URL:
https://api.example.com/api/v1/orders/{{number}}; - authentication: the previously created bearer token connection;
- body, query parameters, and headers: empty.
Output:
- name:
data; - description:
order details.
The second action is order cancellation.
Input:
- name:
number; - description:
order number, e.g. AAA111.
Request:
- method:
POST; - URL:
https://api.example.com/api/v1/orders/{{number}}/cancel; - authentication: the same bearer token connection;
- body, query parameters, and headers: empty.
For this kind of action, you don’t necessarily need to define a structured output. Zendesk will still receive the HTTP status and JSON with success and message, and the agent can use that message inside its workflow.
Before wiring actions into the agent, test the API manually:
curl -sS "https://api.example.com/api/v1/orders/AAA111" \
-H "Accept: application/json" \
-H "Authorization: Bearer ***"
curl -sS -X POST \
"https://api.example.com/api/v1/orders/AAA111/cancel" \
-H "Accept: application/json" \
-H "Authorization: Bearer ***"
After that, you can throw together a test custom agent and workflow to try it on real tickets - just in test mode. Go to Admin Center -> AI -> Custom agents, create an agent, and describe its main instruction.
What we got in the end
In practice, the whole integration boiled down to a regular Sanctum-backed API. No special Zendesk SDKs were needed. Zendesk calls an HTTP endpoint, Laravel validates the token and returns JSON. The only difference is that the client here is not a frontend or a mobile app - it’s an AI agent.