Action webhooks (X-Haven-Signature)
An action guidebook card is a button Haven renders. When a guest taps it, Haven POSTs a signed JSON body to the HTTPS endpoint you configured. Your server never appears in the guest page.
This signer is not the Svix-shaped scheme on Verifying signatures (the inbound public-API webhook design). Action cards use:
X-Haven-Signature: t=<unix_seconds>,v1=<hex(hmac_sha256(t + "." + body))>
The HMAC key is the full whsec_… secret string, including the prefix. Do not base64-decode it.
Request
POST /your-endpoint
Content-Type: application/json
X-Haven-Signature: t=1770000000,v1=4f3c…
{
"type": "guide.widget.action",
"dryRun": false,
"occurredAt": "2026-09-07T18:00:00.000Z",
"propertyCode": "11223344",
"scanSpotId": "clxyz…",
"reservation": {
"checkIn": "2026-09-12",
"checkOut": "2026-09-15"
},
"guest": {
"name": "Ada Lovelace",
"email": null
}
}
dryRun is true when a host clicks Send test request in the editor. Treat it as a no-op against hardware.
Haven:
- allows
https:on port 443 only, with no private/loopback/link-local destinations - follows no redirects (
redirect: 'manual') - waits at most 5 seconds
- reads at most 4 KB of your response
- treats any
2xxas success; anything else is shown to the guest as a failure
Verify (Node)
import crypto from 'crypto';
const TOLERANCE_SECONDS = 300;
export function verifyHavenSignature(
secret: string,
body: string,
header: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
const parts = Object.fromEntries(
header.split(',').map((part) => {
const eq = part.indexOf('=');
return [part.slice(0, eq).trim(), part.slice(eq + 1).trim()];
}),
);
const timestamp = Number(parts.t);
const digest = parts.v1;
if (!Number.isInteger(timestamp) || !digest) return false;
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
if (expected.length !== digest.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(digest));
}
Use the raw request body string, not a re-serialized object. Rotate the secret in the Haven editor if it leaks; guests keep working after rotate once you deploy the new value.