Webhooks are the fastest way to stay in sync with Nowistay. Instead of polling the API every few minutes to detect new or changed reservations, you tell Nowistay where to send the events, and your service receives them as soon as they happen. This article shows you how to create a webhook, verify the signature, and handle retries.
The Nowistay public REST API lets your tools and partners read and write Nowistay data over HTTPS. You authenticate with OAuth2 (authorization code + PKCE), receive an access token, and call endpoints under /public/v1/*. Webhooks are the push counterpart: same data, same shape as GET /public/v1/bookings/{id}, but Nowistay pushes them to you when something changes.
If you have not set up OAuth yet, start with the main guide: Using the Nowistay public REST API. It covers registration, the consent flow, scopes and how to test calls from the docs playground.
booking.created and booking.updated events.GET /public/v1/bookings/{id}, including dates, channel, status, amounts, mission-relevant fields and your custom booking parameters.webhooks.write scope. POST /public/v1/webhooks also requires bookings.read, because the payload is a full booking detail.2xx status.curl -X POST https://api.nowistay.com/public/v1/webhooks \
-H "Authorization: Bearer nis_access_token_example" \
-H "Content-Type: application/json" \
-d '{
"propertyId": 101,
"url": "https://partner.example.com/nowistay/webhooks",
"events": ["booking.created", "booking.updated"],
"description": "PMS sync"
}'Response:
{
"id": 801,
"propertyId": 101,
"url": "https://partner.example.com/nowistay/webhooks",
"events": ["booking.created", "booking.updated"],
"description": "PMS sync",
"status": "active",
"createdAt": "2026-05-27T16:40:00Z",
"updatedAt": "2026-05-27T16:40:00Z",
"signingSecret": "whsec_9npwH6J8QyZp1L8U3xqG7sN2"
}Save the signingSecret now. It is returned only once, at creation. There is no way to read it again from the API. If you lose it, delete the webhook and create a new one.
Each delivery is an HTTP POST to your URL, with these headers:
Content-Type: application/jsonUser-Agent: Nowistay-Webhooks/1.0Nowistay-Webhook-Id: 801Nowistay-Webhook-Event-Id: evt_LkP2sxR9zV8QyW... (unique per event)Nowistay-Webhook-Event-Type: booking.updatedNowistay-Webhook-Timestamp: 1779292800 (Unix seconds)Nowistay-Webhook-Signature: v1=3a7b...e1c (HMAC-SHA256 hex)And a JSON body shaped like this:
{
"id": "evt_LkP2sxR9zV8QyW...",
"type": "booking.updated",
"createdAt": "2026-05-27T16:40:00Z",
"apiVersion": "2026-05-27",
"propertyId": 101,
"bookingId": 9001,
"data": {
"booking": {
"id": 9001,
"propertyId": 101,
"arrival": "2026-07-14",
"departure": "2026-07-18",
"status": "confirmed",
"channel": "airbnb",
"reservationNumber": "HMABCDE",
"guest": { "firstName": "Maya", "lastName": "M." },
"amounts": { "amount": "620.00", "currency": "EUR" },
"identity": { "status": "verified" }
}
}
}Your endpoint should answer with any 2xx status (200 or 204 are typical). Anything else, or a response that takes more than 10 seconds, counts as a failure and triggers a retry.
Always verify the signature before trusting the body. Without verification, anyone who guesses your URL could send fake events to your service.
Nowistay computes the signature as:
signature = "v1=" + HMAC_SHA256(
signing_secret,
timestamp + "." + event_id + "." + raw_request_body
).hex()Recompute it on your side using the Nowistay-Webhook-Timestamp, Nowistay-Webhook-Event-Id headers and the exact raw request body (do not parse and re-serialize the JSON before verifying, or you risk byte differences).
import crypto from "node:crypto";
function verify(req, signingSecret) {
const timestamp = req.headers["nowistay-webhook-timestamp"];
const eventId = req.headers["nowistay-webhook-event-id"];
const signature = req.headers["nowistay-webhook-signature"];
const rawBody = req.rawBody; // Buffer of the raw HTTP body
const signed = Buffer.concat([
Buffer.from(`${timestamp}.${eventId}.`, "utf8"),
rawBody,
]);
const expected = "v1=" + crypto
.createHmac("sha256", signingSecret)
.update(signed)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib
def verify(request, signing_secret: str) -> bool:
timestamp = request.headers["Nowistay-Webhook-Timestamp"]
event_id = request.headers["Nowistay-Webhook-Event-Id"]
signature = request.headers["Nowistay-Webhook-Signature"]
raw_body = request.body # bytes
signed = f"{timestamp}.{event_id}.".encode("utf-8") + raw_body
expected = "v1=" + hmac.new(
signing_secret.encode("utf-8"),
signed,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Also check that the timestamp is recent (for example, within the last 5 minutes) to protect against replay attacks.
The data.booking object mirrors what GET /public/v1/bookings/{id} returns when called without the bookings.sensitive.read scope. That means:
When your tool needs one of those sensitive fields, fetch the booking detail from the API with a token that carries the bookings.sensitive.read scope. The bookingId in the webhook envelope is the right input for that call.
curl https://api.nowistay.com/public/v1/bookings/9001 \
-H "Authorization: Bearer nis_access_token_with_sensitive_scope"If your endpoint returns a non-2xx status, times out, or fails for any other reason, Nowistay does not drop the event. It schedules a retry, with exponential backoff and a bit of jitter:
This continues until your endpoint accepts the event, or until the webhook has been failing for more than 72 hours straight. At that point Nowistay sets the webhook status to disabled and stops sending new events to it. Failure counters reset every time a delivery succeeds, so an isolated incident never trips the auto-disable.
Delivery records are kept for 30 days, then cleaned up.
A disabled webhook can be turned back on from the API once your endpoint is healthy again. Send a PATCH with status: "active":
curl -X PATCH https://api.nowistay.com/public/v1/webhooks/801 \
-H "Authorization: Bearer nis_access_token_example" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'Nowistay does not replay the events that fired while the webhook was disabled. If you missed some, do a one-time backfill with GET /public/v1/bookings using a date filter, then resume your webhook-driven flow.
You can temporarily pause deliveries (during a maintenance window, for example) without losing the webhook config:
# Pause
curl -X PATCH https://api.nowistay.com/public/v1/webhooks/801 \
-H "Authorization: Bearer nis_access_token_example" \
-H "Content-Type: application/json" \
-d '{"status": "paused"}'
# Resume
curl -X PATCH https://api.nowistay.com/public/v1/webhooks/801 \
-H "Authorization: Bearer nis_access_token_example" \
-H "Content-Type: application/json" \
-d '{"status": "active"}'
# Delete permanently
curl -X DELETE https://api.nowistay.com/public/v1/webhooks/801 \
-H "Authorization: Bearer nis_access_token_example"You can also patch the url, the events list or the description in the same call. Changing the URL does not rotate the signing secret.
# List all webhooks (optionally filtered by property)
curl "https://api.nowistay.com/public/v1/webhooks?propertyId=101" \
-H "Authorization: Bearer nis_access_token_example"
# Read one webhook
curl https://api.nowistay.com/public/v1/webhooks/801 \
-H "Authorization: Bearer nis_access_token_example"The list response shows the same fields as the create response, minus the signing secret (it is only returned at creation).
https://user:pass@... is rejected).localhost, no private or reserved IP ranges. Nowistay also resolves the hostname at delivery time and blocks calls that resolve to private addresses.id from the body and skip if you have already processed it.2xx right away and process the event in a background job if it is heavy.Nowistay-Webhook-Event-Id and the request id in your logs. It speeds up support.GET /public/v1/bookings/{id} after receiving the event.