Developers

Signed webhooks

silentVIP can tell your HR, expense or security systems the moment a trip request is made or decided, a trip is booked, changed or canceled, or a traveler asks for help. Each event is an HTTPS POST, signed so you can check it came from silentVIP.

Set up

Add an endpoint in the portal

  1. A travel manager opens Integrations, Webhooks in the silentVIP portal and adds your endpoint's URL: HTTPS on the standard port, with a public host name.

  2. They choose the events to send and copy the endpoint's signing secret, which starts with whsec_. It's shown once; rotating it shows a new one.

  3. Store the secret where your endpoint can read it, then use Send test event to check that deliveries arrive and verify.

An account can have up to 5 endpoints. Each endpoint's last 50 deliveries are listed in the portal with the status code your endpoint answered.

Events

What silentVIP sends

EventWhen
request.createdA trip request was made (portal, app, live search or a group event)
request.approvedAn approver approved a selection or a change (the last step of a chain)
request.declinedAn approver didn't approve a selection or a change
trip.bookedA trip was booked or confirmed
trip.changedA booked trip's dates, destination or itinerary changed
trip.cancelledA trip was canceled
safety.needs_helpA traveler answered a safety check-in saying they need help
incident.openedA safety incident was opened that your account can see
pingA test event, sent when you choose Send test event

Payloads carry ids, dates, cities, statuses and the names of your own travelers. They never carry notes, purposes, email addresses, phone numbers, passport, loyalty or payment details: look those up in the portal. Every field of every event is in the OpenAPI document.

Delivery

One JSON POST per event

HeaderValue
Content-Typeapplication/json
User-AgentsilentVIP-Webhooks/1
silentVIP-Signaturet={unix seconds},v1={hex HMAC-SHA256}
silentVIP-Event-IdThe event's id, the same on every retry

The body is the event:

{
  "id": "evt_3f9a6c2e1d8b4a7f60e5c1d2",
  "type": "trip.booked",
  "created": "2026-11-20T15:04:05.123Z",
  "account": "acct_7c3e9a1f20b4d658",
  "data": {
    "trip_id": "trip_2b7c90d4e1f3a685",
    "request_id": "req_8d2e61f0a4b9c7e1",
    "status": "confirmed",
    "destination": "London",
    "start_date": "2026-12-07",
    "end_date": "2026-12-11",
    "travelers": [
      "Jordan Lee"
    ]
  }
}
  • Answer with any 2xx status within 10 seconds. Do slow work after you answer.
  • No answer in time, a 429 or a 5xx is retried, with waits of 1, 2, 4, 8 and 16 minutes. Any other status, and any redirect, isn't retried: redirects are never followed.
  • A retry repeats the same body and event id with a fresh signature, so the same event can arrive more than once. Use the event id to handle each one once.
  • Events can arrive out of order. Use created, or look the record up, when the order matters.
  • After 20 events in a row can't be delivered, the endpoint is turned off and your travel managers are emailed. Turn it back on in the portal once it's fixed.

Verify

Check every signature

  1. Read the raw request body before parsing it, and the silentVIP-Signature header. Split the header on commas into t and v1.

  2. Compute HMAC-SHA256 of t, a period and the raw body, with the whole signing secret (including whsec_) as the key, as UTF-8. Hex-encode it.

  3. Compare it with v1 in constant time, and refuse a t more than five minutes from now, so an old delivery can't be replayed.

Node.js

import crypto from "node:crypto";

// rawBody: the request body exactly as received (a string, before any JSON parsing)
// header: the silentVIP-Signature header; secret: the endpoint's signing secret, from your secret store
export function verifySilentvip(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest();
  const given = Buffer.from(String(parts.v1 || ""), "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

Python

import hashlib, hmac, time

# raw_body: the request body as bytes, exactly as received; header: the silentVIP-Signature header
def verify_silentvip(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Never put the secret in source code: read it from your secret store. If it may have leaked, rotate it in the portal; deliveries use the new secret at once.

Questions

Need something else?

The accounting, expense and HR integrations are file-based: see Integrations. For anything about webhooks, email support@silentvip.com.

Try the demoBook a Demo