EthicsComputerCourses
Join training +91 90532 10052

HTTP Clients & Webhooks

PHP · Lesson 58 of 63 Advanced 8 min read Updated

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

Terminal
composer require guzzlehttp/guzzle

Create 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
<?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";
}
Output
200 application/json
Jaipur Mumbai Superfast: 37 seats left
Client error 404: {"error":"Train not found"}
GuzzleHttp\Exception\NetworkTimeoutException

Note 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:

ExceptionWhenRetry?
ClientException4xx: your request was wrong (bad key, not found, invalid data)No, fix the request
ServerException5xx: the other server failedYes, after a pause
NetworkException and its subclassesNo response at all: DNS, connection or timeoutYes, usually
GuzzleException (interface)Any Guzzle errorCatch-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
<?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";
Output
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
<?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";
Output
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
<?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));
Output
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
<?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 raw php://input bytes.
  • Comparing signatures with == or ===. Use hash_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, timeout and connect_timeout.
  • 4xx means fix the request; 5xx and network failures can be retried with exponential backoff.
  • Use the json option 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
PHP
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']}";
}
Output
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?

2. Which failure should you NOT retry automatically?

3. Why must a webhook endpoint verify the signature against the raw request body?

4. Why store processed webhook event ids?

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