> For the complete documentation index, see [llms.txt](https://ivorypay.gitbook.io/ivorypay-on-off-ramp-documentation/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://ivorypay.gitbook.io/ivorypay-on-off-ramp-documentation/webhooks/security.md).

# Webhook Security

How to verify webhook signatures to ensure authenticity.

Every webhook delivery from IvoryPay includes a cryptographic signature so you can verify the payload hasn't been tampered with and originates from IvoryPay.

## Signature header

IvoryPay signs every webhook payload using **HMAC-SHA512** and includes the signature in the `x-ivorypay-signature` header.

## The signing key

The signature is generated using your **secret API key** (`sk_test_` for test webhooks, `sk_live_` for live webhooks) — the same key you use to authenticate API requests. There is no separate webhook secret.

{% hint style="danger" %}
**Never expose your secret key.** It should only be stored as an environment variable on your server. If compromised, regenerate it immediately from the dashboard.
{% endhint %}

## How to verify

### Step 1: Extract the signature

Read the `x-ivorypay-signature` header from the incoming request.

### Step 2: Compute the expected signature

Generate an HMAC-SHA512 hash of `JSON.stringify(payload.data)` — that is, only the **inner `data` object** of the webhook payload, serialized as JSON — using your secret key. The signature does **not** cover the outer `{event, data}` envelope, only the `data` object inside it.

### Step 3: Compare

Compare your computed hash with the signature header. If they match, the webhook is authentic.

## Implementation examples

### Node.js

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(req) {
  const signature = req.headers['x-ivorypay-signature'];
  const secretKey = process.env.IVORYPAY_SECRET_KEY;

  // Sign ONLY the inner `data` object of the webhook payload, not the full body.
  const expectedSignature = crypto
    .createHmac('sha512', secretKey)
    .update(JSON.stringify(req.body.data))
    .digest('hex');

  return signature === expectedSignature;
}

// Usage in Express
app.post('/webhooks/ivorypay', express.json(), (req, res) => {
  if (!verifyWebhookSignature(req)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the verified webhook
  res.status(200).send('OK');
});
```

### Python

```python
import hmac
import hashlib
import json
import os

def verify_webhook_signature(request):
    signature = request.headers.get('x-ivorypay-signature')
    secret_key = os.environ['IVORYPAY_SECRET_KEY']

    # Sign ONLY the inner `data` object of the webhook payload, not the full body.
    data = request.json.get('data')
    expected = hmac.new(
        secret_key.encode('utf-8'),
        json.dumps(data).encode('utf-8'),
        hashlib.sha512
    ).hexdigest()

    return hmac.compare_digest(signature, expected)
```

### PHP

```php
// Note: the signed content is the inner `data` object of the webhook payload,
// serialized as JSON — not the full request body.
function verifyWebhookSignature($dataJson, $signatureHeader) {
    $secretKey = getenv('IVORYPAY_SECRET_KEY');
    $expectedSignature = hash_hmac('sha512', $dataJson, $secretKey);

    return hash_equals($expectedSignature, $signatureHeader);
}

// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_IVORYPAY_SIGNATURE'] ?? '';
$decoded = json_decode($payload, true);
$dataJson = json_encode($decoded['data']);

if (!verifyWebhookSignature($dataJson, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Process the verified webhook
$event = $decoded['event'];
http_response_code(200);
```

### Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha512"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

// Sign ONLY the inner `data` object (re-serialized as JSON), not the full body.
func verifySignature(dataJSON []byte, signature string) bool {
    mac := hmac.New(sha512.New, []byte(os.Getenv("IVORYPAY_SECRET_KEY")))
    mac.Write(dataJSON)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("x-ivorypay-signature")

    var payload struct {
        Event string          `json:"event"`
        Data  json.RawMessage `json:"data"`
    }
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "Invalid payload", http.StatusBadRequest)
        return
    }

    if !verifySignature([]byte(payload.Data), signature) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    // Process the verified webhook
}
```

## Security best practices

1. **Always verify signatures** — Never process a webhook without verification
2. **Use timing-safe comparison** — Use `crypto.timingSafeEqual` (Node.js), `hmac.compare_digest` (Python), or `hash_equals` (PHP) to prevent timing attacks
3. **Verify via API** — After signature verification, call the [Verify Transaction](/ivorypay-on-off-ramp-documentation/api-reference-transactions/verify.md) endpoint for an additional layer of confirmation before irreversible actions
4. **Use HTTPS** — Your webhook URL should always use HTTPS
5. **Restrict IP addresses** — If possible, whitelist IvoryPay's IP addresses for your webhook endpoint
6. **Reject replays** — Consider checking timestamps to reject old webhook deliveries

***

> **For AI assistants:** Signature verification uses HMAC-SHA512. The key is the merchant's secret key for the relevant environment (not the API key). The signed content is `JSON.stringify(payload.data)` — only the inner `data` object of the webhook payload, NOT the full `{event, data}` request body. The signature is in the `x-ivorypay-signature` header as a hex string.
