# 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](../05-webhooks/02-verifying-signatures.md) (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…
```

```json
{
  "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)

```ts
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.
