App links
linksCreate 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.
linksFree in your app, Apache-2.0Status 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.
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] }import { links } from "mainsdk";
const link = await links.create({ screen: "order", params: { id: order.id } });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" });
});
});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.
links:
driver: getqrChange this one line to swap providers; your calls stay the same.getqr.id integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-LINK-0003 | Checkmainsdk check, before deploy | A route in links.routes declares a param that identifies a person: email, phone, name, ip, token or a labelled field |
MAIN-LINK-0004 | Checkmainsdk check, before deploy | links.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-0005 | Checkmainsdk check, before deploy | links.signIn is called but link isn't in auth.methods (runtime: 400) |
MAIN-LINK-0001 | RuntimeError responses and logs | Invite 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-0002 | RuntimeError responses and logs | links.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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| links@1 contract | Phase 1 | 0.4 | links#contract |
| Deep links and resolve | Phase 1 | 0.4 | links#deep |
| applinks association files generated from main.yaml | Phase 1 | 0.4 | links#association |
| Invite links | Phase 1 | 0.4 | links#invite |
| Sign-in links that hand off to auth | Phase 1 | 0.4 | links#sign-in |
| Local links with no network | Phase 1 | 0.4 | links#local |
| Check rules for routes and app identifiers | Phase 1 | 0.4 | links#checks |
| Egress conformance for every producer | Phase 1 | 0.4 | links#egress |
| Passkey association in the same files | Phase 3 | 1.1 | mobile-auth#association-files |
Quick start
schema: 1
links:
base: https://app.example.com
routes:
order: { web: /orders/:id, app: orders/:id, params: [id] }"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
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" }| Key | Type | Default | What it does |
|---|---|---|---|
base | URL | none | Required. The app's own HTTPS origin (http outside production). Anything else fails with MAIN-CONFIG-0003 |
default-expiry | duration | 30d | Expiry of a deep link; never above share.max-expiry |
key | secret reference | one per process | Keys the hash of an invite's bound email. Without it, email-bound invites stop working after a restart |
routes.<screen> | map | none | web (path with :param slots), app (the in-app path) and params (the exact list of ids the route takes) |
apps.ios, apps.android | map | none | App identifiers the association files need |
apps.store | map | none | Store 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.
| Name | What 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.
Links in engagement messages
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
Link in a message
Create the link when you send. The sender is your own code, such as notify.
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}`;
}Resolve an incoming link
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.
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.
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).
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.
import { links } from "mainsdk";
export async function GET(req: Request) {
return (await links.route(req)) ?? new Response("Not found", { status: 404 });
}Errors and checks
| Code | Where | Meaning and fix |
|---|---|---|
MAIN-LINK-0001 | runtime | Invite used up, expired, revoked or for another email (410). Ask the inviter for a new one |
MAIN-LINK-0002 | runtime | A screen not under links.routes, or params missing or not declared (400). Declare the screen and its params |
MAIN-LINK-0003 | check | A route param that identifies a person (email, phone, name, ip, token). Pass a record id |
MAIN-LINK-0004 | check | links.apps is missing what the association files need, or links.base is not HTTPS. The finding names the missing identifiers |
MAIN-LINK-0005 | check, runtime | links.signIn without link in auth.methods (400). Add link to auth.methods |
MAIN-AUTH-0046 | runtime | A 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.
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 checkfails 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
- Share links and QR codes: private, expiring links to files and pages.
- Sign-in and sessions: the link method that
links.signInhands to. - Notifications: send links on push, WhatsApp, SMS or email.
- Engagement journeys:
{{link.<name>}}placeholders in reminder messages.
Was this page helpful?