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
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.
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.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
| Event | When |
|---|---|
request.created | A trip request was made (portal, app, live search or a group event) |
request.approved | An approver approved a selection or a change (the last step of a chain) |
request.declined | An approver didn't approve a selection or a change |
trip.booked | A trip was booked or confirmed |
trip.changed | A booked trip's dates, destination or itinerary changed |
trip.cancelled | A trip was canceled |
safety.needs_help | A traveler answered a safety check-in saying they need help |
incident.opened | A safety incident was opened that your account can see |
ping | A 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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | silentVIP-Webhooks/1 |
silentVIP-Signature | t={unix seconds},v1={hex HMAC-SHA256} |
silentVIP-Event-Id | The 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
Read the raw request body before parsing it, and the
silentVIP-Signatureheader. Split the header on commas intotandv1.Compute HMAC-SHA256 of
t, a period and the raw body, with the whole signing secret (includingwhsec_) as the key, as UTF-8. Hex-encode it.Compare it with
v1in constant time, and refuse atmore 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.