# Guest preferences and membership

> Build verified email signup and independent preference management, with isolated TEST rehearsal and explicit consent.


Let visitors explicitly sign up for email updates after identity verification,
or manage email marketing and optional membership through Kismet-hosted preferences.
Neither marketing nor membership is a condition of ordinary sign-in.

> **Check your installed release**
>
> This requires the Developer API v0.7.8 preference extension and the corresponding
> source-pinned SDK build. Confirm both are deployed or installed before integration.
> The published SDK may not yet include these helpers.
>
> TEST state is isolated by installation, collection, and guest. It creates no real
> membership, email permission, email delivery, perk, campaign eligibility, or payment.
> The response identifies simulated delivery with `state.deliveries: "SIMULATED"`.
> LIVE uses the real collection relationship and sends a confirmation code to the
> guest's verified email address. It can create a free membership and change email
> preferences. A staging website with LIVE credentials performs real actions.

## Inline email signup

For a signup form, show the collection's email consent text next to the explicit
signup action. After Google verifies the guest, record that choice without another
confirmation email. For an email form, first complete email-link verification,
then record the same choice. **Signing in alone does not grant marketing consent.**

The inline signup extension requires the matching API and SDK release and the
collection-wide `guest_preferences.write` grant. It does not enroll membership.
In TEST it records an isolated rehearsal only; it does not create LIVE campaign
eligibility or send an extra email. Existing hosted preference management below
continues to use its own confirmation flow.

Mount `handlers.signupDisclosure` as GET
`/api/kismet/guest/preferences/signup-disclosure`, and
`handlers.emailSubscription` as POST
`/api/kismet/guest/preferences/email-subscriptions`, using the same Next.js
handler instance as your guest sign-in routes.

```ts
import { createKismetGuestBrowserClient } from '@kismet-tech/sdk/react';

const signupGuest = createKismetGuestBrowserClient();
const terms = await signupGuest.preferences.getSignupDisclosure();
// Render terms.disclosure.text, privacyUrl, and any non-null notice.
// Capture the explicit choice, version, hash and a unique idempotency key
// before starting sign-in. Preserve them in your authenticated continuation.
// After successful Google or email-link verification:
async function finishSignup(verifiedGuestId: string, originalIdempotencyKey: string) {
  return signupGuest.preferences.subscribeEmail({
    accepted: true,
    expectedGuestId: verifiedGuestId,
    disclosureVersion: terms.disclosure.version,
    disclosureHash: terms.disclosureHash,
    idempotencyKey: originalIdempotencyKey,
  });
}
```

Do not call `finishSignup` from a generic login callback without preserved signup
intent. Use the guest ID returned by verification, not a form-supplied identity.
An account switch returns `GUEST_IDENTITY_CHANGED`; stale terms return
`DISCLOSURE_CHANGED`. Ask for a fresh choice in either case. Retry the same
request with its original idempotency key. A replay returns the current preference
state and never reverses a later unsubscribe. Global email suppression is preserved.

The server client exposes `getSignupDisclosure()` and
`subscribeEmail(input, { guestAccessToken, csrfToken })`. It derives `parentOrigin`
from its configured origin. Direct REST uses
[GET signup disclosure](https://developers.kismet.travel/api/reference/get-developer-guest-signup-disclosure.md) and
[POST email subscription](https://developers.kismet.travel/api/reference/subscribe-developer-guest-email.md), with
the server key, verified guest token and CSRF proof held by your backend.

When combining signup with an offer, separately display the offer's
`deliveryDisclosure.text` and use its hash for offer capture. If that field is
absent, do not enable combined signup against that API release. Offer delivery
consent never creates marketing permission by itself.

## Set up hosted preference management

1. Configure the [same-origin guest-account integration](https://developers.kismet.travel/sdk.md#branded-guest-experiences).
2. Start with a TEST installation with the collection-wide `guest_preferences.write`
   grant. The separate `guest_auth.write` grant permits guest sign-in, but does not
   authorize preference operations. If your dashboard's capability picker does
   not yet show it, an authorized collection administrator or developer can create
   the installation through Kismet's signed-in User MCP developer-access tools.
3. Register your site's exact origin, such as `https://preview.example.com` or
   `http://localhost:3000`. An empty origin list does not permit capture.
4. Keep the server key and verified guest access token in your backend. The browser
   uses the SDK guest client with the existing HttpOnly session and CSRF flow.

## Wire the Next.js routes

Use the same `handlers` instance returned by `createKismetGuestAuthNextHandlers`
from `@kismet-tech/sdk/next` for sign-in and these two routes:

```ts
// app/api/kismet/guest/preferences/route.ts
import { handlers } from '@/lib/kismet-auth';
export const GET = handlers.preferences;
```

```ts
// app/api/kismet/guest/preferences/captures/route.ts
import { handlers } from '@/lib/kismet-auth';
export const POST = handlers.preferenceCapture;
```

The capture handler validates the session, site origin, and CSRF proof. It derives
`parentOrigin` from the request site, not browser-supplied JSON. Responses are
private and non-cacheable.

## Open the Kismet confirmation

```ts
import { createKismetGuestBrowserClient } from '@kismet-tech/sdk/react';

const guest = createKismetGuestBrowserClient();
const before = await guest.preferences.get();
const capture = await guest.preferences.prepareCapture();

// Render a user-activated link to capture.confirmationUrl, for example:
// <a href={capture.confirmationUrl} target="_blank" rel="noopener noreferrer">
//   Continue to Kismet
// </a>
// After the visitor returns, refresh the authoritative state:
const after = await guest.preferences.get();
```

Prepare the URL in response to the visitor's intent to manage preferences, not
on every page view. It expires after at most ten minutes (or earlier if the guest
access proof expires) and is a bearer link. Do not log,
cache, share, or put it in analytics. It opens in a separate tab or popup, not an
iframe. The SDK validates that it points to Kismet's confirmation origin and path.

For a popup integration, completion emits `kismet:guest-preferences:complete`
with `captureId`. Accept it only when `event.origin` is `https://api.ksmt.app`,
`event.source` is your opened popup, and `captureId` matches your capture. The
message is a refresh signal, never proof of consent. Re-read preferences through
your backend. A new-tab integration can simply refresh when the visitor returns.

## What the visitor can try

| Choice | Saved result |
| --- | --- |
| Keep current choice | No marketing change; no enrollment unless membership is separately selected |
| Join optional membership only | Membership becomes `ACTIVE`; email marketing is unchanged |
| Subscribe only | Email becomes `PENDING`; membership is unchanged |
| Confirm the email code | Pending email becomes `SUBSCRIBED`; TEST uses the displayed `000000`, LIVE requires the emailed code |
| Unsubscribe | Email becomes `UNSUBSCRIBED`; membership is unchanged |
| Close before saving | No change; sign-in remains available |

Membership is offered only when the collection has an enabled membership program.
The simulated second step proves the interaction, not email deliverability or a
LIVE double-opt-in system. Never use TEST `ACTIVE` or `SUBSCRIBED` to authorize
real benefits or messages.

## Use LIVE deliberately

Use a separate LIVE installation after completing the TEST checks. The same SDK
methods and BFF routes apply; neither the browser nor a query flag selects the
environment. Read `state.environment` and `state.deliveries` from the response.

- **Membership only:** creates or reactivates the collection's free membership,
  with canonical member-benefit processing. It does not opt into marketing or
  trigger a promotional welcome email. Paid memberships are not supported here.
- **Subscribe only:** records `PENDING` and queues one confirmation email. It does
  not enroll the guest. The Kismet page accepts the code only after the delivery
  service has accepted the email, and only while the capture remains valid.
- **Unsubscribe:** records the withdrawal without cancelling membership. An older
  confirmation cannot reverse a later withdrawal. A fresh subscription requires
  a new confirmation.
- **Existing suppression:** an address blocked for delivery stays blocked. This
  flow does not remove bounce or complaint suppression.

The Kismet page shows queued, sent, or unavailable delivery. If a provider result
is uncertain, the system does not automatically resend or mark the guest as
subscribed. The visitor can start a new confirmation. `state.deliveries: "LIVE"`
identifies the environment, not proof of inbox delivery; `PENDING` is not permission
to send marketing. Permission is collection-specific and bound to the verified
email address. Changing the address does not transfer the prior opt-in.

LIVE preferences are shared across that collection's installations for the same
guest. TEST preferences remain installation-isolated. The read operation never
enrolls or subscribes anyone. SMS, payment, and booking actions are separate.

## REST and server SDK

The same operations work with any server framework. A server client created with
`createKismetGuestAuthServerClient` from `@kismet-tech/sdk/server` provides:

```ts
const preferences = await client.getPreferences(guestAccessToken);
const capture = await client.preparePreferenceCapture(
  { parentOrigin: 'https://preview.example.com' },
  { guestAccessToken, csrfToken },
);
```

```bash
curl 'https://api.ksmt.app/v1/developer/guest/me/preferences' \
  -H "Authorization: Bearer $KISMET_SERVER_KEY" \
  -H "X-Kismet-Guest-Token: $GUEST_ACCESS_TOKEN"

curl 'https://api.ksmt.app/v1/developer/guest/me/preferences/captures' \
  -X POST -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $KISMET_SERVER_KEY" \
  -H "X-Kismet-Guest-Token: $GUEST_ACCESS_TOKEN" \
  -H "X-Kismet-CSRF: $VALIDATED_CSRF_TOKEN" \
  --data '{"parentOrigin":"https://preview.example.com"}'
```

Your backend must validate CSRF and the request origin before forwarding a capture
request. Neither operation accepts an arbitrary guest ID, membership ID, or consent
flag. The guest token determines whose preferences are accessed.

See [GET guest preferences](https://developers.kismet.travel/api/reference/get-developer-guest-preferences.md) and
[POST prepare preference capture](https://developers.kismet.travel/api/reference/prepare-developer-guest-preference-capture.md)
for the complete request and response contracts. Response `state` includes
`environment`, `revision`, `emailMarketing`, `membership.status`,
`membership.joinedAt`, and `deliveries`. `disclosureHash` identifies the displayed
disclosure; it is not a consent receipt or an authorization token.

## Handle expiry and changed state

- `401`: sign in again; do not substitute a guest ID from the browser.
- `403`: check the explicit preference grant, server credential, and registered
  origin. Revoking the creator credential, installation, grant, or origin also
  invalidates prepared confirmations.
- `404 CAPTURE_UNAVAILABLE`: the link is invalid or expired; prepare a new one.
- `409 DISCLOSURE_CHANGED` or `PREFERENCES_CHANGED`: refresh state and open a new
  confirmation. A stale subscription confirmation cannot reverse a withdrawal.
- `409 CAPTURE_CONFLICT`: the same capture already recorded different choices.
- `409 EMAIL_SUPPRESSED`: leave the current choices unchanged; do not work around
  the delivery block.
- `409 CAPTURE_SCOPE_CHANGED`: the signed-in account or email changed; prepare a
  new confirmation.
- `429`: wait before retrying. Capture preparation is bounded to ten captures per
  guest/installation in TEST or guest/collection in LIVE in ten minutes, in
  addition to the request-rate limit. Five incorrect LIVE codes lock that capture.
- `503`: temporary unavailability; leave the existing state unchanged.

Confirmations are replay-safe: repeating the same submitted choice does not create
another receipt or enrollment. Keep the prior result visible during a failed
refresh and clearly distinguish unknown state from unsubscribed state.
