LinkDen
Reference

API Reference

Architecture overview and endpoint reference for LinkDen's tRPC API.

API Reference

LinkDen's API is built with tRPC v11 on Hono, running on Cloudflare Workers. Every call is end-to-end type-safe -- no code generation required.

Architecture

Next.js Frontend (apps/web)
       |
       | tRPC Client (HTTP requests)
       |
       v
Hono Server (apps/server)
       |
       |-- Better Auth (session validation)
       |-- tRPC Router (packages/api)
       |
       v
Drizzle ORM (packages/db)
       |
       | SQL queries
       |
       v
Cloudflare D1 (SQLite)
  • Hono -- Lightweight web framework for edge runtimes. Zero cold start on Cloudflare Workers.
  • tRPC v11 -- End-to-end type safety. Router definitions live in packages/api.
  • Better Auth -- Email/password authentication. Session cookies validated on protected procedures.
  • Drizzle ORM -- Type-safe SQL for Cloudflare D1. Schema lives in packages/db/src/schema/.

Authentication

LinkDen uses Better Auth with email/password sign-in, optional TOTP two-factor and an optional magic link. Auth routes live under /api/auth/*.

Session Flow

  1. Create an admin account during first-time setup at /admin/setup (with the x-setup-token header when SETUP_TOKEN is set).
  2. Sign in at /admin/login. With two-factor on, Better Auth answers the password step with twoFactorRedirect and the login page asks for a TOTP or backup code.
  3. Better Auth issues a secure session cookie, sent with every API request to authenticate protected procedures.

Environment Variables

VariableDescription
BETTER_AUTH_SECRETSecret key for session signing (32+ characters outside localhost, or auth answers 503)
BETTER_AUTH_URLBase URL for the auth server (API server URL)
CORS_ORIGINTrusted origin for the frontend

Generate a secret with: openssl rand -base64 32

Tip: If you hit 401 errors, double-check that CORS_ORIGIN matches your frontend URL exactly -- including protocol, no trailing slash.

Request rules

  • POST requests to /trpc/* must come from CORS_ORIGIN or the API's own origin (403 otherwise). Bodies are capped at 1 MB (16 MB for backup.*).
  • Batched requests (/trpc/a,b?batch=1) may contain at most 20 calls. public.submitContact, public.trackView and public.trackClick are rate limited per call, so a batch spends one token for each of them.
  • Errors never include stack traces; unexpected failures return "Internal server error".

Endpoint Reference

All endpoints live at /trpc/<router>.<procedure>. Public procedures need no auth. Protected procedures require a valid session cookie.

Blocks

Manage content blocks on your page. New blocks start as drafts (unpublished); edits to published blocks go live immediately.

ProcedureTypeAuthDescription
blocks.listQueryProtectedAll blocks ordered by (position, createdAt, id)
blocks.createMutationProtectedCreate a block. The server appends it at the end (MAX(position)+1) unless it is an Undo re-insert, which goes back to its old slot
blocks.updateMutationProtectedUpdate block fields by ID
blocks.deleteMutationProtectedDelete a block by ID (its click history is deleted too)
blocks.reorderMutationProtectedBatch update block positions (max 500)
blocks.toggleEnabledMutationProtectedToggle block visibility
blocks.publishAllMutationProtectedPublish every draft block
blocks.hasDraftQueryProtected{ hasDraft }: whether any block is unpublished

Settings

Manage site-wide key-value configuration. Every key and value is checked against the settings registry; secret values are masked when read.

ProcedureTypeAuthDescription
settings.getQueryProtectedGet a single setting by key
settings.getAllQueryProtectedGet all settings as a key-value map
settings.updateMutationProtectedUpsert a single key-value pair
settings.updateBulkMutationProtectedUpsert multiple key-value pairs in one atomic batch

Forms (Inbox)

Contact form submissions. Filters: isRead?: boolean, blockId?: string.

ProcedureTypeAuthDescription
forms.listQueryProtectedNewest first, paged with limit (1-100, default 50) and offset, plus the filters
forms.countQueryProtected{ total } for the same filters, so "n of total" matches the list
forms.markReadMutationProtectedMark a submission as read
forms.markUnreadMutationProtectedMark a submission as unread
forms.deleteMutationProtectedDelete a submission
forms.markAllReadMutationProtectedMark every submission as read
forms.deleteMultipleMutationProtectedDelete up to 100 submissions by ID
forms.unreadCountQueryProtectedCount of unread submissions

Analytics

Page views and clicks. Every query takes { period: "7d" | "30d" | "90d" | "all" } (default "7d"). Daily buckets follow the admin's timezone setting.

ProcedureTypeAuthDescription
analytics.overviewQueryProtectedTotal views and clicks for the period
analytics.viewsOverTimeQueryProtectedDaily view counts
analytics.clicksOverTimeQueryProtectedDaily click counts
analytics.topLinksQueryProtectedTop 10 links by clicks
analytics.referrersQueryProtectedTop 10 referrer hostnames
analytics.countriesQueryProtectedTop 20 countries by views

Social

Social network links shown under the profile.

ProcedureTypeAuthDescription
social.listQueryProtectedList saved networks
social.updateBulkMutationProtectedCreate, update or remove networks in one batch

Wallet

Configure and sign Apple Wallet passes. The .pkpass itself is downloaded from GET /api/admin/wallet-pass (session required).

ProcedureTypeAuthDescription
wallet.getConfigQueryProtectedGet wallet settings
wallet.updateConfigMutationProtectedUpdate wallet settings (fields validated per section; an empty field key is rejected with its section and number)
wallet.getSigningStatusQueryProtectedWhich signing items are configured and loadable
wallet.getSigningKeysQueryProtectedSaved Team ID / Pass Type ID and whether each key is saved (never the key material)
wallet.updateSigningKeysMutationProtectedSave signing material (raw or base64-wrapped PEM, base64 DER certs); unloadable material is rejected with BAD_REQUEST
wallet.generatePreviewQueryProtectedPass content as the signer would build it

vCard

The global vCard (Design → Branding). The public download is GET /api/vcard (?block=<id> for a vCard block's card).

ProcedureTypeAuthDescription
vcard.getConfigQueryProtectedGet the vCard toggle and data
vcard.updateConfigMutationProtectedUpdate the vCard toggle and fields

Backup

Import and export data.

ProcedureTypeAuthDescription
backup.exportQueryProtectedExport blocks, non-secret settings, social networks and contact messages as JSON, with notes and an images list of referenced image keys (image files are not included)
backup.importMutationProtectedImport a backup in "merge" or "replace" mode in one atomic batch. Replace keeps secret settings, upserts blocks in place (removing ones missing from the backup) and deletes only messages absent from the backup. Accepts ISO or epoch-ms dates; caps: 500 blocks, 500 settings, 5,000 messages on the wire (newest 500 written). Returns counts plus skipped.{blocks, socialNetworks, settings, contactSubmissions}
backup.importLinkStackMutationProtectedImport a LinkStack export (http(s) links appended after existing blocks, profile, matching theme preset)

Danger

Destructive resets from Settings → Data. Both are audited and run as one atomic batch.

ProcedureTypeAuthDescription
danger.deleteAllContentMutationProtectedDelete all blocks, analytics and contact messages; keeps the account, settings and social links
danger.resetEverythingMutationProtectedWipe every table (including the audit log and the admin account); the client then signs out and returns to setup

Version

ProcedureTypeAuthDescription
version.checkUpdateQueryProtectedCompare version.json with the latest GitHub release. Input { force?: boolean }. Returns status: "ok" | "unavailable" and a reason when unavailable. Cached per isolate (6h after success, 10 min after failure); force skips the cache but reuses an answer fetched in the last 60s

Public

Public-facing endpoints -- no authentication needed.

ProcedureTypeDescription
public.getPageQueryFull page data: profile, published blocks, theme, public settings, captchaProvider and captchaSiteKey (never the secret)
public.submitContactMutationSubmit the contact form. Input: firstName, lastName, email, consent: true, optional message, whereMet, blockId, blockTitle, captchaToken, and a website honeypot that must stay empty. Rate limited per call
public.trackViewMutationRecord a page view. Optional { referrer } (document.referrer), stored as its hostname only
public.trackClickMutationRecord a link click: { blockId, referrer? }
public.hasUsersQueryWhether an admin account exists
public.getSetupStatusQuerySetup complete, email configured, magic link available, and login branding

All input is validated with shared Zod schemas from packages/validators.

REST routes on the API Worker

RouteAuthDescription
GET /api/healthPublicD1 check, version, email and auth state (503 when degraded)
POST /api/uploadProtectedImage upload (file, purpose, optional replaces)
GET /api/images/*PublicServe an uploaded image
GET /api/vcardPublicvCard download (?block=<id> optional)
GET /api/admin/wallet-passProtectedSigned .pkpass download

On this page