The Zendesk API from PHP
Thirty lines of PHP will read a ticket. The next hundred are what stop a PHP Zendesk API sync dying quietly on a rate limit at three in the morning.
Auth and the smallest thing that works
Zendesk token auth is HTTP basic auth where the username is your agent email with /token appended and the password is the token. That is the whole scheme.
Credentials come from the environment. Never from source, and never from a config file that lives in the repository.
<?php
$subdomain = getenv('ZD_SUBDOMAIN');
$email = getenv('ZD_EMAIL');
$token = getenv('ZD_TOKEN');
$base = 'https://' . $subdomain . '.zendesk.com/api/v2';$ch = curl_init($base . '/tickets.json?page[size]=100');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_USERPWD => $email . '/token:' . $token,
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($body, true);That works, and you should not ship it. Raw cURL means you write retry logic, header parsing and error handling by hand in every script, and one of those scripts will get it wrong.
Set a timeout on every call. cURL will otherwise sit on a hung socket until somebody notices the cron job has not finished, which is usually three days.
A PHP Zendesk API client worth reusing
Guzzle is the sane default. Install it with Composer, then put every request through one method so retries live in exactly one place.
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;$http = new Client([
'base_uri' => $base . '/',
'auth' => [$email . '/token', $token],
'timeout' => 30,
'headers' => ['Content-Type' => 'application/json'],
]);Then the wrapper. This is the part that matters.
function call(Client $http, string $method, string $path, array $opts = []): array
{
for ($attempt = 0; $attempt < 6; $attempt++) {
try {
$res = $http->request($method, ltrim($path, '/'), $opts + ['http_errors' => false]);
} catch (RequestException $e) {
usleep((int) (pow(2, $attempt) * 1000000));
continue;
}
$status = $res->getStatusCode();
if ($status === 429) {
$wait = (int) ($res->getHeaderLine('Retry-After') ?: 60);
sleep($wait + 1);
continue;
}
if ($status >= 500) {
sleep((int) pow(2, $attempt));
continue;
}
if ($status >= 400) {
throw new RuntimeException($status . ' ' . (string) $res->getBody());
}
return json_decode((string) $res->getBody(), true) ?? [];
}
throw new RuntimeException('gave up after retries');
}Three deliberate choices in there. A 429 waits for exactly as long as Zendesk says, because guessing either wastes time or burns the next window. Server errors back off exponentially, since they are usually transient. And any other 4xx throws immediately with the response body attached, because a 422 will never fix itself and the body names the field that upset it.
http_errors => false is what lets you inspect the status instead of catching an exception for a perfectly ordinary 429.
Pagination that terminates
Modern Zendesk list endpoints use cursor pagination. Do not increment a page number, and do not stop when a page looks short. Follow the link the API hands you and stop when there's not one.
function paginate(Client $http, string $path, string $key): Generator
{
$url = $path . (str_contains($path, '?') ? '&' : '?') . 'page[size]=100';
while ($url) {
$data = call($http, 'GET', $url);
foreach ($data[$key] ?? [] as $item) {
yield $item;
}
$next = $data['links']['next'] ?? null;
$more = $data['meta']['has_more'] ?? false;
$url = ($more && $next) ? $next : null;
}
}Two details. has_more is the authoritative signal, since the next link can be present on the final page. And yielding rather than accumulating means you can stream a few hundred thousand tickets through a writer without the memory limit ending the conversation.
Passing the full next URL back into a Guzzle client that has a base_uri works because an absolute URL overrides the base. If you would rather be explicit, strip the base yourself.
For a whole-history export, don't paginate a list endpoint at all. Use the incremental export endpoints, which walk by timestamp, and check the current API reference for their exact paths and cursor behaviour. Persist your cursor after every successful page so a crash at hour six resumes at hour six.
Writing, and the traps
Creates and updates both wrap the payload in a ticket object. Miss the wrapper and you get a validation error that does not obviously say so.
$new = call($http, 'POST', 'tickets.json', ['json' => [
'ticket' => [
'subject' => 'Printer offline',
'comment' => ['body' => 'Reported by monitoring', 'public' => false],
'tags' => ['monitoring'],
],
]]);Now the trap that costs people a week. Sending tags on an update replaces the entire array. Every tag added by a trigger, a macro or an agent disappears, silently, and nobody notices until a view stops working. Use the dedicated add and remove tag parameters on an update instead of reading the array, appending and writing it back.
Custom fields arrive as a list of id and value pairs, and you set them the same way. Hard-coding numeric field IDs is fine for one private script and a liability for anything you distribute, since the IDs differ per account. Fetch the ticket fields once, build a name to ID map, cache it.
Comments are asymmetric. You read them from the comments endpoint and you write them by updating the ticket with a comment object. That asymmetry is the single most common reason a first integration doesn't work. The ticket API guide goes through it properly.
And closed tickets are immutable. Any job that tries to tag or update one will fail forever, so filter on status before you write.
Running it in production
A few things that aren't code but decide whether this survives.
Use a dedicated integration user in Zendesk, not a real agent. You will want to exclude its updates from triggers later, and you cannot do that if the account also answers tickets.
Set max_execution_time appropriately for CLI runs, or better, run long jobs from the CLI SAPI where it defaults to unlimited rather than from a web request that a load balancer will cut off at sixty seconds.
Log the request ID Zendesk returns on each response. When you open a support case about odd behaviour, that ID is the difference between a fast answer and a fortnight.
Rate limits are per account and vary by plan, so check the current API reference for your numbers rather than trusting a figure from a forum post. Run heavy jobs off-peak either way, because sharing a limit with the mobile app your agents are using is a bad way to spend an afternoon.
If you want to sanity check any of this interactively before scripting it, build the requests in Postman first.
Frequently asked questions
Is there a Zendesk PHP example worth copying?
Start with curl. A Zendesk API curl PHP snippet using basic auth is the whole first step, and any Zendesk REST API PHP client you build after that is error handling and pagination around it.
How do I authenticate the Zendesk API in PHP?
Basic auth with your email plus /token as the username and the API token as the password. In Guzzle that's the auth option; in cURL it is CURLOPT_USERPWD.
Is there an official PHP SDK for Zendesk?
Community libraries exist and their maintenance varies. A thin Guzzle wrapper with retries and cursor pagination is around a hundred lines and you will understand every one of them.
How does pagination work?
Modern list endpoints use cursor pagination. Follow the next link and trust the has_more flag, since the next link can still be present on the final page.
Why did my update wipe the tags?
Sending the tags array on an update replaces the whole set. Use the dedicated add and remove tag parameters instead of read, append and write.
What should I do on a 429?
Sleep for the Retry-After value the response gives you, then retry. Do not guess a fixed delay, and do not retry immediately, because that just extends the lockout.
Your script will create the duplicate cheerfully
The API has no opinion on whether that ticket already exists. Ticket Merger watches the queue and merges the pairs before two agents answer them.
Start free trial14-day free trial. No credit card required.