MainSDK
Search⌘K
Interfaces

Files, sharing and media

App links

links

Create getqr.id deep, invite and sign-in links that open the right screen on the web, Android and iOS, with association files generated from main.yaml.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec linksFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

links makes one link that opens the right screen wherever someone taps it, in your app on Android or iOS when installed and on the web when not, for invites, notifications and sign-in.

At a glance

links:
  base: https://app.example.com
  routes:
    order: { web: "/orders/:id", app: "order/:id", params: [id] }

Calls

  • links.create(route)
  • links.invite(route, { email?, uses?, expiresIn? })
  • links.accept(req, token)
  • links.signIn({ email } | { phone }, { next? })
  • links.resolve(url)
  • links.revoke(id)
  • createLinks(o)
  • parseLinks(raw)
  • links.route(req)

Guarantees

Whatever the driver.

  • Every link minted by share on getqr.id, expiring and revocable
  • Nothing from the route in the getqr.id URL
  • Invites single use by default, optionally bound to an email
  • Sign-in links never return to the caller and never reveal who has an account
  • Universal Links and App Links association files generated from main.yaml

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlgetqr.id (default)
links:
  driver: getqr

Change this one line to swap providers; your calls stay the same.getqr.id integration

Codes

CodeWhere it appearsMeaning
MAIN-LINK-0003Checkmainsdk check, before deployA route in links.routes declares a param that identifies a person: email, phone, name, ip, token or a labelled field
MAIN-LINK-0004Checkmainsdk check, before deploylinks.apps is missing what association files need (iOS team-id and bundle-id, Android package and a sha256), or links.base isn't an HTTPS origin
MAIN-LINK-0005Checkmainsdk check, before deploylinks.signIn is called but link isn't in auth.methods (runtime: 400)
MAIN-LINK-0001RuntimeError responses and logsInvite link used up, expired, revoked, or accepted by a session whose verified email isn't the bound one (410; a mismatch leaves the invite valid)
MAIN-LINK-0002RuntimeError responses and logslinks.create or links.invite named a screen not in links.routes, or params missing or not declared (400)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
links@1 contractPhase 10.4links#contract
Deep links and resolvePhase 10.4links#deep
applinks association files generated from main.yamlPhase 10.4links#association
Invite linksPhase 10.4links#invite
Sign-in links that hand off to authPhase 10.4links#sign-in
Local links with no networkPhase 10.4links#local
Check rules for routes and app identifiersPhase 10.4links#checks
Egress conformance for every producerPhase 10.4links#egress
Passkey association in the same filesPhase 31.1mobile-auth#association-files

Quick start

main.yaml
schema: 1
links:
  base: https://app.example.com
  routes:
    order: { web: /orders/:id, app: orders/:id, params: [id] }
app/orders/actions.ts
"use server";
import { links } from "mainsdk";

export async function orderLink(orderId: string) {
  const link = await links.create({ screen: "order", params: { id: orderId } });
  return link.url;   // a getqr.id link that lands on https://app.example.com/orders/<id>
}

links.create() returns { id, url, qr, expiresAt, kind }. The URL names neither the screen nor the id. A screen you did not declare, or params that differ from the declared list, fail with MAIN-LINK-0002 before any request.

Configuration

main.yaml
links:
  base: https://app.example.com     # the app's own HTTPS origin; deep links land here
  default-expiry: 30d               # capped by share.max-expiry
  key: secret:LINKS_KEY             # keys the hash of a bound invite email
  routes:
    order: { web: /orders/:id, app: orders/:id, params: [id] }
    list:  { web: /lists/:id,  app: lists/:id,  params: [id] }
  apps:
    ios: { team-id: ABCDE12345, bundle-id: com.example.app }
    android: { package: com.example.app, sha256: ["14:6D:E9:…"] }
    store: { ios: "https://apps.apple.com/app/id123", android: "https://play.google.com/store/apps/details?id=com.example.app" }
KeyTypeDefaultWhat it does
baseURLnoneRequired. The app's own HTTPS origin (http outside production). Anything else fails with MAIN-CONFIG-0003
default-expiryduration30dExpiry of a deep link; never above share.max-expiry
keysecret referenceone per processKeys the hash of an invite's bound email. Without it, email-bound invites stop working after a restart
routes.<screen>mapnoneweb (path with :param slots), app (the in-app path) and params (the exact list of ids the route takes)
apps.ios, apps.androidmapnoneApp identifiers the association files need
apps.storemapnoneStore pages for fallback: "store"

Invite and link records live in the app's kv, scoped to the tenant. Links mint through share, so share settings (driver, key, max-expiry) apply: share.driver: local mints links in process in development.

Building it by hand

The SDK builds links for you from main.yaml. Call these only when you wire it by hand, for example in a test or a custom runtime.

NameWhat it is
parseLinks(raw)Reads the links section (kebab-case keys) into a config: base without a trailing slash, defaultExpiry (30d when unset), routes with their web and app paths and params, and the optional apps block for iOS, Android and store links.
createLinks(o)Builds the links interface over a share driver and a link store. Settings: config, share, store and hashKey (it keys the hash of a bound email, so the store never holds an address). Optional settings: now, log, inviter, session, auth, authMethods, send, sleep and answerMs (the least time every sign-in answer takes, 400 ms by default).

What you get

You declare your screens once under links.routes. Every link is a share minted through getqr.id, so it expires, can be revoked and shows nothing of the screen or its params in the URL. Reach for it for invites, order notifications, sign-in links and anything you send that should land on a specific screen. A link grants nothing: whoever opens it still signs in and passes access.

A reminder message carries a link as a {{link.<name>}} placeholder declared under notify.engagement.links. It is not a links route per person: engagement mints one share per journey, rung and variant through share (placement <journey>.<rung>.<variant>, mainsdk_kind: engagement), so no link identifies a person. Opens return as aggregate counts per variant (ADR-0056). Every producer's link, from auth.invites.create (mainsdk_kind: invite) to feeds, imports reports and the seal record page, goes through share; no module calls a getqr.id host itself.

Examples

Create the link when you send. The sender is your own code, such as notify.

app/orders/notify-shipped.ts
import { links } from "mainsdk";

export async function shippedMessage(orderId: string) {
  const link = await links.create({ screen: "order", params: { id: orderId } }, { expiresIn: "14d" });
  return `Your order has shipped: ${link.url}`;
}

links.resolve() takes a request or a URL, either the getqr.id link or a URL on links.base. It returns the screen and params and never throws for an unknown URL.

app/open/route.ts
import { links } from "mainsdk";

export async function GET(req: Request) {
  const target = await links.resolve(new URL(req.url).searchParams.get("u") ?? "");
  return Response.json(target);   // { kind, route, valid, reason? }
}

valid is false with reason of expired, revoked, used-up or unknown (then route is null).

Invite someone

An invite is a link that works for a number of uses and, if you bind an email, only for the signed-in person with that verified email. links.accept(req, token) needs a session, checks the invite and uses one up atomically. A used-up, expired, revoked or wrong-email invite fails with MAIN-LINK-0001 (410); a wrong email leaves the invite usable for the right person.

app/lists/[id]/invite/route.ts
import { auth, links } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req);
  const { id } = await params;
  const invite = await links.invite(
    { screen: "list", params: { id } },
    { email: "jane@example.com", uses: 1, expiresIn: "7d" },
  );
  return Response.json({ url: invite.url, expiresAt: invite.expiresAt });
}

Organisation invites stay in auth.invites. links.invite is for your own things: a list, a circle, a shared document. links.revoke(id) revokes the share and the invite record.

Sign in from an inbox

links.signIn({ email }) or ({ phone }) asks auth for a single-use token, wraps it in a 15-minute link and delivers it by email or, with via: "whatsapp", WhatsApp. It returns { sent: true, expiresAt } and never the link. An address with no account gets the same answer and nothing is sent, and every answer takes at least 400 ms, so speed never tells who has an account. It needs link in auth.methods (MAIN-LINK-0005 otherwise).

app/api/sign-in/route.ts
import { links } from "mainsdk";

export async function POST(req: Request) {
  const { email } = (await req.json()) as { email: string };
  const out = await links.signIn({ email }, { next: { screen: "order", params: { id: "ord_1" } } });
  return Response.json(out);   // { sent: true, expiresAt }
}

Association files

With links.apps set, links.route(req) answers /.well-known/apple-app-site-association (an applinks entry for <team-id>.<bundle-id> with one component per web route) and /.well-known/assetlinks.json (the package and fingerprints). It also serves /_mainsdk/store (the store page for the phone's platform) and /_mainsdk/auth/link (the sign-in redirect). It returns undefined for any other path.

app/.well-known/[...path]/route.ts
import { links } from "mainsdk";

export async function GET(req: Request) {
  return (await links.route(req)) ?? new Response("Not found", { status: 404 });
}

Errors and checks

CodeWhereMeaning and fix
MAIN-AUTH-0046runtimeA sign-in link that is used, expired, unknown or opened in another browser (401)

See check codes and errors.

Swapping drivers

links has no driver of its own; it uses the share driver. Set share.driver: local in development and getqr in production.

Testing

createTestApp runs links on the local share driver with no network.

app/orders/links.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { links } from "mainsdk";

describe("links", () => {
  it("links AC1 makes a link that names no screen or id", async () => {
    createTestApp({ main: "./main.yaml" });
    const link = await links.create({ screen: "order", params: { id: "ord_1" } });
    assert.ok(!link.url.includes("ord_1"));
    await assert.rejects(links.create({ screen: "invoice" }), { code: "MAIN-LINK-0002" });
  });

  it("links AC3 reports a revoked link", async () => {
    createTestApp({ main: "./main.yaml" });
    const link = await links.create({ screen: "order", params: { id: "ord_1" } });
    await links.revoke(link.id);
    assert.partialDeepStrictEqual(await links.resolve(link.url), { valid: false, reason: "revoked" });
  });
});

Security and privacy

  • Links go through getqr.id, like every share link and QR code in a MainSDK app.
  • Keep personal data out of params; use record ids your app resolves after sign-in. mainsdk check fails a route that declares a person-identifying param.
  • An invite stores its token only as a SHA-256 digest and a bound email only as a keyed hash.
  • Tokens appear only in the target URL handed to share, never in a log, an audit record or an error.

FAQ

How is this different from share()? share() gives someone read-only access to a link or file for a while. links opens a screen in your app; the person still signs in and goes through your normal checks.

Do I write the association files myself? No. links.route() generates them from links.apps. The passkey part (webcredentials) comes with the mobile sign-in spec.

Do deferred deep links survive an app install? No, and they are out of scope: they need device fingerprinting.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet