HTTP Clients & Webhooks
Modern PHP apps rarely work alone. A shop calls a payment gateway, an SMS provider and a courier's tracking API; those services call back with webhooks when a payment succeeds or a parcel is delivered. Talking to other servers is where many production bugs live: slow APIs freeze your pages, failed calls lose orders, and unverified webhooks let attackers mark unpaid orders as paid.
JSON & APIs showed a basic request with cURL. In this lesson you will use Guzzle, the most widely used PHP HTTP client, to call APIs with proper timeouts, send JSON, handle every kind of failure and retry safely. Then you will build the receiving side: a webhook endpoint that checks an HMAC signature, rejects replays and processes each event only once. The examples call a small practice API running on the local machine; real APIs work the same way.
Setting up a client
composer require guzzlehttp/guzzleCreate one client per external service, with its base URL and defaults. Guzzle's client implements the PSR-18 interface from PSR Standards in Practice.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\NetworkException;
$api = new Client([
'base_uri' => 'http://127.0.0.1:18931/v1/',
'timeout' => 5,
'connect_timeout' => 2,
'headers' => ['Accept' => 'application/json'],
]);
$response = $api->get('trains/12956');
$train = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
echo $response->getStatusCode(), ' ', $response->getHeaderLine('Content-Type'), "\n";
echo "{$train['name']}: {$train['seats_left']} seats left\n";
try {
$api->get('trains/99999');
} catch (ClientException $e) {
echo 'Client error ', $e->getResponse()->getStatusCode(), ': ', $e->getResponse()->getBody(), "\n";
}
try {
$api->get('slow', ['timeout' => 1]);
} catch (NetworkException $e) {
echo $e::class, "\n";
}200 application/json
Jaipur Mumbai Superfast: 37 seats left
Client error 404: {"error":"Train not found"}
GuzzleHttp\Exception\NetworkTimeoutExceptionNote the trailing slash in base_uri and no leading slash in 'trains/12956'; that combination joins the two parts as you expect. timeout limits the whole request and connect_timeout limits connecting. By default Guzzle sets no limit on the total request time, so always set both. A payment page that hangs for two minutes because a courier API is slow is worse than one that shows "tracking is unavailable right now".
Kinds of failure
By default Guzzle throws an exception for every response with status 400 or above. Knowing which exception you got tells you what to do next:
| Exception | When | Retry? |
|---|---|---|
ClientException | 4xx: your request was wrong (bad key, not found, invalid data) | No, fix the request |
ServerException | 5xx: the other server failed | Yes, after a pause |
NetworkException and its subclasses | No response at all: DNS, connection or timeout | Yes, usually |
GuzzleException (interface) | Any Guzzle error | Catch-all for logging |
These class names are from Guzzle 8. In Guzzle 7, which you will still meet in many projects, connection problems and timeouts throw ConnectException instead. Catching GuzzleHttp\Exception\TransferException works in both versions for "anything went wrong with the transfer".
When a non-2xx status is a normal answer for you, turn exceptions off for that call with 'http_errors' => false and check getStatusCode() yourself.
Sending JSON and avoiding duplicates
The json option encodes an array, sets the Content-Type: application/json header and sends it:
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
$sms = new Client(['base_uri' => 'http://127.0.0.1:18931/v1/', 'timeout' => 5]);
$response = $sms->post('sms', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('SMS_API_KEY'),
'Idempotency-Key' => 'order-1042-confirmation',
],
'json' => ['to' => '+9198290xxxxx', 'text' => 'Order #1042 confirmed'],
]);
echo $response->getStatusCode(), "\n";
print_r(json_decode((string) $response->getBody(), true));
$response = $sms->post('sms', [
'headers' => ['Authorization' => 'Bearer wrong'],
'json' => ['to' => '+9198290xxxxx', 'text' => 'Hi'],
'http_errors' => false,
]);
echo $response->getStatusCode(), ' ', $response->getBody(), "\n";202
Array
(
[id] => msg_7f3a
[to] => +9198290xxxxx
[status] => queued
[idempotency_key] => order-1042-confirmation
)
401 {"error":"Bad key"}The API key comes from the environment, never from the code. The idempotency key matters for anything that costs money or messages a customer. If your request times out, you do not know whether the provider received it. Retrying might send the SMS (or charge the card) twice. Many payment and messaging APIs accept a header such as Idempotency-Key: requests with the same key are carried out only once. Check your provider's documentation for the exact header name; not every API supports it.
Retrying with backoff
Temporary failures (a 503 "busy" answer, a dropped connection) often succeed a moment later. Retry them a few times, waiting longer each time, which is called exponential backoff. Never retry 4xx errors; they will fail the same way again.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\NetworkException;
use GuzzleHttp\Exception\ServerException;
use Psr\Http\Message\ResponseInterface;
/**
* Runs $request, retrying on 5xx answers and network failures with growing pauses.
*
* @param callable(): ResponseInterface $request
*/
function withRetry(callable $request, int $attempts = 3, int $firstDelayMs = 200): ResponseInterface
{
for ($try = 1; ; $try++) {
try {
return $request();
} catch (ServerException | NetworkException $e) {
if ($try >= $attempts) {
throw $e;
}
$delayMs = $firstDelayMs * 2 ** ($try - 1); // 200, 400, 800 ...
echo "Attempt $try failed ({$e->getCode()}), waiting {$delayMs} ms\n";
usleep($delayMs * 1000);
}
}
}
$api = new Client(['base_uri' => 'http://127.0.0.1:18931/v1/', 'timeout' => 5]);
$response = withRetry(fn() => $api->get('flaky'));
echo $response->getStatusCode(), ' ', $response->getBody(), "\n";Attempt 1 failed (503), waiting 200 ms
Attempt 2 failed (503), waiting 400 ms
200 {"ok":true,"attempt":3}In a web request, keep retries short, because the visitor is waiting. Slow or important work (sending invoices, syncing stock) belongs in a background job that can retry for longer; see Command-Line Scripts & Cron Jobs. Guzzle also offers retry middleware (GuzzleHttp\Middleware::retry()) that applies the same idea to every request of a client.
Receiving webhooks safely
A webhook is the reverse direction: the provider sends an HTTP POST to a URL on your site when something happens, for example payment.captured. Your endpoint is public, so anyone can post fake events to it. Providers therefore sign each webhook: they compute an HMAC (a keyed hash) of the request body with a secret only you and they know, and send it in a header. You recompute it and compare.
The exact header format differs between providers, so always follow their documentation. A common pattern includes a timestamp in the signed text, so old messages cannot be replayed later:
<?php
declare(strict_types=1);
function verifyWebhook(
string $body,
string $signatureHeader,
#[\SensitiveParameter] string $secret,
int $now,
int $toleranceSeconds = 300,
): bool {
// Header format used in this example: "t=<unix time>,v1=<hex HMAC>"
parse_str(str_replace(',', '&', $signatureHeader), $parts);
$timestamp = filter_var($parts['t'] ?? null, FILTER_VALIDATE_INT);
$signature = $parts['v1'] ?? '';
if ($timestamp === false || !is_string($signature) || abs($now - $timestamp) > $toleranceSeconds) {
return false; // missing, malformed or too old
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
return hash_equals($expected, $signature);
}
$secret = 'whsec_demo_only';
$body = '{"event":"payment.captured","id":"evt_501","order":1042,"amount":129900}';
$sentAt = 1793520000;
$header = 't=' . $sentAt . ',v1=' . hash_hmac('sha256', $sentAt . '.' . $body, $secret);
var_dump(verifyWebhook($body, $header, $secret, now: $sentAt + 4));
var_dump(verifyWebhook(str_replace('129900', '1', $body), $header, $secret, now: $sentAt + 4));
var_dump(verifyWebhook($body, $header, $secret, now: $sentAt + 3600));
var_dump(verifyWebhook($body, 'garbage', $secret, now: $sentAt));bool(true) bool(false) bool(false) bool(false)
The genuine message passes. A changed amount, a message replayed an hour later, and a garbage header all fail. hash_equals() compares in constant time, so an attacker cannot discover the signature byte by byte from response timings.
The endpoint itself reads the raw body (the signature covers the exact bytes, so never re-encode the JSON first), verifies it, and records each event id so that a repeated delivery is ignored. Providers retry webhooks when they do not get a quick 2xx answer, so duplicates are normal:
<?php
declare(strict_types=1);
// public/webhooks/payments.php
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!verifyWebhook($body, $header, getenv('PAYMENT_WEBHOOK_SECRET'), time())) {
http_response_code(400);
exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
// processed_events.event_id has a UNIQUE index
$stmt = db()->prepare('INSERT IGNORE INTO processed_events (event_id) VALUES (:id)');
$stmt->execute(['id' => $event['id']]);
if ($stmt->rowCount() === 1 && $event['event'] === 'payment.captured') {
markOrderPaid((int) $event['order'], (int) $event['amount']);
}
http_response_code(200);
echo 'ok';INSERT IGNORE adds the id only if it is new, so rowCount() is 1 the first time and 0 for a duplicate. Answer within a few seconds; if processing is slow, save the event and let a background job do the work.
Common mistakes
- No timeout. One slow API can make every page of your site hang.
- Retrying 4xx errors or retrying payments without an idempotency key. You get the same error again, or charge twice.
- Verifying a re-encoded body.
json_encode(json_decode($body))can change spacing or escaping; sign-check the rawphp://inputbytes. - Comparing signatures with
==or===. Usehash_equals(). - Doing slow work before answering a webhook. The provider times out and sends the event again.
Key points
- Create one Guzzle client per service with
base_uri,timeoutandconnect_timeout. - 4xx means fix the request; 5xx and network failures can be retried with exponential backoff.
- Use the
jsonoption to send JSON and an idempotency key for requests that must not run twice. - Webhooks must be verified with an HMAC of the raw body, a timestamp check and
hash_equals(). - Record processed event ids so duplicate deliveries are ignored, and answer quickly.
Exercise
Write a function trackParcel(Client $courier, string $awb): string that calls GET shipments/{awb} and returns a short status line. It should return "Tracking number not found" for a 404, "Courier service unavailable, try later" after retrying a 5xx or network failure twice with withRetry(), and otherwise "<awb>: <status> at <city>" from JSON like {"status":"In transit","city":"Pune"}.
Show solution
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\NetworkException;
use GuzzleHttp\Exception\ServerException;
function trackParcel(Client $courier, string $awb): string
{
try {
$response = withRetry(fn() => $courier->get('shipments/' . rawurlencode($awb)), attempts: 3);
} catch (ClientException $e) {
if ($e->getResponse()->getStatusCode() === 404) {
return 'Tracking number not found';
}
throw $e;
} catch (ServerException | NetworkException) {
return 'Courier service unavailable, try later';
}
$data = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
return "$awb: {$data['status']} at {$data['city']}";
}AWB778812: In transit at Pune
withRetry() only retries server and network errors, so a 404 reaches the first catch at once. rawurlencode() keeps an unusual tracking number from breaking the URL.
Quick Quiz
1. What does Guzzle do by default when an API answers with status 404?
With the default http_errors setting, 4xx answers throw ClientException and 5xx throw ServerException.
2. Which failure should you NOT retry automatically?
4xx errors mean the request itself is wrong; sending it again gives the same result.
3. Why must a webhook endpoint verify the signature against the raw request body?
Even a changed space or escape character produces a different HMAC.
4. Why store processed webhook event ids?
Recording ids makes the handler idempotent: duplicates are recognised and ignored.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
