Skip to content

Webhooks

Webhooks let an Odeva app receive an HTTP POST whenever something happens in an organisation, instead of polling the API. Each subscription is scoped to one organisation and to a list of event types. Deliveries are signed so a receiver can verify they came from Odeva.

Use webhooks when an external service needs to react to bookings — issuing invoices, opening doors, syncing a CRM, kicking off internal automation. For one-off lookups or interactive flows, call the GraphQL API directly.

Create a subscription with the createWebhookSubscription mutation. The mutation returns the signingSecret exactly once at creation time, so store it before moving on.

mutation CreateWebhook($input: CreateWebhookSubscriptionInput!) {
createWebhookSubscription(input: $input) {
webhookSubscription {
id
name
endpointUrl
eventTypes
status
}
signingSecret
errors
}
}
{
"input": {
"name": "Reservations to billing",
"endpointUrl": "https://hooks.example.com/odeva",
"eventTypes": ["reservation.created", "reservation.cancelled"],
"appInstallationId": "AppInstallation-123"
}
}

Notes:

  • endpointUrl must be a public HTTP or HTTPS URL. Delivery to loopback, link-local, and private IP ranges is blocked.
  • appInstallationId is optional. If you call the mutation with an app API key, the subscription is attached to that installation automatically and is removed when the app is uninstalled.
  • The signing secret is prefixed with whsec_ and is only readable from the create response. Rotate by deleting and recreating the subscription.

The supported event types are returned by the webhookEventTypes query and currently include:

  • reservation.created
  • reservation.confirmed
  • reservation.cancelled
  • reservation.checked_in
  • reservation.checked_out
  • app.installed
  • app.uninstalled

A subscription may listen to any subset. app.uninstalled is always delivered to subscriptions attached to the installation being removed, even after the subscription is disabled, so the receiver can clean up on its side.

Each delivery is a POST to the subscription endpoint with Content-Type: application/json.

Headers:

  • X-Odeva-Event-Id — the platform event ID.
  • X-Odeva-Event-Type — the event type, e.g. reservation.confirmed.
  • X-Odeva-Delivery-Id — the delivery ID. Use this as your idempotency key.
  • X-Odeva-Timestamp — Unix seconds when the request was signed.
  • X-Odeva-Signaturesha256=<hex> HMAC of the signed payload.
  • User-AgentOdeva-Webhooks/1.0.

Body:

{
"id": "PlatformEvent-7f9c…",
"type": "reservation.confirmed",
"version": 1,
"organization_id": "Organization-42",
"occurred_at": "2026-05-26T09:14:31Z",
"subject": { "type": "Reservation", "id": "Reservation-1234" },
"payload": { "...": "event-specific fields" },
"correlation_id": "optional-trace-id"
}

Respond with any 2xx status code as soon as you have accepted the event. Long-running work belongs in a background queue on your side — Odeva treats responses slower than 10 seconds as failures.

The signature is an HMAC-SHA256 of "{timestamp}.{body}" using the subscription’s signing secret.

import crypto from "node:crypto";
export function verifyOdevaSignature(req: {
headers: Record<string, string>;
rawBody: string;
}, signingSecret: string): boolean {
const timestamp = req.headers["x-odeva-timestamp"];
const signature = req.headers["x-odeva-signature"];
if (!timestamp || !signature) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${req.rawBody}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Reject requests with a missing or mismatched signature. Reject timestamps that are far outside your tolerance window (5 minutes is a reasonable default) to limit replay attacks. Use the raw request body for the HMAC — re-serialising the parsed JSON will change the bytes and break verification.

A delivery is considered successful when your endpoint returns a 2xx response. On any other response, network error, or timeout, Odeva retries up to 5 attempts with delays of 1 minute, 5 minutes, 15 minutes, and 1 hour. After the final failed attempt the delivery is marked failed and the subscription’s lastFailureAt and lastError are updated.

Inspect recent deliveries with the webhookDeliveries query, optionally filtered by subscriptionId. Each WebhookDelivery exposes status, attemptCount, responseStatus, a truncated responseBody, lastError, and nextAttemptAt.

Use retryWebhookDelivery(id: ID!) to retry a failed delivery manually after fixing your endpoint, or updateWebhookSubscription to change the endpoint URL, event types, or status without recreating the subscription.

The webhook queries and mutations require the webhooks:manage scope on the API key calling them. Request that scope when you register the app, alongside any other scopes the integration needs.