KausateKausate Docs
Webhooks

Webhooks

Receive asynchronous order results securely and verify that each delivery came from Kausate.

Kausate sends completed asynchronous order results to each active webhook registered for your organization. Every registered webhook has its own signing secret, and every delivery is signed over the exact request body.

This page covers order-result webhooks registered through /v2/webhooks. Monitor callbacks configured with webhookUrl have different security semantics.

Create a webhook

curl -X POST https://api.staging.kausate.com/v2/webhooks \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order Results",
    "url": "https://your-server.com/webhooks/kausate"
  }'

The response includes a signingSecret beginning with kwhsec_. Store it as a secret and use it only on the server receiving the webhook.

{
  "id": "4cfdf18b-6075-45ac-98bd-9ef0a41bb563",
  "name": "Order Results",
  "url": "https://your-server.com/webhooks/kausate",
  "status": "active",
  "signingSecret": "kwhsec_<base64-encoded-secret>"
}

See Create Webhook for the complete request and response schema.

Signed delivery headers

Each signed request contains these server-controlled headers:

HeaderMeaning
Kausate-Webhook-IdThe Temporal workflow ID for this logical delivery. It is stable across delivery retries.
Kausate-Webhook-TimestampUnix time in seconds when this HTTP attempt was created. A retry gets a new timestamp.
Kausate-Webhook-SignatureSpace-delimited v1,<base64> HMAC-SHA256 signatures. Usually one; two during rotation.
Kausate-VersionThe API version used for the webhook payload.

The webhook ID is not the webhook configuration ID returned by POST /v2/webhooks, and it is not just the order ID. For a normal order-result delivery it has this structure:

{orderId}-webhook-{webhookConfigurationId}

Kausate uses this exact ID for the SendWebhook child workflow in Temporal. This makes a delivery directly traceable in our workflow history. All retry attempts for that logical delivery keep the same ID. A later re-delivery of a cached result gets a new delivery ID.

The timestamp represents the outbound HTTP attempt, not the time the order started or completed. This is intentional: it lets your receiver reject stale requests even when a delivery is retried much later.

Verify a signature

Verify the signature before parsing or acting on the payload:

  1. Read the raw request body as bytes. Do not re-serialize parsed JSON.
  2. Read Kausate-Webhook-Id, Kausate-Webhook-Timestamp, and Kausate-Webhook-Signature.
  3. Reject timestamps outside your allowed clock-skew window. Five minutes is a reasonable default.
  4. Remove the kwhsec_ prefix from signingSecret and base64-decode the remainder.
  5. Build the signed bytes as <id>.<timestamp>.<raw-request-body>.
  6. Calculate HMAC-SHA256, then compare it with the base64-decoded v1 signature using a constant-time comparison.
import base64
import binascii
import hashlib
import hmac
import time
from collections.abc import Mapping


def verify_kausate_webhook(
    raw_body: bytes,
    headers: Mapping[str, str],
    signing_secret: str,
    *,
    tolerance_seconds: int = 300,
) -> bool:
    if not signing_secret.startswith("kwhsec_"):
        return False

    try:
        message_id = headers["Kausate-Webhook-Id"]
        timestamp_text = headers["Kausate-Webhook-Timestamp"]
        signature_header = headers["Kausate-Webhook-Signature"]
        timestamp = int(timestamp_text)
        timestamp_bytes = timestamp_text.encode("ascii")
        secret = base64.b64decode(
            signing_secret.removeprefix("kwhsec_"),
            validate=True,
        )
    except (KeyError, binascii.Error, ValueError, TypeError):
        return False

    if abs(int(time.time()) - timestamp) > tolerance_seconds:
        return False

    if not secret:
        return False

    signed_content = (
        message_id.encode("utf-8")
        + b"."
        + timestamp_bytes
        + b"."
        + raw_body
    )
    expected_digest = hmac.new(
        secret,
        signed_content,
        hashlib.sha256,
    ).digest()
    for signature in signature_header.split():
        try:
            version, encoded_digest = signature.split(",", 1)
            provided_digest = base64.b64decode(encoded_digest, validate=True)
        except (binascii.Error, ValueError):
            continue
        if version == "v1" and hmac.compare_digest(expected_digest, provided_digest):
            return True
    return False

Framework helpers such as request.json() may consume or transform the body. Capture the original bytes first and use those same bytes for verification.

After a successful signature check, use Kausate-Webhook-Id as an idempotency key. Return a 2xx response only after the delivery is durably accepted.

Rotate a signing secret

Rotate a webhook independently if its signing secret is exposed or as part of your regular secret-management policy:

curl -X POST https://api.staging.kausate.com/v2/webhooks/4cfdf18b-6075-45ac-98bd-9ef0a41bb563/secret/rotate \
  -H "X-API-Key: your_api_key" \
  -H "Kausate-Version: 2026-05-01"

The response contains the new secret and the fixed expiry of the previous secret:

{
  "signingSecret": "kwhsec_<new-base64-encoded-secret>",
  "previousSecretExpiresAt": "2026-07-30T12:00:00Z"
}

Update your receiver to use the new secret before that timestamp. For exactly 24 hours after rotation, Kausate includes signatures made with both the new and previous secrets in the same Kausate-Webhook-Signature header. Verification succeeds when any one of the space-delimited signatures matches. After the overlap expires, only the new secret is used automatically.

Only one rotation can be active for an endpoint. Starting another rotation before the 24-hour overlap expires returns 409 Conflict and leaves both active secrets unchanged.

Outbound IP addresses

If your firewall requires source-IP allowlisting, allow every production address below. A webhook can originate from any availability zone.

EnvironmentOutbound IPv4 address
Production3.65.43.249
Production63.181.68.138
Production3.74.212.143
Staging3.127.78.117

Retry behavior

Kausate retries non-2xx responses and network failures with exponential backoff:

SettingValue
Maximum attempts50
Initial interval1 second
Maximum interval4 hours
Backoff multiplier

Retries keep the same Kausate-Webhook-Id, but receive a fresh Kausate-Webhook-Timestamp and signature. Your endpoint should therefore deduplicate only after signature and timestamp verification succeed.

Last updated on

On this page