Connecting your app
Userdeck reads your users on demand — nothing is synced or stored. Your app exposes two endpoints (the Connect API) and pushes lifecycle events and support tickets to Userdeck. The dependency is strictly one-way: Userdeck calls your app, your app posts to Userdeck — nothing of Userdeck runs inside your product. The credentials are generated when you connect the app in Settings → Connected apps.
1. The Connect API (you implement this)
Two endpoints under your app's base URL — plus an optional third — protected by the bearer token (USERDECK_CONNECT_TOKEN):
GET /api/admin/v1/users?query=&limit=&cursor=&sort=&metadata.<key>=<value> GET /api/admin/v1/users/:id GET /api/admin/v1/config (optional) GET /api/admin/v1/sessions/:key (optional) Authorization: Bearer <USERDECK_CONNECT_TOKEN>
List response:
{
"users": [
{
"id": "usr_99x", // REQUIRED — stable external ID
"createdAt": "2026-01-10T08:00:00Z", // REQUIRED
"email": "a@b.at", // nullable
"name": "Alex Miller", // nullable
"plan": "pro", // nullable
"status": "active", // nullable
"lastSeenAt": "2026-07-17T19:04:00Z", // nullable — powers activity tags
"revenue": { "lifetime": 34800, "mrr": 2900 }, // integer cents, nullable
"metadata": { "state": "Wien" } // flat key → string|number|bool
}
],
"nextCursor": "opaque-string-or-null"
}- Only
idandcreatedAtare required — anonymous leads are fine. queryis a server-side substring search over email + name (ILIKE).sort=plan(optional but recommended): return your most valuable plan first — the order you declare in/config— ties bylastSeenAtdesc, plan-less users last. That's what puts your top users on the first page of the dashboard. Ignoring the param is fine; the page then sorts only what it has loaded.metadata.<key>=<value>filters are passed through — add a WHERE clause.plan/status/revenue are filtered by Userdeck on the fetched page, not passed to you.revenueis integer cents so cross-app aggregation stays exact.
Optional: declare your plans & metadata (/config)
Without it, Userdeck discovers plans and metadata keys from the users it happens to fetch. Implement GET /api/admin/v1/configand you get stable plan ordering (most valuable first) and pre-populated filter dropdowns instead. Returning 404 is fine — that just means "discover from data".
{
"plans": [ // ordered most → least valuable; order IS the rank
{ "id": "pro", "label": "Pro" }, // id REQUIRED (matches users' "plan"), label nullable
{ "id": "free", "label": "Free" }
],
"metadata": { // key → descriptor; undeclared keys may still appear
"berater": {
"label": "Berater", // nullable — display name, falls back to the key
"description": "Assigned advisor", // nullable — shown as tooltip
"type": "string", // REQUIRED — "string" | "number" | "boolean"
"values": ["julian", "sarah"] // nullable — enum → dropdown; absent → free-text
}
}
}Userdeck caches this for 5 minutes and treats errors or invalid shapes as "no config" — it can never break anything. A static route is enough:
// app/api/admin/v1/plans.ts — ONE array drives /config AND sort=plan
export const PLANS = [{ id: "pro", label: "Pro" }, { id: "free", label: "Free" }];
export const planRank = (plan: string | null) => {
const i = PLANS.findIndex((p) => p.id === plan);
return i === -1 ? 0 : PLANS.length - i; // most valuable = highest rank, unknown = 0
};
// app/api/admin/v1/config/route.ts
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.USERDECK_CONNECT_TOKEN}`)
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return NextResponse.json({ plans: PLANS, metadata: {} });
}2. Event webhooks (you send these)
POST https://<userdeck>/api/v1/ingest/events
Content-Type: application/json
X-Userdeck-App-Id: <USERDECK_APP_ID>
X-Userdeck-Signature: hex(hmac_sha256(rawBody, USERDECK_INGEST_SECRET))
{
"ext_user_id": "usr_99x",
"event_type": "signup", // signup | login | upgrade | downgrade |
// cancellation | payment | any string
"occurred_at": "2026-07-18T12:00:00Z",
"metadata": {}
}Events carry no name or email — only the external ID. They power the activity timeline, analytics, and email automations.
Session binding (for events fired from middleware/proxy, where the session cookie is opaque): fire a login event with ext_user_id and a metadata.session key when a session is created — a hash of the session token, e.g. sha256(token), never the raw token. After that, events for the same session may omit ext_user_id and just carry metadata.session; Userdeck resolves the user from the binding. Typical use: pageview events fired directly from your proxy on every navigation.
For sessions created before you integrated (no login event yet), optionally implement GET /api/admin/v1/sessions/:key → {"ext_user_id": "..."}or 404: Userdeck asks it at most once per unknown session and the stored event becomes the binding. Without it, such sessions simply start tracking at the user's next login.
3. Tickets (you send these)
Post ticket metadata from your backend — your own contact form, in-app feedback button, or anywhere else. Userdeck creates the ticket, emails the user a confirmation, and threads their email replies back into the conversation. Signed exactly like events.
POST https://<userdeck>/api/v1/ingest/tickets
Content-Type: application/json
X-Userdeck-App-Id: <USERDECK_APP_ID>
X-Userdeck-Signature: hex(hmac_sha256(rawBody, USERDECK_INGEST_SECRET))
{
"subject": "Can't export my data",
"body": "The export button spins forever…",
"ext_user_id": "usr_99x", // known user — email resolved via your Connect API
"contact_email": "a@b.at", // OR: address the visitor typed in (anonymous lead)
"kind": "support", // free-form label: support | feedback | bug | …
"metadata": { "page": "/settings" } // optional loose context, shown on the ticket
}At least one of ext_user_id / contact_email is required. If no email can be resolved, the ticket is still created — it just gets no confirmation email.
Reference implementation — Next.js (App Router, Drizzle)
// app/api/admin/v1/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { and, asc, ilike, or, type SQL } from "drizzle-orm";
import { db } from "@/db";
import { user } from "@/db/schema";
import { PLANS, planRank } from "../plans";
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.USERDECK_CONNECT_TOKEN}`)
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const p = req.nextUrl.searchParams;
const limit = Math.min(Number(p.get("limit")) || 50, 100);
const offset = Number(p.get("cursor")) || 0;
const where: SQL[] = [];
const q = p.get("query");
if (q) where.push(or(ilike(user.email, `%${q}%`), ilike(user.name, `%${q}%`))!);
// fetch matching rows, sort in code, slice — no SQL sorting gymnastics.
// In-memory is fine until you have serious user counts.
const rows = await db.select().from(user)
.where(where.length ? and(...where) : undefined)
.orderBy(asc(user.id));
if (p.get("sort") === "plan")
rows.sort((a, b) => planRank(b.plan) - planRank(a.plan));
return NextResponse.json({
users: rows.slice(offset, offset + limit).map((u) => ({
id: u.id,
createdAt: u.createdAt.toISOString(),
email: u.email,
name: u.name,
plan: u.plan,
status: null,
lastSeenAt: null,
revenue: null,
metadata: {},
})),
nextCursor: offset + limit < rows.length ? String(offset + limit) : null,
});
}Reference implementation — Fastify
// src/routes/userdeck-v1.routes.ts
import type { FastifyPluginAsync } from "fastify";
export const userdeckRoutes: FastifyPluginAsync = async (app) => {
app.addHook("onRequest", async (req, reply) => {
if (req.headers.authorization !== `Bearer ${process.env.USERDECK_CONNECT_TOKEN}`)
return reply.code(401).send({ error: "unauthorized" });
});
app.get("/api/admin/v1/users", async (req) => {
const { query, limit = 50, cursor = 0 } = req.query as Record<string, string>;
const rows = await findPersons({ query, limit: Number(limit) + 1, offset: Number(cursor) });
return {
users: rows.slice(0, Number(limit)).map((p) => ({
id: p.id,
createdAt: p.createdAt.toISOString(),
email: p.email,
name: [p.firstName, p.lastName].filter(Boolean).join(" ") || null,
plan: null, status: null, lastSeenAt: null, revenue: null,
metadata: { state: p.state, profession: p.profession, source: p.foundThrough },
})),
nextCursor: rows.length > Number(limit) ? String(Number(cursor) + Number(limit)) : null,
};
});
};Sending a signed event
import { createHmac } from "node:crypto";
const body = JSON.stringify({
ext_user_id: user.id,
event_type: "signup",
occurred_at: new Date().toISOString(),
});
await fetch("https://<userdeck>/api/v1/ingest/events", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Userdeck-App-Id": process.env.USERDECK_APP_ID!,
"X-Userdeck-Signature": createHmac("sha256", process.env.USERDECK_INGEST_SECRET!)
.update(body).digest("hex"),
},
body,
});Use Test connection in Settings after deploying — it fetches one page and validates the contract shape.