# Verifying `haven_ctx`

An **embed** guidebook card loads your URL in a sandboxed iframe. When a guest has linked a stay (and passed any verified-stay gate you turned on), Haven appends a short-lived `haven_ctx` query parameter. Your app verifies it with the widget's signing secret and can skip its own login step.

This is **not** the public-API webhook scheme. The HMAC is keyed on the **full** `whsec_…` secret string, including the prefix.

## Token

```
https://your-app.example/door?haven_ctx=<base64url(payload)>.<hex(hmac)>
```

- Construction: `base64url(JSON payload) + "." + hex(HMAC-SHA256(encoded, secret))`
- TTL: 10 minutes (`exp` is a unix-ms timestamp)
- The payload includes only the claims you checked in the embed editor. Haven never sends a default bag of guest data.

```json
{
  "v": 1,
  "jti": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "exp": 1770000000000,
  "spotId": "clxyz…",
  "guestName": "Ada Lovelace",
  "guestEmail": "ada@example.com",
  "checkIn": "2026-09-12",
  "checkOut": "2026-09-15",
  "reservationRef": "11223344"
}
```

`guestName`, `guestEmail`, `checkIn`/`checkOut`, and `reservationRef` are present only when you allowed that claim **and** Haven had a non-null value.

## Verify (Node / Next.js)

```ts
import crypto from 'crypto';

type HavenCtx = {
  spotId: string;
  jti: string;
  guestName?: string;
  guestEmail?: string;
  checkIn?: string;
  checkOut?: string;
  reservationRef?: string;
};

export function verifyHavenCtx(
  token: string | undefined,
  secret: string,
  nowMs = Date.now(),
): HavenCtx | null {
  if (!token || !secret) return null;
  const dot = token.lastIndexOf('.');
  if (dot === -1) return null;

  const encoded = token.slice(0, dot);
  const signature = token.slice(dot + 1);
  const expected = crypto
    .createHmac('sha256', secret)
    .update(encoded)
    .digest('hex');
  if (
    signature.length !== expected.length ||
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    return null;
  }

  const payload = JSON.parse(
    Buffer.from(encoded, 'base64url').toString('utf8'),
  ) as { v: number; jti: string; exp: number; spotId: string } & HavenCtx;
  if (payload.v !== 1) return null;
  if (typeof payload.exp !== 'number' || nowMs > payload.exp) return null;
  return payload;
}
```

Read `haven_ctx` from the request URL. Store the secret from the Haven editor after create or rotate — Haven shows the full value once.

The iframe is sandboxed (`allow-scripts allow-forms allow-popups allow-same-origin`) and cannot navigate the parent guidebook. Guests also get an "Open in new tab" escape hatch.
