Skip to content

Webhooks

Webhooks let Beyond Pricing push events to your backend the moment they happen, so you do not have to poll the API. When something you care about occurs — a reservations refresh finishes, a listing's base price changes — Beyond sends a signed HTTP POST to an endpoint you register.

Every delivery is a JSON:API document signed with Standard Webhooks (HMAC-SHA256), so you can verify it really came from Beyond and was not tampered with in transit.

Availability

Webhooks are currently available to Partners only. They are configured at the OAuth2 application level and fire for the application that owns a resource's user. We intend to extend webhooks to personal-user automations in a future release — see For Personal Users.

Event Catalog

Each event is documented independently, including exactly when it fires and the full shape of its payload.

Event Event type Resource type Fires when
Account Created account.created account-created-events The background listing sync that follows adding a managed account finishes (success or failure).
Account Refreshed account.refreshed account-refreshed-events The background listing sync started by refreshing a managed account finishes (success or failure).
Listing Created listing.created listing-created-events A new listing owned by one of your users is fully set up (its initial base price is computed).
Listing Refresh listing.refreshed listing-refreshed-events A partner-triggered listing refresh chain finishes (success or failure).
Listing Base Price Changed listing.base_price_changed listing-base-price-changed-events A listing's effective base price or currency changes on any Beyond surface; enabled marks live vs. speculative changes.

Getting Set Up

Webhook delivery is configured per OAuth2 application. To start receiving events, provide Beyond with:

  1. An endpoint URL — an HTTPS URL on your backend that accepts POST requests.
  2. Beyond returns a signing secret in the form whsec_<base64-key>. Store it securely; you need it to verify every delivery.

Contact Beyond during onboarding to register your endpoint and receive your secret, the same way IP allowlists are provisioned for partner applications. If an application has no endpoint or secret configured, matching events are simply not delivered — nothing else changes.

Rotation

Treat the signing secret like a credential. If it is ever exposed, ask Beyond to rotate it. Rotation is currently a hard cutover: deliveries are signed with the old secret until the change lands, and with the new one immediately after, with no overlap window. Coordinate a rotation window with us so you can swap the secret on your side at the same moment. (The webhook-signature header is a list precisely so that an overlap period can be added later without changing your verification code.)

Delivery Model

sequenceDiagram
    participant BP as Beyond Pricing
    participant N as Notifications Service
    participant P as Your Endpoint

    BP->>N: Event occurs, enqueue delivery
    N->>N: Build deterministic body, sign (HMAC-SHA256)
    N->>P: POST signed JSON:API document
    alt Any 2xx
        P-->>N: 200 OK
        Note over N: Delivered — done
    else 5xx / 408 / 429 / timeout / network error
        P-->>N: error
        N->>P: Retry with exponential backoff (up to 5 attempts)
    else Other 4xx (incl. 410)
        P-->>N: error
        Note over N: Not retried
    end
  • Transport: HTTPS POST to your registered URL.
  • Content-Type: application/vnd.api+json (a JSON:API document).
  • Body: byte-stable JSON with deterministic key ordering. The signature is computed over the raw bytes — verify and parse those exact bytes, do not re-serialize before verifying.

Request Headers

Every delivery carries the Standard Webhooks headers:

Header Description
webhook-id Stable event identifier (msg_<base32 ULID>). Re-used across delivery retries — use it as your idempotency key. Matches data.id in the body.
webhook-timestamp Unix time (seconds) when this attempt was sent. Re-stamped on every retry. Reject deliveries whose timestamp is more than ~5 minutes from your clock to defend against replay.
webhook-signature Space-separated list of v<version>,<base64-signature> entries (currently just v1,…). See verification.

Verifying Signatures

Always verify the signature before trusting or parsing the body.

The signed content is the string {webhook-id}.{webhook-timestamp}.{raw-body}, and the signature is base64(HMAC-SHA256(key, signed_content)), where the key is your secret with the whsec_ prefix stripped and base64-decoded to 32 raw bytes.

Use an official library

The simplest, safest path is a Standard Webhooks library for your language — it handles the timestamp tolerance and constant-time comparison for you. Pass it your whsec_… secret, the request headers, and the raw body.

If you verify manually, mirror this recipe (Python):

import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict[str, str], raw_body: bytes) -> bool:
    """Return True if the delivery is authentic and fresh."""
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]

    # Replay protection: reject deliveries that are too old (or from the future).
    if abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode("ascii")

    # webhook-signature is a space-separated list of "v<version>,<signature>".
    for entry in headers["webhook-signature"].split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False

Always compare in constant time

Use a constant-time comparison (hmac.compare_digest above) so a naive string comparison cannot leak the signature byte-by-byte through timing.

Responding to Deliveries

  • Acknowledge fast. Return any 2xx as soon as you have safely received the event (for example, after persisting it to a queue). Do the real work asynchronously — slow handlers risk a timeout, which Beyond treats as a failure and retries.
  • Return 410 Gone if the endpoint is permanently retired. Beyond stops retrying that delivery; future iterations may auto-disable endpoints that respond 410.

Retries

Deliveries that do not succeed are retried automatically:

Response Behavior
Any 2xx Delivered. No retry.
5xx, 408, 429, timeout, or network error Retried with exponential backoff.
Other 4xx (including 410) Not retried — treated as a permanent rejection.

Beyond makes up to 5 attempts (the initial delivery plus 4 retries) with exponential backoff (roughly 1s, 5s, 25s, 125s, capped at 300s), honoring a Retry-After header when you send one. After the final attempt, the delivery is dropped.

Idempotency

Because retries re-use the same webhook-id, the same event may arrive more than once. Make your handler idempotent: dedupe on webhook-id (equivalently, data.id in the body) and treat a repeat as a no-op. A given webhook-id always represents the same event with the same body.

Payload Envelope

Every event shares the same JSON:API envelope. The meta block is identical across events; data is event-specific and documented on each event's page.

{
  "meta": {
    "type": "listing.refreshed",
    "sent-at": "2026-07-03T12:34:56Z"
  },
  "data": {
    "type": "listing-refreshed-events",
    "id": "msg_01J9Z6M0K3QK7YV8N2C4B5A6D7",
    "attributes": { "...": "event-specific" },
    "relationships": { "...": "event-specific" }
  }
}
meta field Description
type The event type (e.g. listing.refreshed). Your routing key, and where the version lives if one is ever introduced — see Versioning.
sent-at RFC 3339 UTC timestamp when the envelope was assembled. Not when the thing happened.

The event's identifier appears exactly twice: in the webhook-id header, and as data.id. There is no schema-version field: if an event ever needs a breaking change, it ships under a new event type rather than a bumped envelope — see Versioning.

Two kinds of type

An event carries two type fields and they do different jobs:

  • meta.type (listing.refreshed) is the event type — route on this, and acknowledge (2xx) any type you do not recognize. New event types (including a .v2 of one you handle) are delivered as soon as they exist — see Versioning.
  • data.type (listing-refreshed-events) is the JSON:API resource type — deserialize on this.

See the JSON:API guide for why the event type lives under meta.

data.id is the event id, not a domain id

The document you receive describes the event, so data.id is the event's ULID — the same value as the webhook-id header, and what you dedupe on. It is not the id of the listing or account the event is about. The domain object is always reached through relationships — its id is in relationships.<name>.data.id, and relationships.<name>.links.related is a URL you can GET for its current state.

sent-at is not the occurrence time

meta.sent-at stamps the envelope. When the thing actually happened lives in the event's own attributes — completed-at, changed-at, or created-at, depending on the event. They can differ by minutes, and on a retry sent-at stays fixed while the webhook-timestamp header is re-stamped.

Field names are dasherized

Like the rest of the API, event field names use dashes (base-price, completed-at, channel-listing-id). See the JSON:API guide.

OpenAPI / Machine-Readable Schema

The webhook payloads are published in the OpenAPI 3.1 schema as top-level webhooks, alongside the request/response endpoints. Use them to generate types or validate payloads.

Tool URL Notes
OpenAPI Schema /api/v1/schema/ Downloadable OpenAPI 3.1 spec; webhook bodies live under the top-level webhooks key, one per event.
ReDoc /api/v1/redoc/ Renders the webhooks section under its own heading.

Each event contributes one webhook (named in camelCase — listing.refreshedlistingRefreshed) tagged Webhooks, documenting its body schema and the Standard Webhooks headers.

Next Steps