DocsGuides

Webhooks

Subscribe to real-time events and verify signed payloads in Node or Python.

Webhooks push events to your server the moment something happens in Coastly, so you do not have to poll. You register an HTTPS endpoint in the vendor dashboard, choose the events you care about, and receive a signed POST for each one.

Events

Event Fires when
reservation.created A reservation is created.
reservation.confirmed A reservation is confirmed.
reservation.modified A reservation's dates, rooms, guests, or add-ons change.
reservation.cancelled A reservation is cancelled.
order.created A marketplace or F&B order is created.
order.updated An order's status or contents change.
invoice.issued An invoice is issued.
invoice.paid An invoice is paid.

Payload shape

Each delivery is a JSON body with a stable envelope. data holds the resource in the same shape the REST API returns.

{
  "id": "evt_7Hn3k9",
  "type": "reservation.created",
  "created": 1751328000,
  "data": {
    "id": "res_9Fq2c8",
    "property_id": "prop_1a2b3c",
    "status": "confirmed",
    "check_in": "2026-07-12",
    "check_out": "2026-07-15",
    "guests": 2
  }
}

Respond with a 2xx status quickly (within a few seconds). Do the heavy work asynchronously. Non-2xx responses and timeouts are retried with backoff.

Verifying signatures

Every delivery carries an X-Coastly-Signature header so you can prove it came from Coastly and was not altered:

X-Coastly-Signature: t=1751328000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix timestamp when the signature was generated.
  • v1 is the HMAC-SHA256, hex-encoded, of the string ${t}.${rawBody} using your endpoint's signing secret (whsec_…).

To verify, in order:

  1. Read the raw request body exactly as received. Do not re-serialize the parsed JSON; whitespace changes break the signature.
  2. Extract t and v1 from the header.
  3. Compute HMAC_SHA256(secret, "${t}.${rawBody}") and compare to v1 using a constant-time comparison.
  4. Reject the request if the timestamp is stale: abs(now - t) > 300 seconds. This blocks replay attacks.

Node

import crypto from "node:crypto";

// `rawBody` MUST be the raw string/Buffer, not the parsed object.
export function verifyCoastlyWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.trim().split("="))
  );
  const t = Number(parts.t);
  const v1 = parts.v1;

  // Reject stale timestamps (replay protection).
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Wire it into an Express route using the raw body:

import express from "express";
const app = express();

app.post(
  "/webhooks/coastly",
  express.raw({ type: "application/json" }), // keep the raw body
  (req, res) => {
    const ok = verifyCoastlyWebhook(
      req.body, // Buffer from express.raw
      req.header("X-Coastly-Signature"),
      process.env.COASTLY_WEBHOOK_SECRET
    );
    if (!ok) return res.status(400).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // handle event.type … then ack fast
    res.status(200).send("ok");
  }
);

Python

import hashlib
import hmac
import time


def verify_coastly_webhook(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.strip().split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    v1 = parts.get("v1", "")

    # Reject stale timestamps (replay protection).
    if not t or abs(time.time() - t) > 300:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Flask handler:

from flask import Flask, request, abort

app = Flask(__name__)


@app.post("/webhooks/coastly")
def coastly_webhook():
    raw = request.get_data()  # raw bytes, unparsed
    sig = request.headers.get("X-Coastly-Signature", "")
    if not verify_coastly_webhook(raw, sig, WEBHOOK_SECRET):
        abort(400)
    event = request.get_json()
    # handle event["type"] … then ack fast
    return "ok", 200

Idempotent processing

Deliveries may be retried, so the same evt_… id can arrive more than once. Record processed event ids and skip duplicates so your side stays consistent even under retries.