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 available to Partners and to personal users.
- Partners configure them per OAuth2 application; events fire for the application that owns a resource's user. See Getting Set Up.
- Personal users configure one endpoint per Beyond account from the dashboard; events fire for everything on that account. 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 In Active Market Changed | listing.in_active_market_changed |
listing-in-active-market-changed-events |
A listing's in-active-market attribute changes — it leaves or re-enters the state where its calendar is guaranteed to work. |
| 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. |
| Webhook Ping | webhook.ping |
webhook-ping-events |
A personal user saves a webhook URL or sends a test ping from the dashboard. Verification only; acknowledge and ignore. |
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.
Once you are set up you can manage the endpoint yourself, without contacting us:
Webhook Configuration reads and updates
your destination URL and your event selection, behind the webhooks:write scope.
For Personal Users¶
If you automate your own Beyond account with a personal access token, you configure webhooks yourself from the dashboard's Personal Access Tokens page. Only an administrator of the account can manage them, and the same Beyond Pro requirement as for personal access tokens applies.
- Generate a signing secret. Beyond shows the
whsec_…value once. Add it to your receiver first: every delivery, including the verification ping, is signed with it. - Save your endpoint URL. It must be a public
httpsURL. Beyond sends a signedwebhook.pingto it and saves the URL only when your endpoint answers with a2xx. Redirects are not followed, and a failed ping leaves your previous settings untouched; the dashboard shows why it failed. - Choose your events. You receive every event type by default, including ones added later. Untick "All events" to pin an explicit list.
After that you can send a test ping at any time, rotate the secret (the new value is shown once and applies to the next delivery; update your receiver first), or remove the endpoint, which stops delivery immediately.
Events about your account fire the same way as for partners: a new listing, a
base price change, and so on. You can read back what Beyond delivered with a
PAT through the Webhook Events endpoints;
personal access tokens carry the webhooks:read scope.
Choosing Your Events¶
By default an application receives every event type, including ones Beyond
adds in future — that is what subscribed-events: ["*"] means, and it is what
you get unless you change it. Nothing in the catalog above is opt-in.
If your receiver only handles some of these, narrow the list to exactly the types you process:
{
"data": {
"type": "webhook-configurations",
"id": "your-client-id",
"attributes": {
"subscribed-events": ["listing.created", "listing.base_price_changed"]
}
}
}
The trade-off is deliberate: pinning an explicit list means a new event type Beyond ships is not delivered until you add it, so you are never surprised by a payload you did not plan for — but you also have to come back for anything new. See Webhook Configuration.
Unsubscribing applies to every event, including those that report on a job you
started yourself: turn off listing.refreshed and a refresh you requested through
the API completes without notifying you.
Changing Your Destination URL¶
The same endpoint moves your deliveries to a new URL. For partners there is no verification step — the new URL is used from the moment the write succeeds, so confirm it is live and verifying signatures first. (Personal users saving a URL from the dashboard get a verification ping instead; see For personal users.) Deliveries already in flight, including retries of earlier events, may still land on the old URL for a short period; keep both endpoints up across a cutover if you cannot tolerate a gap.
Setting webhook-url to "" stops delivery while keeping your event selection
intact, which is the clean way to pause webhooks.
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.
If your receiver was down and you need to recover events you missed, you can read
back what Beyond delivered — including the delivery status and each event's body —
via the Webhook Events endpoints (webhooks:read
scope).
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 In Active Market Changed
(
listing.in_active_market_changed) — a listing'sin-active-marketattribute changed. - Listing Refresh (
listing.refreshed) — reservations refresh completed. - Listing Base Price Changed
(
listing.base_price_changed) — a listing's effective base price changed. - Webhook Ping (
webhook.ping) — a personal user's endpoint is being verified.