Skip to main content

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. 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. Status: The session token handoff mechanism is still being finalized. Before building on this pattern, confirm the current integration approach with the Aptly team.

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.
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:
{
  "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

{
  "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

StatusMeaning
400email field is missing or invalid
401API key is missing or invalid
404No contact found with that email address
500Contact 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.
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:
{
  "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

StatusMeaning
400code field is missing
401Code is wrong, expired, already used, or requestId not found
After 5 wrong codes the verification is permanently invalidated.
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.
// 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"] });
}
// 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

// 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.
// 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:
// 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:
FieldDescription
_idMongoDB document ID — use as your primary identifier
fullNameDisplay name (first + last, or company name)
emailVerified email address
phoneContact’s phone number
titleJob title
isCompanytrue if this is an organization record rather than a person

Display a contact card

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>
  `;
}

Once you have the contact’s _id, pass it as relatedId on the board endpoint to fetch all cards linked to that contact:
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.
// 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:
// 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);
// 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
}