# Unidy Web SDK — Headless / JS API

Everything the components do is available programmatically. Import Auth and getUnidyClient from the SDK module to read auth state, call the profile/newsletter/ticket/subscription APIs directly, and combine the results with any library — no u-* markup required.

## Setup

Every example below assumes the SDK is loaded and configured once per page. The
import map lets the headless examples use the bare `@unidy.io/sdk` specifier —
the same import a bundler/npm setup uses; drop the import map if you install the
package instead.

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@unidy.io/sdk@1.12.0/dist/sdk/sdk.css">
<script type="importmap">
  { "imports": { "@unidy.io/sdk": "https://cdn.jsdelivr.net/npm/@unidy.io/sdk@1.12.0/dist/sdk/index.esm.js" } }
</script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@unidy.io/sdk@1.12.0/dist/sdk/sdk.esm.js"></script>

<u-config
  base-url="https://hannover96.staging.unidy.de"
  api-key="your-api-key"
  locale="en"
  check-signed-in="true"></u-config>
```

## Auth state from JavaScript

authState is the same reactive store the components use: read authState.authenticated for the current value and subscribe with onAuthChange for updates. Session restore runs asynchronously on page load, so reacting to the store — instead of a one-shot check — is the reliable pattern. Auth.Errors exposes stable error codes for handling specific sign-in failures.

- **authState store** — current auth state, shared with the components
- **onAuthChange()** — subscribe to sign-in/sign-out transitions
- **Auth.Errors** — stable error-code constants

```html
<div class="rounded-lg border border-border bg-background-light p-4">
  <p class="text-sm">
    Authentication status:
    <strong id="auth-status" class="font-mono">checking…</strong>
  </p>
</div>

<script type="module">
  // The same module the components use — import it for headless access
  import { authState, onAuthChange, Auth } from "@unidy.io/sdk";

  const render = (authenticated) => {
    document.getElementById("auth-status").textContent = authenticated
      ? "authenticated"
      : "not authenticated";
  };

  // authState is a reactive store: read it now, subscribe for changes.
  // (Session restore is async on page load — never rely on a one-shot check.)
  render(authState.authenticated);
  onAuthChange("authenticated", render);

  // Auth.Errors holds stable error codes for the sign-in process,
  // e.g. Auth.Errors.email.NOT_FOUND === "account_not_found"
  console.log("Known email errors:", Auth.Errors.email);
</script>
```

Live demo: /headless#auth-state

## API calls with getUnidyClient

getUnidyClient() exposes typed services — profile, newsletters, tickets, subscriptions, auth. Calls return a [error, data] tuple, so error handling is a destructure instead of try/catch.

- **client.profile.get() / .update()** — read and write profile data
- **[error, data] tuples** — predictable error handling
- **All feature areas** — newsletters, tickets, subscriptions, auth

```html
<div class="flex flex-col gap-3">
  <button id="load-profile" class="btn btn-primary !min-h-0 !px-4 !py-2 w-fit text-sm">
    Load my profile via the API
  </button>
  <pre
    id="profile-output"
    class="overflow-x-auto rounded-lg bg-background-light p-4 font-mono text-xs text-text-light">(click the button — requires being signed in)</pre>
</div>

<script type="module">
  import { getUnidyClient } from "@unidy.io/sdk";

  document.getElementById("load-profile").addEventListener("click", async () => {
    const output = document.getElementById("profile-output");

    // getUnidyClient() reads its config from <u-config> on the page.
    // Every service call returns a [error, data] tuple — no try/catch needed.
    const client = getUnidyClient();
    const [error, profile] = await client.profile.get();

    output.textContent = error
      ? `Error: ${JSON.stringify(error, null, 2)}`
      : JSON.stringify(profile, null, 2);

    // Also available: client.newsletters, client.tickets, client.subscriptions, client.auth
  });
</script>
```

Live demo: /headless#unidy-client

## Mix with any library: profile QR code

SDK data is just data — here the signed-in user's name is fetched via the client and rendered as a QR code with a third-party library. The same pattern powers loyalty cards, wallet passes, or personalized widgets.

- **SDK + npm ecosystem** — combine with any client-side library
- **u-signed-in gating** — components and JS API work together

```html
<u-signed-in>
  <!-- Combine SDK data with any third-party library — here: a QR code
       encoding the user's name, rendered fully client-side -->
  <canvas id="profile-qr-canvas" width="200" height="200"></canvas>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to see your profile QR
    code.
  </p>
</u-signed-in>

<script type="module">
  import QRCode from "https://esm.sh/qrcode@1.5.4";
  import { authState, onAuthChange, getUnidyClient } from "@unidy.io/sdk";

  async function renderQr() {
    const [error, profile] = await getUnidyClient().profile.get();
    if (error) return;
    const name = [profile.first_name?.value, profile.last_name?.value].filter(Boolean).join(" ");
    QRCode.toCanvas(
      document.getElementById("profile-qr-canvas"),
      `Hello, ${name || "Unidy user"}!`
    );
  }

  // Session restore is async on page load, so react to auth state instead
  // of checking once
  if (authState.authenticated) renderQr();
  onAuthChange("authenticated", (authenticated) => authenticated && renderQr());
</script>
```

Live demo: /headless#profile-qr

## Ticket transfers via getUnidyClient

client.ticketTransfers mirrors the u-ticket-transfer-* components as a plain API: list() returns pending incoming and outgoing offers, and accept/decline/cancel take a token. Accepted transfers are undone by ticket id instead — revoke() and return() resolve to the updated ticket, not to a transfer. Errors come back as stable identifiers such as feature_disabled or ticket_not_transferred.

- **ticketTransfers.list()** — incoming and outgoing pending offers
- **accept / decline / cancel** — act on a transfer by token
- **revoke / return** — act on a ticket id; auth.userTokenPayload() tells owner from holder
- **Stable error codes** — feature_disabled, transfer_expired, ticket_not_transferred, …

```html
<u-signed-in>
  <div class="flex flex-col gap-3">
    <div class="flex flex-wrap gap-2">
      <button id="load-transfers" class="btn btn-primary !min-h-0 !px-4 !py-2 w-fit text-sm">
        List pending offers
      </button>
      <button id="load-transfer-tickets" class="btn btn-outline !min-h-0 !px-4 !py-2 w-fit text-sm">
        List tickets in transfer
      </button>
    </div>
    <div id="transfers-output" class="text-sm text-text-light">
      (click a button to load pending offers or already-transferred tickets)
    </div>
  </div>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to list transfers.
  </p>
</u-signed-in>

<script type="module">
  import { Auth, getUnidyClient } from "@unidy.io/sdk";

  const output = document.getElementById("transfers-output");
  const client = getUnidyClient();

  const row = (label, actions = "") =>
    `<li class="flex items-center justify-between gap-3 border-b border-border py-2">
       <span>${label}</span>${actions}</li>`;

  const button = (attrs, text) =>
    `<button ${attrs} class="text-primary underline">${text}</button>`;

  // --- Pending offers: identified by a transfer token -------------------

  async function accept(token) {
    // Every ticketTransfers call returns a [error, data] tuple. The error is a
    // stable identifier (feature_disabled, transfer_expired, …) — map it to your
    // own copy; the SDK's own translation helper is internal to the components.
    const [error] = await client.ticketTransfers.accept({ token });
    if (error) return alert(error);
    loadOffers(); // refresh the list
  }

  async function loadOffers() {
    const [error, transfers] = await client.ticketTransfers.list();
    if (error) {
      output.textContent = `Could not load transfers: ${error}`;
      return;
    }

    const label = (t) =>
      `${t.ticket?.title ?? "Ticket"} — ${t.recipient_email ?? t.sender_email ?? ""}`;

    output.innerHTML = `
      <p class="font-semibold text-text">Incoming (${transfers.incoming.length})</p>
      <ul>${transfers.incoming.map((t) => row(label(t), button(`data-token="${t.token}"`, "Accept"))).join("") || "<li class='py-2'>none</li>"}</ul>
      <p class="mt-3 font-semibold text-text">Outgoing (${transfers.outgoing.length})</p>
      <ul>${transfers.outgoing.map((t) => row(label(t))).join("") || "<li class='py-2'>none</li>"}</ul>`;

    output
      .querySelectorAll("button[data-token]")
      .forEach((b) => b.addEventListener("click", () => accept(b.dataset.token)));
  }

  // --- Accepted transfers: identified by a ticket id --------------------
  // Once an offer is accepted there is no transfer object left to act on —
  // the ticket itself carries a holder_id. The owner can pull it back
  // (revoke), the holder can hand it over (return). Both resolve to the
  // updated *ticket*, not to a transfer.

  async function currentUserId() {
    // userTokenPayload() decodes the SDK's JWT — `sub` is the Unidy user id.
    const auth = await Auth.getInstance();
    return (await auth.userTokenPayload())?.sub ?? null;
  }

  async function act(action, ticketId) {
    const [error, ticket] = await client.ticketTransfers[action]({ ticketId });
    if (error) return alert(error);
    console.log(`${action} ok — ticket is now held by`, ticket.holder_id ?? ticket.user_id);
    loadTickets(); // refresh the list
  }

  async function loadTickets() {
    const me = await currentUserId();
    const [error, tickets] = await client.tickets.list({ perPage: 20 });
    if (error) {
      output.textContent = `Could not load tickets: ${error}`;
      return;
    }

    // holder_id is set and differs from the owner → the ticket is away from home.
    const inTransfer = tickets.results.filter((t) => t.holder_id && t.holder_id !== t.user_id);

    output.innerHTML = `
      <p class="font-semibold text-text">Tickets in transfer (${inTransfer.length})</p>
      <ul>${
        inTransfer
          .map((t) =>
            row(
              t.title,
              t.user_id === me
                ? button(`data-action="revoke" data-ticket="${t.id}"`, "Revoke")
                : button(`data-action="return" data-ticket="${t.id}"`, "Return")
            )
          )
          .join("") || "<li class='py-2'>none</li>"
      }</ul>`;

    output
      .querySelectorAll("button[data-ticket]")
      .forEach((b) => b.addEventListener("click", () => act(b.dataset.action, b.dataset.ticket)));
  }

  document.getElementById("load-transfers").addEventListener("click", loadOffers);
  document.getElementById("load-transfer-tickets").addEventListener("click", loadTickets);
</script>
```

Live demo: /headless#ticket-transfers

## Further reading

- Full component reference: https://github.com/UnidyID/unidy-sdk/blob/HEAD/packages/sdk/readme.md
- Official quick-start examples: https://github.com/UnidyID/unidy-sdk/blob/HEAD/packages/sdk/quick-start-examples.md
- Package: https://www.npmjs.com/package/@unidy.io/sdk
- All guides: /llms.txt
