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

# Verification

> Confirming an incoming webhook request genuinely came from Grow CRM.

# Verification

Every webhook request carries a set of identifying headers. Check them before trusting a payload.

## Headers sent with every delivery

| Header               | Description                                                          |
| -------------------- | -------------------------------------------------------------------- |
| `X-Webhook-Secret`   | The endpoint's signing secret (shown on the endpoint's detail page). |
| `X-Webhook-Event`    | The event key, matching the `event` field in the JSON body.          |
| `X-Webhook-Delivery` | A unique id for this specific delivery attempt.                      |

## v1: shared-secret verification

The current version uses a **shared-secret** model: your receiving endpoint should compare the
`X-Webhook-Secret` header against the secret shown on the endpoint's detail page in the CRM, and
reject the request if it doesn't match.

```php theme={null}
<?php
$expected_secret = 'the secret shown on the endpoint detail page';
$received_secret = $_SERVER['HTTP_X_WEBHOOK_SECRET'] ?? '';

if (!hash_equals($expected_secret, $received_secret)) {
    http_response_code(401);
    exit;
}

$payload = json_decode(file_get_contents('php://input'), true);
// ... handle $payload['event'] / $payload['data']
```

```javascript theme={null}
// Node / Express
app.post('/webhooks/growcrm', (req, res) => {
  const expected = process.env.GROWCRM_WEBHOOK_SECRET;
  const received = req.header('X-Webhook-Secret');

  if (!received || received !== expected) {
    return res.sendStatus(401);
  }

  const { event, id, data } = req.body;
  // ... handle the event
  res.sendStatus(200);
});
```

Use a constant-time comparison (`hash_equals` in PHP, or an equivalent) rather than `==`/`===` where
your language's standard library provides one, to avoid timing attacks.

## Keep the secret private

Treat the signing secret like a password. Anyone who has it can send requests to your endpoint that
your code will accept as genuine. If a secret leaks, delete the endpoint and recreate it — a new
secret is generated automatically.

## Recommended future hardening: HMAC-SHA256

A shared secret sent on every request has two limitations: it travels over the wire on every call
(so it must never be logged or sent over plain HTTP), and it doesn't protect the **payload
itself** — nothing stops a captured request from being replayed later. A future version of this
module is expected to add HMAC-SHA256 request signing, the same approach used by Stripe and GitHub:

* The endpoint's secret is used as an HMAC key, never sent on the wire itself.
* The signature is computed over `timestamp + "." + raw_body`, sent in a header such as
  `X-Webhook-Signature: t=1730000000,v1=<hex-hmac>`.
* Your code recomputes the HMAC locally and compares it, and rejects requests whose timestamp is
  too old (protecting against replay).

If you are building a new integration today, structure your verification code so the comparison is
isolated in one function — swapping a shared-secret check for an HMAC check later will then be a
small, contained change.
