Webhooks
Create signed subscriptions and operate durable delivery and replay
Webhooks notify your backend that API state changed. They are compact invalidations, not resource snapshots. Event fields and examples come from the generated Webhook Catalog.
Subscription Scope
Webhook administration requires an API key. The server derives scope:
- a regular team's key creates a
selfsubscription; - a partner key without a selected managed team creates
managedTeams; and - a partner key operating as one selected managed team creates
self.
Clients cannot widen scope in the request body. Managed-team access is checked when a delivery is created and again immediately before it is sent.
Create a webhook (POST /webhooks) with URL,
event types, and optional description. The response returns the signing secret
once. Store it before requesting a test. Creation does not send automatically
and leaves the subscription pending.
Call Test webhook after your receiver
can verify the secret. A successful test activates that exact subscription
revision. Changing the URL or event set, or explicitly reactivating a disabled
subscription through Update a webhook
(PUT /webhooks/{id}) creates a new pending
revision that requires another test. Editing a disabled subscription without
requesting reactivation leaves it disabled. Delete a webhook
(DELETE /webhooks/{id}) softly disables
the subscription while retaining delivery history.
Event Catalog
V1 supports these subscription events:
team.issues.changedconnection.changedsync.status.changedreservation.changedlisting.changedstatement.status.changedtransaction.changed
webhook.test is delivery-only and cannot be selected in a subscription.
Each resource event includes a monotonic source-owned version and a relative
API link. Fetch that link with an authenticated request when the version is
newer than the one you have stored. A delete event is a tombstone, so its link
may return 404.
statement.status.changed is emitted only for persisted statement transitions;
draft projections do not emit. transaction.changed is emitted only after the
required journal, payment, reconciliation, or attachment effects reach their
final public state. Neither event contains financial, owner, bank, contact,
statement-row, or journal data.
Event Envelope
{
"id": "018f9c2b-7f4b-7d72-bf2e-9ab983f07018",
"type": "transaction.changed",
"apiVersion": "2026-07-01",
"createdAt": "2026-07-17T12:00:00.000+00:00",
"teamId": "61d87dad-91fe-44be-90bf-fe27b637f860",
"data": {
"resourceId": "28357b97-86fd-4fbb-aace-059fdabdbf53",
"changeType": "updated",
"resourceVersion": 9,
"href": "/transactions/28357b97-86fd-4fbb-aace-059fdabdbf53"
}
}The current authenticated API response remains the source of truth. Payloads do not include complete reservations, listings, transactions, statements, credentials, provider responses, guests, contacts, or journal entries.
Verify Signatures
Every delivery carries four headers:
Webhook-Id: the delivery ID; identical on every retry and replay.Webhook-Timestamp: unix epoch seconds at which the attempt was sent.Webhook-Signature: one or two space-separatedv1,<base64>signatures.Webhook-Attempt: the lifetime attempt number for this delivery.
The signed string is the delivery ID, timestamp, and exact raw request body, joined with dots:
<webhook-id>.<webhook-timestamp>.<exact-raw-request-body>Preserve the raw request bytes before parsing JSON. During the 24-hour secret rotation overlap, accept the request when any supplied signature matches any currently accepted secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(headers, rawBody, secrets) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const received = signatures.split(' ').flatMap((signature) =>
signature.startsWith('v1,')
? [Buffer.from(signature.slice(3), 'base64')]
: []
);
return secrets.some((secret) => {
const expected = createHmac('sha256', secret)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return received.some(
(signature) =>
signature.length === expected.length &&
timingSafeEqual(signature, expected)
);
});
}Deduplicate by Webhook-Id. Record an ID as processed only after your
business transaction commits.
Delivery, Retry, And Replay
Return 2xx only after durable processing succeeds. Network failures,
timeouts, 408, 429, and 5xx retry after 1 minute, 5 minutes, 30 minutes,
2 hours, 8 hours, and 24 hours. Other 4xx responses fail immediately. A
410 Gone also disables the exact subscription revision that received it.
Delivery is at least once and globally unordered. Database state is the durable
outbox; a one-minute recovery sweep re-enqueues due or abandoned work. Manual
replay keeps the original Webhook-Id, starts a fresh retry sequence, and is
allowed only for a terminal delivery whose subscription revision is still
current. If the destination or event set changed, reconcile current API state
instead of replaying the old delivery to the new configuration.
Terminal deliveries and their attempts are retained for 90 days. Delivery history is available through List deliveries.
Webhook delivery does not originate from static source IPs. Authenticate with the signature, not an IP allowlist.
Destination Security
Destinations must be public HTTPS outside local development. URLs with
credentials, redirects, or local, loopback, private, link-local, multicast, or
metadata destinations are rejected. DNS is resolved and all answers are
validated independently before every attempt. Delivery egress selects one
validated address and connects to that numeric address while preserving the
original hostname for TLS certificate validation and Host. Redirects are
returned as failures and are never followed.
Secret Rotation
Rotation returns the new secret once. The previous secret remains valid for a 24-hour overlap, and deliveries include signatures from both secrets. Deploy the new secret, accept both during the overlap, then remove the old secret.
Quotas
- Up to 25 non-disabled subscriptions per owner.
- Up to 5 tests per subscription and 20 tests per owner each minute.
- Up to 20 replays per owner each minute.
Excess requests return 429 with Retry-After. Disabled subscriptions do not
count toward the subscription cap.
API Reference
- Create a webhook —
POST /webhooks - Update a webhook —
PUT /webhooks/{id} - Delete a webhook —
DELETE /webhooks/{id} - List deliveries —
GET /webhook-deliveries POST /webhook-deliveries/{id}/replay— Replay a delivery- Send a signed test —
POST /webhooks/{id}/test POST /webhooks/{id}/rotate-secret— Rotate the signing secret
