VRPlatformVRPlatform
Run in Production

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 self subscription;
  • 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.changed
  • connection.changed
  • sync.status.changed
  • reservation.changed
  • listing.changed
  • statement.status.changed
  • transaction.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-separated v1,<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

On this page