Skip to main content

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

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

HTTP headers

HeaderValueDescription
Content-Typeapplication/jsonBody content type
x-kiwify-digital-signatureBase64url (no padding)EdDSA-Ed25519 signature
x-kiwify-timestampUnix 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}
ComponentDescription
url_pathOnly the path of your registered URL (e.g. /webhooks/kiwibank), not the full URL
POSTHTTP method (always POST)
raw_bodyJSON body exactly as received — do not re-serialize
timestampValue 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
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