<!-- ZenPays documentation · https://docs.zenpayz.com/docs/guides/webhooks/signature-verification -->

# Signature Verification

ZenPay signs every webhook delivery so you can verify it came from ZenPay and was not tampered with.

## How Signatures Work

1. **Algorithm:** HMAC-SHA256 by default (configurable per endpoint to `sha256` or `sha512` in the dashboard).
2. **Signed content:** The raw JSON request body as a UTF-8 string.
3. **Signature format:** `{algorithm}={hex_digest}` — e.g. `sha256=a1b2c3d4e5f6...`
4. **Header:** `X-ZenPay-Signature` (default, configurable per endpoint).
5. **Secret:** Your webhook endpoint secret from the dashboard.

:::caution Use the raw body
Always verify against the **raw request body** (bytes/string), not a parsed-then-re-serialized JSON object. Re-serialization can change key ordering or whitespace, which invalidates the signature.
:::

## Webhook Headers

Every webhook delivery includes the following headers:

| Header | Description | Example |
|--------|-------------|---------|
| `X-ZenPay-Signature` | `{algorithm}={hex_hmac_digest}` of the raw JSON body using your webhook secret | `sha256=a1b2c3d4...` |
| `X-Zenpay-Event` | Event type string | `payment.success` |
| `X-Zenpay-Event-Id` | Unique event ID for deduplication | `evt_abc123` |
| `X-Zenpay-Timestamp` | ISO 8601 delivery timestamp | `2026-02-28T10:30:45.123Z` |
| `X-Zenpay-Attempt` | Delivery attempt number (starts at 1) | `1` |
| `User-Agent` | Delivery user agent | `Zenpay-Webhook/1.0` |

## Verification Implementation

<Tabs groupId="language">
  <TabItem value="javascript" label="JavaScript / Node.js" default>

**Use the helpers shipped in the SDK — you do not need to implement HMAC yourself.** Both are exported at the package root and take no API key, so they run inside a webhook route with no client configured:

```typescript
import { constructWebhookEvent, verifyWebhookSignature } from '@zenxdigitalholdings/zenpays'

// Returns a boolean.
const ok = await verifyWebhookSignature(rawBody, signatureHeader, secret)

// Or: verify and parse in one step. Throws Error('Invalid webhook signature')
// on failure, so a successful return guarantees authenticity.
const event = await constructWebhookEvent<PaymentSuccessEvent>(rawBody, signatureHeader, secret)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `rawBody` | `string` | The raw request body, exactly as received |
| `signatureHeader` | `string \| undefined \| null` | `X-ZenPay-Signature` value. Accepts `sha256=…` or bare hex |
| `secret` | `string` | Your webhook signing secret |

Both are `async` (they use Web Crypto, with a `node:crypto` fallback below Node 18) and both return `false` / throw rather than error out on a missing body, header, or secret.

:::caution SHA-256 only
The bundled helpers implement HMAC-SHA256. If you have configured an endpoint to sign with `sha512`, verify it with the manual implementation below instead.
:::

Full Express example:

```typescript
import { constructWebhookEvent } from '@zenxdigitalholdings/zenpays'
import express from 'express'

const app = express()

app.post(
  '/webhooks/zenpays',
  // express.raw() keeps the body unparsed — express.json() would re-serialize
  // it and break the signature.
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-zenpay-signature'] as string
    const timestamp = req.headers['x-zenpay-timestamp'] as string
    const eventId = req.headers['x-zenpay-event-id'] as string

    // 1. Verify the signature and parse in one step.
    let event: unknown
    try {
      event = await constructWebhookEvent(
        req.body.toString(),
        signature,
        process.env.WEBHOOK_SECRET!,
      )
    }
    catch {
      return res.status(401).json({ error: 'Invalid signature' })
    }

    // 2. Check timestamp freshness (prevent replay attacks)
    const deliveryTime = new Date(timestamp)
    const now = new Date()
    if (Math.abs(now.getTime() - deliveryTime.getTime()) > 5 * 60 * 1000) {
      return res.status(401).json({ error: 'Webhook timestamp expired' })
    }

    // 3. Deduplicate using event ID
    // if (await isEventAlreadyProcessed(eventId)) {
    //   return res.status(200).send('OK')
    // }

    // 4. Process
    handleWebhookEvent(event)

    res.status(200).send('OK')
  }
)
```

<details>
<summary>Manual implementation (no SDK, or <code>sha512</code> endpoints)</summary>

```typescript
import crypto from 'node:crypto'

function verifyWebhookSignature(
  rawBody: Buffer | string,
  signature: string,
  secret: string
): boolean {
  const [algorithm, receivedHash] = signature.split('=')

  const expectedHash = crypto
    .createHmac(algorithm, secret)
    .update(rawBody)
    .digest('hex')

  // Timing-safe comparison to prevent timing attacks
  const received = Buffer.from(receivedHash, 'hex')
  const expected = Buffer.from(expectedHash, 'hex')

  if (received.length !== expected.length) return false
  return crypto.timingSafeEqual(received, expected)
}
```

</details>

  </TabItem>
  <TabItem value="python" label="Python">

```python
import hmac
import hashlib
from datetime import datetime, timezone, timedelta
from flask import Flask, request, jsonify

app = Flask(__name__)

def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    algorithm, received_hash = signature.split("=", 1)

    expected_hash = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        getattr(hashlib, algorithm),
    ).hexdigest()

    return hmac.compare_digest(received_hash, expected_hash)

@app.route("/webhooks/zenpays", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-ZenPay-Signature", "")
    timestamp = request.headers.get("X-Zenpay-Timestamp", "")
    event_id = request.headers.get("X-Zenpay-Event-Id", "")

    # 1. Verify signature
    if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
        return jsonify({"error": "Invalid signature"}), 401

    # 2. Check timestamp freshness
    delivery_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    now = datetime.now(timezone.utc)
    if abs((now - delivery_time).total_seconds()) > 300:
        return jsonify({"error": "Webhook timestamp expired"}), 401

    # 3. Parse and process
    event = request.get_json(force=True)
    handle_webhook_event(event)

    return "OK", 200
```

  </TabItem>
</Tabs>

## Replay Attack Prevention

Validate `X-Zenpay-Timestamp` is within 5 minutes of the current server time. This prevents attackers from replaying captured webhook deliveries.

## Retry Behavior

If your endpoint fails to respond with a `2xx` status code within 30 seconds, ZenPay will retry the delivery:

- **Max retries**: 3 attempts
- **Backoff**: Exponential backoff between retries
- Each retry includes an incremented `X-Zenpay-Attempt` header

## Best Practices

1. **Respond quickly** — Return a `2xx` response within 30 seconds. Queue events for asynchronous background processing.
2. **Handle duplicates** — Events may be delivered more than once. Use `X-Zenpay-Event-Id` to deduplicate.
3. **Use HTTPS** — Always use secure endpoints in production.
4. **Verify signatures** — Never trust unverified payloads. Always validate `X-ZenPay-Signature` using the raw request body.
5. **Check timestamps** — Validate `X-Zenpay-Timestamp` is within 5 minutes of current time to prevent replay attacks.
6. **Log everything** — Keep records of received webhooks for debugging.
7. **Monitor failures** — Check the dashboard for failed deliveries and fix endpoint issues promptly.
