SUQODocs
User GuideAPI ReferenceSDKs

Handle webhooks

Verifying inbound webhook deliveries, and managing webhook endpoints, with the SUQO PHP SDK.

Two unrelated things share the word: verifying a delivery (Suqo\Webhook::verify(), below) needs no client, no API key and no network; managing endpoints ($suqo->webhooks->*, see Managing webhook endpoints) is the eight webhooks_* API operations. They meet at one point — $suqo->webhooks->secret(), in the method table below, returns the signing secret that verify() takes.

Suqo\Webhook::verify() confirms an inbound delivery genuinely came from SUQO and has not been tampered with or replayed. It needs no client, no API key and no network, so it can be called straight from a serverless handler. It never throws: every failure path, malformed input included, returns false.

A complete handler

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Suqo\Webhook;

$raw = file_get_contents('php://input');       // the exact bytes received

if ($raw === false) {
    http_response_code(400);
    exit;
}

$verified = Webhook::verify(
    rawBody: $raw,
    signature: $_SERVER['HTTP_X_SUQO_SIGNATURE'] ?? null,
    timestamp: $_SERVER['HTTP_X_SUQO_TIMESTAMP'] ?? null,
    secret: (string) getenv('SUQO_WEBHOOK_SECRET'),
);

if (!$verified) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);   // parse only after verifying

// Acknowledge fast, then process out of band.
http_response_code(200);

SuqoClient::verifyWebhook() is a static alias with identical parameters and behaviour, for a handler that already imports the client. Prefer Webhook::verify() where it does not — it pulls in one small class instead of the whole entry point.

Pass the raw bytes

A body re-serialised from a parse verifies only by luck: the signature covers bytes, not structure, and a round-trip through a JSON decoder changes whitespace, key order, escaping and number formatting. A trivial payload can survive that round-trip and pass in a test, then fail on the first real event with a nested object or unicode in it.

// Wrong: verification will fail even for a genuine event.
Webhook::verify(rawBody: json_encode($request->all()), /* … */);

Reach for the untouched body: $request->getContent() in Symfony or Laravel, (string) $request->getBody() on a PSR-7 request, php://input in plain PHP. If a middleware has already consumed the stream, capture the bytes before it does.

Replay protection

The signed payload is the timestamp, a literal ., then the body — "{timestamp}.{rawBody}" — and the comparison is hash_equals on decoded bytes, which is constant-time.

maxAge (default 300 seconds) widens only the backward window. An event whose timestamp is more than 60 seconds in the future fails regardless of maxAge, which is what catches a badly skewed sender rather than a slow queue.

// A retry queue that can sit for ten minutes.
Webhook::verify($raw, $sig, $ts, $secret, maxAge: 600);

What is checked, in order

Each step returns false on failure; none raises.

  1. signature and timestamp are both non-null.
  2. timestamp is numeric after trimming, and finite.
  3. now - timestamp <= maxAge — not too old.
  4. timestamp - now <= 60 — not too far in the future.
  5. signature starts with sha256=.
  6. The part after it is exactly 64 characters, and valid hex.
  7. HMAC-SHA256 over the signed payload matches.

Because every failure is a false rather than an exception, treat a false as "reject the delivery" — it does not tell you which of the seven checks failed, deliberately.

Managing webhook endpoints

$suqo->webhooks registers and edits the endpoints SUQO delivers to. Nothing here verifies anything — Webhook::verify() above is the other half.

An account may hold one webhook per event. The events are the cases of Suqo\Model\WebhookEvent: checkout.succeeded, checkout.failed, subscription.status_changed, api_key.created, api_key.deleted, api_key.expiring_soon, api_key.expired. An event the SDK does not yet name still reads back — as the raw string rather than a case — and can still be registered by passing that string.

Before an endpoint is stored it must use https, resolve, and resolve only to public addresses — every address the name answers with is checked, so a host with both a public and a loopback record is refused. Delivery re-runs the same check, because DNS can be re-pointed after this one passes.

use Suqo\Model\WebhookEvent;
use Suqo\Params\WebhookParams;
use Suqo\Params\WebhookUpdateParams;

// Register.
$webhook = $suqo->webhooks->create(new WebhookParams(
    event: WebhookEvent::CheckoutSucceeded,
    endpointUrl: 'https://partner.example.com/hooks/suqo',
));

// List every endpoint on the account — the one unpaginated collection on the
// API, so this is a bare array with no auto-paging.
foreach ($suqo->webhooks->list() as $endpoint) {
    printf("%s  %s  %s\n", $endpoint->id, $endpoint->endpointUrl, $endpoint->isActive ? 'on' : 'off');
}

// Pause deliveries without deleting the subscription — a body of one field.
$suqo->webhooks->update($webhook->id, new WebhookUpdateParams(isActive: false));

// Fetch the account's signing secret — minted on first read, not rotatable
// through this API. Fetch it once at deploy time; verification itself must
// not need the network.
$secret = $suqo->webhooks->secret()->signingSecret;

// Enqueue a signed test payload to confirm your receiver. The 202 means
// queued, not delivered — the delivery itself is asynchronous.
$suqo->webhooks->testDelivery($webhook->id);

$suqo->webhooks->delete($webhook->id);
MethodDoesThrows
list()Every registered endpoint — list<WebhookEndpoint>, not paginated
create()Subscribe an endpoint to one eventValidationError when the event is taken, or the endpoint fails the https/resolve check
read()One endpoint by idNotFoundError
replace()Overwrite the whole record (PUT) — both fields required, an omitted one is not preserved
update()Change only what you set (PATCH)
delete()Remove the subscription — 204, nothing returned. Past delivery records are kept for debugging
secret()The account's signing secret
testDelivery()Queue a signed test payloadReturns Suqo\Model\DetailResponse — detail, not message

replace and update are named apart deliberately: replace() is the PUT — a field you leave out is not preserved — and update() is the one-field PATCH.

None of these writes are retried automatically — see Not yet implemented.

On this page