---
name: zoodealio-webhooks
description: "Receive and verify Zoodealio webhooks (offer.*, transaction.stage_changed, comping.completed, comping.failed, property.files_imported). Use when writing an endpoint that handles Zoodealio webhook deliveries, verifying the X-Zoodealio-Signature header, or managing webhook subscriptions."
---

# Receiving Zoodealio webhooks

Subscriptions are managed with the API key at `https://api.zoodealio.ai/openapi/Webhooks` (see https://zoodealio.ai/developers#webhooks). The signing secret is shown once on create and on `POST /openapi/Webhooks/{id}/rotate`; store it like a password (env var or secret manager).

## Delivery

Each delivery is an HTTPS POST whose JSON body is the envelope:

```json
{ "id": "<delivery id>", "type": "comping.completed", "apiVersion": "2026-09-22", "occurredAt": "…", "tenantId": "…", "data": { } }
```

Headers: `X-Zoodealio-Signature: t=<unix seconds>,v1=<hex>`, `X-Zoodealio-Event` (the type), `X-Zoodealio-Delivery` (same as `id`).

## Handler rules

1. Verify the signature over the RAW request bytes, before parsing JSON. Frameworks that parse the body first (Express `json()`, Next.js `req.json()`) destroy the bytes; read the raw body.
2. Signed material is `<t>.<raw body>`, HMAC-SHA256 with the signing secret, lowercase hex. Compare in constant time.
3. Reject when `t` is more than 5 minutes from now (replay protection).
4. De-duplicate on `id`: delivery is at least once and `id` is stable across retries.
5. Return 2xx fast (within a few seconds) and do slow work asynchronously. Non-2xx and timeouts retry with exponential backoff, about six attempts.
6. Payloads carry ids and status; offer amounts appear only after details are shared. Read the offer with the API when you need numbers.
7. `transaction.stage_changed` fires at every journey step with `previousStage`, `stage` and `phase`: use it to mirror progress.
8. The endpoint must be public HTTPS; private/local addresses and redirects are refused. Use a tunnel for local development.

## Node.js (TypeScript) verification

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyZoodealioSignature(rawBody: string, header: string | null, secret: string, toleranceSeconds = 300): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=', 2) as [string, string]));
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(v1, 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

## Python verification

```python
import hmac, hashlib, time

def verify_zoodealio_signature(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

## Debugging

`GET /openapi/Webhooks/deliveries` lists delivery attempts with status. `GET /openapi/Webhooks/event-types` is the current event catalogue.
