> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getaptly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Contact Verification & Lightweight SSO

> Use the email verification flow to confirm a contact's identity, then load their data — no passwords required.

## Before you build — choose your auth context

Aptly supports several distinct auth patterns depending on who is using the app you're building. Pick the one that matches your use case before writing any code. Getting this wrong is the most common source of integration problems.

### Context 1 — Customer portal (contact verification SSO) ✅ Recommended starting point

**Who this is for:** You're an Aptly customer building an app for *your* contacts — a client portal, a booking page, a status tracker. Your end users are people stored as contacts in your Aptly account.

**How auth works:**

1. Aptly handles the initial identity check (email + 6-digit code)
2. Your app creates and manages its own session after verification succeeds
3. All Aptly API calls happen server-side, invisibly to the end user

**Architecture requirement — server component is mandatory.** Your Aptly API key grants access to your entire organization's data. If it appears anywhere in client-side HTML, JavaScript, or network requests visible in the browser, any user can extract it and query your full contact database. You must build a server-side component (Node.js, Python, a serverless function, etc.) that holds the API key and proxies all Aptly calls. A front-end-only app using an API key puts your entire organization at risk.

```
Browser ──POST /auth/start──► Your Server ──POST /api/contacts/verify-email──► Aptly API
                                            (API key stays here, never sent to browser)
Browser ◄── { requestId } ────────────────────────────────────────────────────────────────

[user enters code]

Browser ──POST /auth/confirm──► Your Server ──POST /api/contacts/verify-email/:id/confirm──► Aptly
                                              ◄── { verified: true, contacts: [...] } ──────────
                              issues signed session cookie
Browser ◄── Set-Cookie: session=<JWT> ──────────────────────────────────────────────────────────

[subsequent requests]

Browser ──GET /portal/cards──► Your Server (validates cookie, extracts contactId) ──► Aptly API
```

This document covers this context end-to-end.

***

### Context 2 — Aptly user session (plugin or embedded app)

**Who this is for:** You're building a plugin, iframe embed, or extension that runs *inside* the Aptly UI for logged-in Aptly users.

**How auth works:** Aptly passes a signed user session token to your app at load time (via `postMessage`, a URL parameter on redirect, or another handoff mechanism). Your app uses that token to call Aptly APIs on behalf of the logged-in user — no separate login flow required.

**Architecture:** Because the token is scoped to the session and carries no persistent API key, these apps can be front-end-only. There is no long-lived credential to protect.

See the [Embedded App Authentication](delegate-auth) guide for the full implementation.

***

### Context 3 — Public / unauthenticated app

**Who this is for:** You're building a public-facing surface — a company website, a listings page, a booking widget — where the visitor is anonymous and no login is required.

**How auth works:** Either no auth at all (public endpoints only), or an Aptly API key for read-only public data.

**Architecture:** If the app uses an API key, you still need a server component for the same reasons as Context 1. If the app uses only fully public endpoints, it can be front-end-only.

***

## Contact verification SSO — how it works

This is the full implementation guide for **Context 1**.

The flow has two steps:

1. Your app sends an email address to Aptly → Aptly looks up the contact and sends a 6-digit code
2. Your app sends the code back → Aptly confirms it and returns the contact record

Your server then issues a signed session cookie. All subsequent requests use that cookie to identify the contact — Aptly is not involved again until the session expires and the user needs to re-verify.

Codes expire after **10 minutes**. After **5 failed attempts**, the verification is permanently invalidated and the user must restart from step 1.

***

## Step 1 — Initiate verification

Call this from your server, not the browser. Your API key must not appear in client-side code.

```bash theme={null}
curl -X POST https://core-api.getaptly.com/api/contacts/verify-email \
  -H "x-token: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com"
  }'
```

**Response:**

```json theme={null}
{
  "requestId": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "verifyUrl": "/api/contacts/verify-email/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/confirm"
}
```

Return the `requestId` to the browser. It is not a secret — the code sent to the user's email is the secret. Store `requestId` in your UI state (a hidden field, React state, etc.) so you can submit it in step 2.

### Custom email content

```json theme={null}
{
  "email": "jane@example.com",
  "emailSubject": "Your login code for Acme Portal",
  "replyTo": "support@acme.com",
  "emailHtml": "<p>Hi! Your code is: <strong>{{ verificationCode }}</strong></p><p>It expires in {{ expirationTime }}.</p>"
}
```

`{{ verificationCode }}` and `{{ expirationTime }}` are replaced automatically. Omit `emailHtml` to use the Aptly default.

### Error cases

| Status | Meaning                                                 |
| ------ | ------------------------------------------------------- |
| `400`  | `email` field is missing or invalid                     |
| `401`  | API key is missing or invalid                           |
| `404`  | No contact found with that email address                |
| `500`  | Contact found but the verification email failed to send |

A `404` means the email isn't in your Aptly contact database. Show a "not found" message or a registration prompt — do not fall back to creating a session anyway.

***

## Step 2 — Confirm the code

The browser submits the code + `requestId` to your server. Your server calls Aptly and creates the session.

```bash theme={null}
curl -X POST https://core-api.getaptly.com/api/contacts/verify-email/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/confirm \
  -H "x-token: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "042815"
  }'
```

**On success:**

```json theme={null}
{
  "verified": true,
  "contacts": [
    {
      "_id": "64a1f2b3c4d5e6f7a8b9c0d1",
      "firstname": "Jane",
      "lastname": "Doe",
      "fullName": "Jane Doe",
      "email": "jane@example.com",
      "phone": "555-867-5309",
      "isCompany": false,
      "company": null
    }
  ]
}
```

### Error cases

| Status | Meaning                                                        |
| ------ | -------------------------------------------------------------- |
| `400`  | `code` field is missing                                        |
| `401`  | Code is wrong, expired, already used, or `requestId` not found |

After 5 wrong codes the verification is permanently invalidated.

***

## Step 3 — Issue a session cookie

**Do not trust a client-supplied `contactId` on subsequent requests.** MongoDB ObjectIds are not secrets — they are time-ordered and partially predictable. If you store `_id` raw in `localStorage` or an unsigned cookie, any user can swap it for another contact's ID. Always lock the `contactId` inside a signed token that your server verifies on every request.

After the confirm step succeeds, mint a JWT, set it as an `HttpOnly` cookie, and return only safe display fields to the browser.

```javascript theme={null}
// server-side (Node.js + jsonwebtoken)
import jwt from "jsonwebtoken";

const SESSION_SECRET = process.env.SESSION_SECRET; // long random string, kept server-side only
const SESSION_TTL = "4h"; // adjust to your app's needs

function issueSessionToken(contact) {
  return jwt.sign(
    {
      sub: contact._id, // contactId — locked inside the signature
      email: contact.email,
      name: contact.fullName,
    },
    SESSION_SECRET,
    { expiresIn: SESSION_TTL, algorithm: "HS256" },
  );
}

function verifySessionToken(token) {
  // throws if expired, tampered, or signed with the wrong secret
  return jwt.verify(token, SESSION_SECRET, { algorithms: ["HS256"] });
}
```

```javascript theme={null}
// POST /auth/start — browser hits this; your server calls Aptly
app.post("/auth/start", async (req, res) => {
  const { email } = req.body;

  const aptlyRes = await fetch(`${APTLY_BASE}/api/contacts/verify-email`, {
    method: "POST",
    headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });

  if (aptlyRes.status === 404) {
    return res.status(404).json({ error: "No account found for that email." });
  }
  if (!aptlyRes.ok) {
    return res
      .status(500)
      .json({ error: "Failed to send verification email." });
  }

  const { requestId } = await aptlyRes.json();
  res.json({ requestId }); // safe to return — not a secret
});

// POST /auth/confirm — browser submits code + requestId; your server confirms and issues cookie
app.post("/auth/confirm", async (req, res) => {
  const { requestId, code } = req.body;

  const aptlyRes = await fetch(
    `${APTLY_BASE}/api/contacts/verify-email/${requestId}/confirm`,
    {
      method: "POST",
      headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ code }),
    },
  );

  if (!aptlyRes.ok) {
    return res.status(401).json({ error: "Invalid or expired code." });
  }

  const { contacts } = await aptlyRes.json();
  const contact = contacts[0];

  const sessionToken = issueSessionToken(contact);

  // HttpOnly prevents client JS from reading or modifying the cookie
  res.cookie("session", sessionToken, {
    httpOnly: true,
    secure: true, // requires HTTPS in production
    sameSite: "lax",
    maxAge: 4 * 60 * 60 * 1000, // 4 hours in ms — match SESSION_TTL
  });

  // Return only display-safe fields — never the raw contactId
  res.json({ name: contact.fullName, email: contact.email });
});
```

### Validate the session on every protected request

```javascript theme={null}
// Middleware — runs before any route that needs an authenticated contact
function requireContact(req, res, next) {
  const token = req.cookies.session;
  if (!token) return res.status(401).json({ error: "Not authenticated." });

  try {
    const payload = verifySessionToken(token); // throws if expired or tampered
    req.contactId = payload.sub; // extracted from signed token — never from req.body or req.query
    next();
  } catch {
    res.status(401).json({ error: "Session expired or invalid." });
  }
}

// Protected route — contactId comes from the verified token, not the client
app.get("/portal/cards", requireContact, async (req, res) => {
  const { contactId } = req; // safe — cryptographically bound to the verified contact

  const aptlyRes = await fetch(
    `${APTLY_BASE}/api/board/${BOARD_ID}?page=0&relatedId=${contactId}`,
    { headers: { "x-token": APTLY_API_KEY } },
  );

  res.json(await aptlyRes.json());
});
```

**Never accept `contactId` as a query param or body field from the browser on protected endpoints.** The only valid source is the value extracted from the verified session cookie.

### Session persistence on revisit

On page load, your browser-side code should call a lightweight `/auth/me` endpoint to check whether a valid session cookie already exists. If it does, the user is still logged in and does not need to re-verify.

```javascript theme={null}
// GET /auth/me — check for an active session
app.get("/auth/me", requireContact, async (req, res) => {
  // requireContact already validated the cookie — if we're here, the session is valid
  // You can re-fetch the contact from Aptly if you need fresh data,
  // or return what's already in the token for a fast response
  const token = req.cookies.session;
  const payload = verifySessionToken(token);
  res.json({ name: payload.name, email: payload.email, loggedIn: true });
});

// GET /auth/logout — clear the session
app.post("/auth/logout", (req, res) => {
  res.clearCookie("session");
  res.json({ loggedIn: false });
});
```

On the client:

```javascript theme={null}
// On app load — check for existing session before showing the login form
async function checkSession() {
  const res = await fetch("/auth/me");
  if (res.ok) {
    const { name, email } = await res.json();
    showPortal(name, email); // user is still logged in
  } else {
    showLoginForm(); // session expired or never existed — prompt for email
  }
}
```

***

## Contact object reference

The contact returned on successful verification:

| Field       | Description                                                   |
| ----------- | ------------------------------------------------------------- |
| `_id`       | MongoDB document ID — use as your primary identifier          |
| `fullName`  | Display name (first + last, or company name)                  |
| `email`     | Verified email address                                        |
| `phone`     | Contact's phone number                                        |
| `title`     | Job title                                                     |
| `isCompany` | `true` if this is an organization record rather than a person |

### Display a contact card

```javascript theme={null}
function renderContactCard(contact) {
  return `
    <div class="contact-card">
      ${
        contact.imageUrl
          ? `<img src="${contact.imageUrl}" alt="${contact.fullName}" />`
          : `<div class="avatar">${contact.duogram}</div>`
      }
      <h2>${contact.fullName}</h2>
      ${contact.title ? `<p>${contact.title}</p>` : ""}
      <p>${contact.email}</p>
      ${contact.phone ? `<p>${contact.phone}</p>` : ""}
    </div>
  `;
}
```

***

## Loading related board cards for a verified contact

Once you have the contact's `_id`, pass it as `relatedId` on the board endpoint to fetch all cards linked to that contact:

```bash theme={null}
curl "https://core-api.getaptly.com/api/board/{boardId}?page=0&relatedId=64a1f2b3c4d5e6f7a8b9c0d1" \
  -H "x-token: YOUR_API_KEY"
```

Cards are linked to a contact when the contact's `_id` appears in the card's `references` field.

```javascript theme={null}
// Call this from your server — contactId comes from the verified session, not the browser
async function getContactCards(boardId, contactId) {
  const url = new URL(`https://core-api.getaptly.com/api/board/${boardId}`);
  url.searchParams.set("page", "0");
  url.searchParams.set("relatedId", contactId);

  const res = await fetch(url, {
    headers: { "x-token": APTLY_API_KEY },
  });

  if (!res.ok) throw new Error("Failed to load board cards.");

  return res.json(); // { cards: [...], total: n, page: 0 }
}
```

***

## Full end-to-end example

A minimal but complete Node.js + Express backend that handles all three auth endpoints, plus a corresponding browser-side flow:

```javascript theme={null}
// server.js — Node.js + Express
import express from "express";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";

const app = express();
app.use(express.json());
app.use(cookieParser());

const APTLY_BASE = "https://core-api.getaptly.com";
const APTLY_API_KEY = process.env.APTLY_API_KEY; // never expose this to the browser
const APTLY_BOARD_ID = process.env.APTLY_BOARD_ID;
const SESSION_SECRET = process.env.SESSION_SECRET; // long random string, server-only
const SESSION_TTL = "4h";

function issueSessionToken(contact) {
  return jwt.sign(
    { sub: contact._id, email: contact.email, name: contact.fullName },
    SESSION_SECRET,
    { expiresIn: SESSION_TTL, algorithm: "HS256" },
  );
}

function verifySessionToken(token) {
  return jwt.verify(token, SESSION_SECRET, { algorithms: ["HS256"] });
}

function requireContact(req, res, next) {
  const token = req.cookies.session;
  if (!token) return res.status(401).json({ error: "Not authenticated." });
  try {
    req.contactId = verifySessionToken(token).sub;
    next();
  } catch {
    res.status(401).json({ error: "Session expired or invalid." });
  }
}

// Step 1 — initiate verification
app.post("/auth/start", async (req, res) => {
  const { email } = req.body;
  const aptlyRes = await fetch(`${APTLY_BASE}/api/contacts/verify-email`, {
    method: "POST",
    headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });
  if (aptlyRes.status === 404)
    return res.status(404).json({ error: "No account found." });
  if (!aptlyRes.ok)
    return res.status(500).json({ error: "Could not send code." });
  const { requestId } = await aptlyRes.json();
  res.json({ requestId });
});

// Step 2 — confirm code and issue session cookie
app.post("/auth/confirm", async (req, res) => {
  const { requestId, code } = req.body;
  const aptlyRes = await fetch(
    `${APTLY_BASE}/api/contacts/verify-email/${requestId}/confirm`,
    {
      method: "POST",
      headers: { "x-token": APTLY_API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ code }),
    },
  );
  if (!aptlyRes.ok)
    return res.status(401).json({ error: "Invalid or expired code." });
  const { contacts } = await aptlyRes.json();
  const contact = contacts[0];
  res.cookie("session", issueSessionToken(contact), {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 4 * 60 * 60 * 1000,
  });
  res.json({ name: contact.fullName, email: contact.email });
});

// Check existing session (called on page load)
app.get("/auth/me", requireContact, (req, res) => {
  const payload = verifySessionToken(req.cookies.session);
  res.json({ name: payload.name, email: payload.email, loggedIn: true });
});

// Logout
app.post("/auth/logout", (req, res) => {
  res.clearCookie("session");
  res.json({ loggedIn: false });
});

// Protected route — load cards for the verified contact
app.get("/portal/cards", requireContact, async (req, res) => {
  const aptlyRes = await fetch(
    `${APTLY_BASE}/api/board/${APTLY_BOARD_ID}?page=0&relatedId=${req.contactId}`,
    { headers: { "x-token": APTLY_API_KEY } },
  );
  if (!aptlyRes.ok)
    return res.status(502).json({ error: "Failed to load cards." });
  res.json(await aptlyRes.json());
});

app.listen(3000);
```

```javascript theme={null}
// client.js — runs in the browser, no API key here
async function init() {
  const res = await fetch("/auth/me");
  if (res.ok) {
    const { name } = await res.json();
    showPortal(name); // already logged in
  } else {
    showLoginForm();
  }
}

async function startVerification(email) {
  const res = await fetch("/auth/start", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });
  if (res.status === 404) throw new Error("No account found for that email.");
  if (!res.ok) throw new Error("Could not send verification code.");
  const { requestId } = await res.json();
  return requestId; // store in UI state for the next step
}

async function confirmVerification(requestId, code) {
  const res = await fetch("/auth/confirm", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ requestId, code }),
  });
  if (!res.ok) throw new Error("Invalid or expired code.");
  return res.json(); // { name, email } — safe display data only
}
```
