> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pesahub.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time payment and payout event notifications and verify their authenticity using HMAC-SHA256 signatures.

## Introduction

PesaHub notifies your application of payment and payout status changes by sending an HTTP `POST` request to the `callback_url` configured on your API token. Each request body is a JSON payload with an `event` field identifying what happened.

**Payment events:**

* `payment.received` — a payment completed successfully
* `payment.failed` — a payment attempt failed

**Payout events:**

* `payout.completed` — a payout was paid out to the beneficiary
* `payout.failed` — a payout failed
* `payout.refunded` — a payout was refunded by the provider
* `payout.reversed` — a payout was reversed by the provider

Use the `event` field to branch your handling logic. Your endpoint should respond with a `2xx` status once it has accepted the webhook — a non-2xx response or timeout is treated as a failed delivery and retried.

<Warning>
  Because deliveries can be retried, your handler must be idempotent. A redelivered webhook carries the same payload, so don't assume you'll only ever see an event once.
</Warning>

## Checksum verification

Every delivery is signed with an HMAC-SHA256 signature so you can confirm a webhook is genuinely came from PesaHub and wasn't altered in transit.

### Setting up your passphrase

Signing is enabled per API token. Set a **passphrase** on the token under **Settings > API Access** — this is the secret key used to sign every webhook delivered for that token.

<Note>
  If no passphrase is set, webhooks are still sent to your `callback_url`, but without a signature.
</Note>

### The signature header

Signed webhooks include a `Pesahub-Signature` header alongside the JSON body:

```http theme={null}
POST {callback_url} HTTP/1.1
Content-Type: application/json
Pesahub-Signature: 3f9c1a...e4b2

{"event":"payment.failed","transaction_id":"...", ...}
```

### The algorithm

The signature is computed over the JSON payload as follows:

<Steps>
  <Step title="Canonicalize">
    Recursively sort object keys alphabetically at every nesting level. Sequential (list) arrays keep their original order; only associative objects are sorted.
  </Step>

  <Step title="Serialize">
    JSON-encode the canonicalized payload with slashes unescaped and no extra whitespace.
  </Step>

  <Step title="Hash">
    Compute `HMAC-SHA256(json, passphrase)` and take the 64-character hex digest.
  </Step>
</Steps>

```php theme={null}
function canonicalize($data) {
    if ($data === null || !is_array($data)) {
        return $data;
    }

    if (array_values($data) === $data) {
        return array_map('canonicalize', $data);
    }

    ksort($data);
    $result = [];
    foreach ($data as $key => $value) {
        $result[$key] = canonicalize($value);
    }
    return $result;
}

function generateSignature(string $passphrase, array $payload): string {
    $canonical = canonicalize($payload);
    $json = json_encode($canonical, JSON_UNESCAPED_SLASHES);

    return hash_hmac('sha256', $json, $passphrase);
}
```

<Tip>
  Because canonicalization sorts keys recursively, the signature is stable regardless of field order — just decode the JSON and recompute over the resulting data.
</Tip>

### Verifying a webhook

<Steps>
  <Step title="Read the request">
    Read the raw JSON body and the `Pesahub-Signature` header.
  </Step>

  <Step title="Decode the payload">
    Decode the JSON body into an associative array/object.
  </Step>

  <Step title="Recompute the signature">
    Recompute the signature over the payload using your `passphrase` as the key.
  </Step>

  <Step title="Compare signatures">
    Compare the recomputed value against `Pesahub-Signature` using a **timing-safe** comparison (e.g. `hash_equals` in PHP).
  </Step>

  <Step title="Reject on mismatch">
    Reject the request (401/403) if they don't match, or if the header is missing while you have a passphrase configured.
  </Step>
</Steps>

```php theme={null}
$payload = json_decode(file_get_contents('php://input'), true);
$receivedSignature = $_SERVER['HTTP_PESAHUB_SIGNATURE'] ?? null;

if (empty($receivedSignature) || !hash_equals(generateSignature($passphrase, $payload), $receivedSignature)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Signature verified - safe to process $payload
```

<Warning>
  Always verify the signature before acting on the payload (e.g. before crediting an order or updating a payout status).
</Warning>

### Notes

* If you rotate your passphrase, PesaHub immediately starts signing new deliveries with the new value — update your verification code at the same time.
* Retried webhooks carry the same payload and therefore the same signature — a repeated signature does not indicate a replay attack.
