---
title: "App links"
description: "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."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "links"
canonical: "https://mainsdk.dev/docs/interfaces/links"
updated: "2026-10-28"
---

# App 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.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/links · 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

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/links.md
- Category: Files, sharing and media

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)`

In main.yaml:

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

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

Pick one in main.yaml with `links.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| getqr.id | `getqr` | yes |  |
| Local links on the dev server | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-LINK-0003` | check | A route in `links.routes` declares a param that identifies a person: `email`, `phone`, `name`, `ip`, `token` or a labelled field |
| `MAIN-LINK-0004` | check | `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` | check | `links.signIn` is called but `link` isn't in `auth.methods` (runtime: 400) |
| `MAIN-LINK-0001` | runtime | 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` | runtime | `links.create` or `links.invite` named a screen not in `links.routes`, or params missing or not declared (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| links@1 contract | 1 | 0.4 | `links#contract` |
| Deep links and resolve | 1 | 0.4 | `links#deep` |
| applinks association files generated from main.yaml | 1 | 0.4 | `links#association` |
| Invite links | 1 | 0.4 | `links#invite` |
| Sign-in links that hand off to auth | 1 | 0.4 | `links#sign-in` |
| Local links with no network | 1 | 0.4 | `links#local` |
| Check rules for routes and app identifiers | 1 | 0.4 | `links#checks` |
| Egress conformance for every producer | 1 | 0.4 | `links#egress` |
| Passkey association in the same files | 3 | 1.1 | `mobile-auth#association-files` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
schema: 1
links:
  base: https://app.example.com
  routes:
    order: { web: /orders/:id, app: orders/:id, params: [id] }
```

```ts title="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

```yaml title="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" }
```

| 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`](https://mainsdk.dev/docs/interfaces/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`](https://mainsdk.dev/docs/interfaces/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`.

> **Note:** Built: `create`, `invite`, `accept`, `signIn`, `resolve`, `revoke` and `route` (the association files, the store page and the sign-in redirect). Planned: the HTTP adapters that serve `links.route()` for you, so today you call it from your own route handler. `links.signIn` and `links.accept` need `auth` to hand over its link method and sessions; without them `signIn` fails with `MAIN-LINK-0005` and `accept` with `MAIN-AUTH-0001`.

### 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`](https://mainsdk.dev/docs/interfaces/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`](https://mainsdk.dev/docs/interfaces/notify).

```ts title="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}`;
}
```

### 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.

```ts title="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.

```ts title="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).

```ts title="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.

```ts title="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

| 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](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/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.

```ts title="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()`](https://mainsdk.dev/docs/interfaces/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](https://mainsdk.dev/docs/interfaces/share): private, expiring links to files and pages.
- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth): the link method that `links.signIn` hands to.
- [Notifications](https://mainsdk.dev/docs/interfaces/notify): send links on push, WhatsApp, SMS or email.
- [Engagement journeys](https://mainsdk.dev/docs/interfaces/engagement): `{{link.<name>}}` placeholders in reminder messages.
