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:
- An endpoint URL — an HTTPS URL on your backend that accepts
POSTrequests. - 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
POSTto 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 Goneif the endpoint is permanently retired. Beyond stops retrying that delivery; future iterations may auto-disable endpoints that respond410.
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.v2of 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.refreshed
→ listingRefreshed) tagged Webhooks, documenting its body schema and
the Standard Webhooks headers.
Next Steps¶
- Account Created (
account.created) — a new managed account's listings have all been created. - Account Refreshed (
account.refreshed) — a managed account's listings have all been refreshed. - Listing Created (
listing.created) — a new listing is fully set up. - Listing Refresh (
listing.refreshed) — reservations refresh completed. - Listing Base Price Changed
(
listing.base_price_changed) — a listing's effective base price changed.