> For the complete documentation index, see [llms.txt](https://docs-sdk.crypto-chief.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs-sdk.crypto-chief.com/processing/php/guides/webhooks.md).

# Webhooks

Verify and handle Crypto Chief webhooks in PHP with typed event payloads.

Webhooks are signed with your API key using HMAC-SHA256.

| Header               | Value                                                                               |
| -------------------- | ----------------------------------------------------------------------------------- |
| `X-Webhook-Delivery` | Delivery id, 1–128 characters `[A-Za-z0-9_-]`; the same on every attempt and resend |
| `X-CC-Timestamp`     | Unix time of the attempt, seconds                                                   |
| `X-CC-Signature`     | `v1=` + 64 hex characters                                                           |

The string to sign is four lines joined with `\n`, with no line break after the last one:

```
CC-HMAC-SHA256-WEBHOOK-V1
<X-CC-Timestamp>
<X-Webhook-Delivery>
<lowercase hex SHA-256 of the body>
```

`X-CC-Signature` is `v1=` followed by the lowercase hex HMAC-SHA256 of that string under your API key.

`body` is the raw request body. `Webhook::verify()` checks, in order: the three headers, the timestamp window (300 seconds), the signature (constant-time, hex in any case).

A retry or resend carries the same `X-Webhook-Delivery` and a new `X-CC-Timestamp`. Deduplicate by the delivery id — see [Webhook deliveries](/processing/php/guides/webhook-deliveries.md#keep-the-delivery-id).

## Laravel

```php
use CryptoChief\Processing\Exception\WebhookVerificationException;
use CryptoChief\Processing\Webhook;
use CryptoChief\Processing\Webhook\PayInEvent;
use CryptoChief\Processing\Webhook\PayoutEvent;
use Illuminate\Http\Request;

Route::post('/webhook', function (Request $request) {
    $raw = $request->getContent();                            // EXACT bytes — do not re-encode

    try {
        $evt = Webhook::parseEvent(env('API_KEY'), $raw, $request->headers->all());
    } catch (WebhookVerificationException) {
        abort(401, 'webhook verification failed');
    }

    if ($evt instanceof PayInEvent && $evt->status === 'paid') {
        // invoice.paid — fulfill the order for $evt->orderId
    } elseif ($evt instanceof PayoutEvent) {
        // payout.paid / payout.system_fail — reconcile your ledger
    }
    return ['ok' => true];
});
```

## Symfony

```php
use CryptoChief\Processing\Webhook;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

public function webhook(Request $request): Response
{
    $raw = $request->getContent();                            // string of raw bytes

    try {
        $evt = Webhook::parseEvent($_ENV['API_KEY'], $raw, $request->headers->all());
    } catch (\CryptoChief\Processing\Exception\WebhookVerificationException) {
        return new Response('webhook verification failed', 401);
    }
    // dispatch $evt to your handler...
    return new Response('ok');
}
```

## Plain PHP

For framework-less endpoints, `php://input` gives you the raw bytes:

```php
use CryptoChief\Processing\Webhook;

$raw = file_get_contents('php://input');

try {
    $evt = Webhook::parseEvent(getenv('API_KEY'), $raw, Webhook::headersFromGlobals());
} catch (\CryptoChief\Processing\Exception\WebhookVerificationException) {
    http_response_code(401);
    exit;
}
echo 'ok';
```

`Webhook::headersFromGlobals()` returns the request headers keyed by lowercase name — from `getallheaders()` where the SAPI provides it, otherwise from `$_SERVER`.

## Manual verification

For any other stack, pass the raw bytes and the request headers. `verify()` returns nothing and throws on failure:

```php
use CryptoChief\Processing\Exception\WebhookVerificationException;

try {
    Webhook::verify($apiKey, $rawBody, $headers);
} catch (WebhookVerificationException) {
    http_response_code(401);
    exit;
}
```

`$headers` maps header names in any case to a value or a list of values: PSR-7 `getHeaders()`, Symfony `$request->headers->all()`, or `Webhook::headersFromGlobals()`. A header given twice — under one name or two spellings of it — is refused.

Catch the subclass to tell the checks apart:

| Exception                   | Cause                                                              |
| --------------------------- | ------------------------------------------------------------------ |
| `WebhookHeadersException`   | A signature header is missing, repeated or malformed               |
| `WebhookTimestampException` | `X-CC-Timestamp` differs from the clock by more than the tolerance |
| `WebhookSignatureException` | The signature does not match the body                              |

All three extend `WebhookVerificationException`. An empty `$apiKey` throws `CryptoChiefException`. The fourth and fifth arguments of `verify()` and `parseEvent()` set the tolerance in seconds and the current time:

```php
Webhook::verify($apiKey, $rawBody, $headers, 60, 1789430400);
```

`Sign::webhookV1Sign($apiKey, $timestamp, $deliveryId, $body)` returns the `X-CC-Signature` value, for testing a receiver.

## Event types

Typed payloads live under `CryptoChief\Processing\Webhook\`: `PayoutEvent`, `TransactionEvent`, `PayInEvent`, `StaticDepositEvent`, `SweepEvent`. Event-name prefixes are `payout.*`, `transaction.*`, `invoice.*` (pay-ins), `static_deposit.*`, and `sweep.*`. `Webhook::parseEvent()` returns the matching typed object, or the raw `array` for an unknown prefix. Payout and transaction webhooks fire **only on terminal status**.

`sweep.confirmed` is the moment swept funds reach the network's finality depth in your master wallet — the other half of a deposit's life, and what treasury reporting should key off. See [Sweep callbacks](/processing/php/guides/sweep-callbacks.md).

Confirmation fields on events:

| Event class        | Property                                 | Meaning                                                                                                                                                     |
| ------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PayoutEvent`      | `confirmations`                          | `?int`. The lowest count among the sources; `null` while no source has a transaction. See [Send a payout](/processing/php/guides/payouts.md#confirmations). |
| `PayoutEvent`      | `requiredConfirmations`                  | `?int`. The network's finality depth, at least `1`; `null` when not sent.                                                                                   |
| `TransactionEvent` | `confirmations`, `requiredConfirmations` | Always sent. See [Sign & execute](/processing/php/guides/sign-execute.md#confirm).                                                                          |
| `SweepEvent`       | `sweepConfirmations`                     | At least `requiredConfirmations`. See [Sweep callbacks](/processing/php/guides/sweep-callbacks.md#payload).                                                 |
| `SweepEvent`       | `requiredConfirmations`                  | `?int`. The network's finality depth; `null` when not sent.                                                                                                 |

On a `PayoutEvent`, `sources` and `serviceOperations` are raw arrays. Read each item's count as `$item['confirmations'] ?? null`; the key is absent until its transaction is on chain.

{% hint style="warning" %}
**"No token contract" is spelled differently on the two deposit-side events.** A `StaticDepositEvent` for a native coin carries `contract` as an **empty string**, while a `SweepEvent` omits `assetContract` entirely and it decodes to `null`. Both are typed `?string`, so a null check alone misses the native static deposit — test `$evt->contract === null || $evt->contract === ''`. The same goes for `gasPumpTxHash`: absent (so `null`) on the webhook, but an empty string in sweep history, alongside `gasPumpSource` of `none`.
{% endhint %}

{% hint style="info" %}
`Webhook::SENDER_IPS` lists the addresses webhooks are delivered from — whitelist them at your edge for defence in depth.
{% endhint %}
