Every webhook delivery is an HTTP POST with a JSON body and three relevant 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:
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):
{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
- Compute SHA-256 of the UTF-8 message bytes
- Decode
x-kiwify-digital-signature from base64url (no padding)
- Verify with EdDSA-Ed25519 using the active public key
Webhook signatures use Ed25519 prehashed mode (SHA-256 of the message before verification). This differs from generic Ed25519 examples that sign the message directly.
Example (Python)
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