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

# Webhook Headers & Verification

> HTTP headers and Ed25519 signature verification for webhooks

# Webhook Headers & Verification

Every webhook delivery is an **HTTP POST** with a JSON body and three relevant headers.

## HTTP headers

| Header                       | Value                          | Description                          |
| ---------------------------- | ------------------------------ | ------------------------------------ |
| `Content-Type`               | `application/json`             | Body content type                    |
| `x-kiwify-digital-signature` | Base64url (no padding)         | EdDSA-Ed25519 signature              |
| `x-kiwify-timestamp`         | Unix ms (e.g. `1705423200000`) | Timestamp used in the signed message |

Example:

```http theme={null}
POST /webhooks/kiwibank HTTP/1.1
Content-Type: application/json
x-kiwify-digital-signature: gnfHkqEfBhKN3lYmGDF_J9fO3jSQtq8d1-agAZPMn4u3huef0kg5XqbnxSj5SCb_
x-kiwify-timestamp: 1705423200000

{"id":"550e8400-e29b-41d4-a716-446655440000","type":"CASHOUT.PIX.TRANSFERS.COMPLETED",...}
```

## Get the public key

Use `GET /v1/webhooks-keys` to list public keys. Use the key with `is_active: true`.

Cache the key for up to 24 hours and refresh periodically.

## Verification process

### Step 1: Validate timestamp

Reject deliveries with timestamps outside a **5-minute** window from current time (replay protection).

### Step 2: Reconstruct the signed message

PoP format (same pattern as API authentication, with a different `uri` component):

```text theme={null}
{url_path}:POST:{raw_body}:{timestamp}
```

| Component   | Description                                                                            |
| ----------- | -------------------------------------------------------------------------------------- |
| `url_path`  | **Only the path** of your registered URL (e.g. `/webhooks/kiwibank`), not the full URL |
| `POST`      | HTTP method (always POST)                                                              |
| `raw_body`  | JSON body **exactly as received** — do not re-serialize                                |
| `timestamp` | Value from `x-kiwify-timestamp` header                                                 |

### Step 3: Verify the signature

1. Compute **SHA-256** of the UTF-8 message bytes
2. Decode `x-kiwify-digital-signature` from base64url (no padding)
3. Verify with EdDSA-Ed25519 using the active public key

<Warning>
  Webhook signatures use **Ed25519 prehashed** mode (SHA-256 of the message before verification). This differs from generic Ed25519 examples that sign the message directly.
</Warning>

### Example (Python)

```python theme={null}
import base64
import hashlib
import time
from urllib.parse import urlparse
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization

def verify_webhook(endpoint_url: str, body_bytes: bytes, signature_b64: str, timestamp_ms: int, public_key_pem: str) -> bool:
    if abs(int(time.time() * 1000) - timestamp_ms) > 300_000:
        return False

    path = urlparse(endpoint_url).path or "/"
    message = f"{path}:POST:{body_bytes.decode('utf-8')}:{timestamp_ms}"
    digest = hashlib.sha256(message.encode("utf-8")).digest()

    public_key = serialization.load_pem_public_key(public_key_pem.encode())
    padding = (4 - len(signature_b64) % 4) % 4
    signature = base64.urlsafe_b64decode(signature_b64 + "=" * padding)

    try:
        public_key.verify(signature, digest)
        return True
    except Exception:
        return False
```

## Common mistakes

* Using the full URL instead of only the path in the signed message
* Re-serializing JSON (whitespace/key order changes the signature)
* Verifying Ed25519 directly on the message without SHA-256 prehash
