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 2xx as 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.