---
title: "Share links and QR codes"
description: "Share a link or file as a private, expiring, read-only getqr.id link and QR code, with open counts only under consent."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "files-share"
canonical: "https://mainsdk.dev/docs/interfaces/share"
updated: "2026-10-28"
---

# Share links and QR codes

> Share a link or file as a private, expiring, read-only getqr.id link and QR code, with open counts only under consent.

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/share · Updated: 2026-10-28

`share` turns a link or a file into a private, expiring, read-only getqr.id link and QR code, with no personal data in the URL.

A share link never authorises a write. Reach for it when a user shares a report, a file, a profile card or an invite. Every share sheet, QR code and link page shows "Powered by getqr.id", and no app builds its own sharer or shortener.

You share something that already has an address: a `url` (a page of your app that checks `access` when it opens), a file's `FileRef` from [`files`](https://mainsdk.dev/docs/interfaces/files), an `uploadId`, a getqr.id `cardId`, the signed-in person's own `contact`, or a `whatsapp` number. A bare database record is not a resource; share the URL of the page that shows it.

> **Note:** Built: `share()`, `get`, `list`, `update`, `revoke`, `qr`, `story`, `stats`, `usage`, campaigns, placements, `webhooks`, `on` and `as`, on the `getqr` and `local` drivers. Not decided in the spec: a custom short domain.

## At a glance

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

Calls:

- `share(resource, { expiresIn })`
- `share.get(id)`
- `share.list()`
- `share.update(id, { title?, url?, uploadId?, extend? })`
- `share.revoke()`
- `share.qr(id, { format?, size? })`
- `share.story(id)`
- `share.stats()`
- `share.usage()`
- `share.campaigns.create()`
- `share.campaigns.get(id)`
- `share.placements.create(campaignId, rows)`
- `share.placements.list(campaignId, { consent })`
- `share.placements.get(id, { consent })`
- `share.placements.update(id)`
- `share.on(type, fn)`
- `share.webhooks.list()`
- `share.webhooks.create(url, events)`
- `GETQR_EVENT_TYPES(const)`
- `GETQR_OPERATIONS(const)`
- `new LocalGetqr(o?)`
- `httpTransport(o)`
- `localGetqrFetch(local)`
- `createShare(o)`
- `handleShareRoute(share, req)`
- `verifyGetqrSignature(raw, header, { secret, nowMs })`
- `withShareMembers(routed, base)`

In main.yaml:

```yaml
share:
  driver: getqr
  key: secret:GETQR_API_KEY
  default-expiry: 7d
  max-expiry: 90d
app:
  url: https://acme.dev                # public origin; default share.origin of the local driver
```

Guarantees, whatever the driver:

- The only egress for links, QR codes and share pages (ADR-0056)
- Links private by default, expiring and read-only
- No personal data in the URL, title or metadata
- Open and scan counts first-party and only with consent
- Every share sheet shows "Powered by getqr.id"
- Types generated from getqr.id's own OpenAPI, pinned and diffed (ADR-0057)


## Drivers

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

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

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SHARE-0001` | check | A link shortener, deep-link or QR code library imported in app code, such as `bitly`, `tinyurl`, `qrcode`, `qrcode.react`, `react-qr-code`, `qr-code-styling`, `branch-sdk` or `@react-native-firebase/dynamic-links` |
| `MAIN-SHARE-0007` | check | Code calls, or `network.allow` lists, a link shortener, deep-link or QR rendering service such as `api-ssl.bitly.com`, `tinyurl.com`, `is.gd`, `api.short.io`, `firebasedynamiclinks.googleapis.com`, `api2.branch.io` or `api.qrserver.com` |
| `MAIN-CONTRACT-0001` | check | Two error registry entries share a code, or a code does not match `MAIN-<AREA>-<NNNN>`; the message names both entries |
| `MAIN-CONTRACT-0003` | check | No driver version satisfies every interface range during `sync` |
| `MAIN-CONTRACT-0004` | check | `contract diff --base` found no contract snapshot at the given ref or file |
| `MAIN-CONTRACT-0021` | check | A contract has no threat model (`mainsdk contract check`) |
| `MAIN-CONTRACT-0030` | check | The vendored getqr.id `openapi.yaml` doesn't match the sha256 in its `vendor.yaml`, or the generated TypeSpec import is stale (ADR-0057) |
| `MAIN-CONTRACT-0031` | check | `contract diff --vendor getqr` found an upstream change that breaks an operation or field MainSDK uses; the finding names both |
| `MAIN-SHARE-0002` | runtime | `expiresIn` is longer than `share.max-expiry` (400) |
| `MAIN-SHARE-0003` | runtime | A share asked for access other than `read` (400); share links never grant writes |
| `MAIN-SHARE-0004` | runtime | `share.stats()` was called without the person's consent (403) |
| `MAIN-SHARE-0005` | runtime | getqr.id answered a share or stats call with an error |
| `MAIN-SHARE-0006` | runtime | getqr.id returned a share that breaks the rules (kept forever, no expiry, or off getqr.id); it is revoked and the call fails (502) |
| `MAIN-SHARE-0008` | runtime | `share.driver: local` in production; the app refuses to start (500) |
| `MAIN-SHARE-0009` | runtime | A local share link was opened after it expired or was revoked (410) |
| `MAIN-SHARE-0010` | runtime | `share.update` was asked to keep a share forever (400); share links always expire, and placements are the only kept links |
| `MAIN-SHARE-0011` | runtime | `share.placements.create` failed its row checks and created nothing (422; the problem lists each row, field and reason) |
| `MAIN-SHARE-0012` | runtime | A getqr.id webhook event type that isn't in the pinned contract; acknowledged, logged once per type, not passed on |
| `MAIN-SHARE-0013` | runtime | A share's `title` or `metadata` holds personal data, `metadata` has more than 16 keys or a reserved `mainsdk_` key, or a `contact` share isn't the signed-in person's own (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| share@1 contract | 1 | 0.3 | `files-share#contract` |
| Private, expiring, read-only links and QR codes | 1 | 0.3 | `files-share#share` |
| ShareBar and QRCode from @getqr/react | 1 | 0.3 | `files-share#react` |
| Check against other sharers and shorteners | 1 | 0.3 | `files-share#check-rule` |
| Vendored getqr.id contract with contract diff | 1 | 0.3 | `files-share#vendor-contract` |
| Sandbox contract test in stage | 1 | 0.3 | `files-share#sandbox-contract` |
| Offline contract replay of recorded calls and contract record | 1 | 0.3 | `files-share#contract-replay` |
| Get, list and update shares | 1 | 0.3 | `files-share#share-manage` |
| QR in SVG or PNG and story images | 1 | 0.3 | `files-share#share-qr-story` |
| Usage against the getqr.id plan | 1 | 0.3 | `files-share#share-usage` |
| Campaigns and offline QR placements with consent-gated counts | 1 | 0.3 | `files-share#campaigns` |
| Inbound getqr.id webhooks preset and share.on | 1 | 0.3 | `files-share#webhook-preset` |
| Counts into files status and analytics with consent | 1 | 0.3 | `files-share#webhook-feeds` |
| Local share driver | 1 | 0.3 | `files-share#share-local` |
| Check against shortener and QR libraries and hosts | 1 | 0.3 | `files-share#check-sharers` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Revoke shared files across apps from the Fleet console |


## Quick start

`share` needs no block of its own when `files` uses the getqr driver: it reuses that key. Without `files` on getqr, set `share.key`. In development, `share.driver: local` mints links in process with no account.

```yaml title="main.yaml"
files:
  driver: getqr
  key: secret:GETQR_API_KEY
share:
  default-expiry: 7d
```

```ts title="app/api/reports/[id]/share/route.ts"
import { auth, share } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  await auth.require(req);
  const link = await share(
    { url: `https://app.example.com/reports/${id}`, title: "Q3 report" },
    { expiresIn: "7d", access: "read" },
  );
  return Response.json({ url: link.url, qr: link.qr, id: link.id, expiresAt: link.expiresAt });
}
```

The result is `{ url, qr, id, expiresAt }`. The link's host is getqr.id, and it stops working at `expiresAt` or when you revoke it. Keep `id` to revoke, extend or read counts later.

## Configuration

Every key lives under `share:`.

```yaml title="main.yaml"
share:
  driver: getqr          # getqr (default) | local (development and tests only)
  key: secret:GETQR_API_KEY
  default-expiry: 7d     # expiry when a call passes no expiresIn
  max-expiry: 30d        # longest expiry a call may ask for
  track:
    analytics: false     # whether track: true is allowed to count opens
```

| Key | Type | What it does |
| --- | --- | --- |
| `driver` | string | `getqr` by default, `local` for development. `local` in production stops the app with `MAIN-SHARE-0008` |
| `key` | secret reference | The getqr.id secret key. Defaults to `files.key` when `files` uses getqr, else `secret:GETQR_API_KEY`. Without a key or `driver: local`, `MAIN-CONFIG-0004` |
| `default-expiry` | duration | Expiry used when `share()` has no `expiresIn`. Default `7d` |
| `max-expiry` | duration | Upper bound for `expiresIn` and `extend`. Default `30d`. A longer request fails with `MAIN-SHARE-0002`; a default above the max fails at start with the same code |
| `baseUrl` | URL | getqr.id API origin, for a sandbox |
| `origin` | URL | `local` only: the origin of minted links; defaults to `app.url` or `http://localhost:3000` |
| `track.analytics`, `track.engagement` | boolean | Allow `track: true` shares to feed analytics and engagement counts |

### Overlays by market

A market can tighten expiry without touching code:

```yaml title="main.eu.yaml"
share:
  max-expiry: 14d
```

See [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml) for overlay order and merge rules.

## What you get

| Call | What it does |
| --- | --- |
| `share.story(id)` | Returns the story-sized image (`ShareImage`) for a share, as `share.qr(id)` returns the QR code |
| `share.campaigns.get(id)` | Reads one campaign |
| `share.placements.get(id, { consent })` | Reads one placement; counts need `consent: true` |
| `share.placements.update(id, patch)` | Changes a placement's `name`, `url`, `whatsapp` or `spend` |
| `share.webhooks.list()` | Lists your getqr.id webhook endpoints |
| `createShare(settings)` | Builds the `share` interface from a getqr.id key, a `transport` or a `local` driver. The runtime calls it for you; call it yourself in tests or a custom host |
| `handleShareRoute(share, request)` | Serves the local share pages of the dev adapter at `/_mainsdk/s/<code>` (redirect and count; a gone link answers 410; anything but GET or HEAD is refused with `MAIN-SHARE-0003`). Returns `undefined` for any other path |
| `withShareMembers(routed, base)` | Gives a wrapped share function (such as the protect router's) every member of the full interface by forwarding to `base()` |
| `verifyGetqrSignature(raw, header, { secret, nowMs })` | Checks a getqr.id webhook: HMAC-SHA256 over `<t>.<raw body>` and a timestamp within 5 minutes. Throws `MAIN-HOOK-0002` (400) otherwise. `share.receive` calls it |
| `GETQR_EVENT_TYPES(const)` | The eight event types of the contract, in order: `share.created`, `share.opened`, `share.expired`, `share.removed`, `share.reported`, `upload.scanned`, `placement.scanned`, `chat.started` |
| `GETQR_OPERATIONS(const)` | The contract's `operationId` values with their method and path, which MainSDK maps one to one |
| `httpTransport({ apiKey, baseUrl?, fetch })` | The transport that reaches getqr.id over HTTP through the injected fetch, with a bearer key |
| `new LocalGetqr({ origin?, now?, maxLifetimeHours?, emit? })` | An in-process getqr.id for development and tests. `handle()` answers a contract request and `open()` serves a local link |
| `localGetqrFetch(local)` | A fetch that answers like getqr.id from a `LocalGetqr`, so any transport runs against the offline fake |

## Examples

### Share sheet with QRCode

Make the link on the server, then show it with `<QRCode/>` from `@getqr/react`, which shows "Powered by getqr.id". Its props belong to `@getqr/react`. On the server, `share.qr(id, { format, size })` returns the QR image as `{ type, bytes }`.

```ts title="app/reports/[id]/actions.ts"
"use server";
import { auth, share } from "mainsdk";

export async function shareReport(req: Request, reportId: string) {
  await auth.require(req);
  const { url } = await share(
    { url: `https://app.example.com/reports/${reportId}` },
    { expiresIn: "7d" },
  );
  return url;
}
```

```tsx title="app/reports/[id]/share-sheet.tsx"
"use client";
import { QRCode } from "@getqr/react";

export function ShareSheet({ url }: { url: string }) {
  return <QRCode value={url} size={256} download />;
}
```

### Share a file

Pass the `FileRef` that `files.upload()` returned. The holder gets read-only access for the expiry window. A file on the getqr.id driver is shared as its getqr.id upload; a file on another store is shared through a short-lived signed URL.

```ts title="app/documents/actions.ts"
"use server";
import { files, share } from "mainsdk";

export async function shareUpload(bytes: Uint8Array, userId: string) {
  const ref = await files.upload(bytes, { owner: { id: userId }, type: "application/pdf" });
  return share(ref, { expiresIn: "24h", access: "read" });
}
```

### Extend, revoke and read open counts

`share.revoke(id)` stops a link at once. `share.update(id, { extend: "3d" })` moves the end later, never past `max-expiry` from now; asking to keep a link forever fails with `MAIN-SHARE-0010`. `share.stats()` returns plain open counts, and needs `consent: true` from the person whose links they are (`MAIN-SHARE-0004` otherwise).

```ts title="app/api/shares/[id]/route.ts"
import { auth, share } from "mainsdk";

export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req);
  const { id } = await params;       // the id you stored from share()
  await share.revoke(id);
  return new Response(null, { status: 204 });
}

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req);
  const { id } = await params;
  const stats = await share.stats(id, { consent: true, range: "7d" });
  return Response.json(stats);       // { opens, byDay, byCountry, byDevice }
}
```

`share.get(id)` returns one share (`url`, `qr`, `expiresAt`, `status`, `opens`). `share.list({ status, limit, cursor })` returns a page of `{ items, next }`; `status` is `live`, `expired` or `removed`.

### React to opens

`share.on(type, fn)` handles a getqr.id event and returns an unsubscribe function. Events arrive through the `getqr` inbound webhook (set `webhooks.inbound.getqr.secret`) or, on the local driver, straight from the in-process getqr.id. The types are `share.created`, `share.opened`, `share.expired`, `share.removed`, `share.reported`, `upload.scanned`, `placement.scanned` and `chat.started`.

```ts title="lib/share-events.ts"
import { share } from "mainsdk";

export const stop = share.on("share.opened", (event) => {
  console.log("opened", event.id);
});
```

### Campaigns and placements

A placement is the one kept link: a printed QR code or an ad link that stays live and is counted per placement. Create them in a campaign. `share.placements.create(campaignId, rows)` creates all rows or none (`MAIN-SHARE-0011` lists each bad row).

```ts title="scripts/flyers.ts"
import { share } from "mainsdk";

const campaign = await share.campaigns.create({ name: "Diwali", brand: "Acme" });
const { placements, printSheet } = await share.placements.create(campaign.id, [
  { slug: "flyer-a", name: "Flyer A", kind: "link", url: "https://app.example.com/offer" },
]);
const counts = await share.placements.list(campaign.id, { consent: true });
console.log(placements[0]?.qr, counts.totals.opens, printSheet);
```

A row with `kind: "ad_link"` (the default) needs a `whatsapp: { phone }` number in E.164 form. `share.usage()` returns your plan's share, open and storage use against its limits, and `share.webhooks.create(url, events)` registers a getqr.id webhook endpoint. `share.webhooks.list()` returns the endpoints you registered, each with its status (`healthy`, `failing` or `disabled`) and no secret.

`share.campaigns.get(id)` reads one campaign back. `share.placements.get(id, { consent })` reads one placement, and `share.placements.update(id, patch)` changes its `name`, `url`, `whatsapp` number or `spend` (`{ inr, results, label? }`); a placement's QR code never changes, so a printed sheet stays valid. Counts need `consent: true`, as for `placements.list`.

## Errors and checks

| Code | Where | Meaning and fix |
| --- | --- | --- |
| `MAIN-SHARE-0002` | runtime | `expiresIn` or `extend` is above `share.max-expiry` (400). Use a shorter one, or raise the max on purpose |
| `MAIN-SHARE-0003` | runtime | `access` other than `read` (400). Shares are read-only; give the person an account and an `access` rule to write |
| `MAIN-SHARE-0004` | runtime | `track` or `stats` without the person's analytics consent (403). Ask for consent, then pass `consent: true` |
| `MAIN-SHARE-0005` | runtime | A resource share cannot send, or getqr.id answered with an error. The `fix` says which |
| `MAIN-SHARE-0006` | runtime | getqr.id returned a share that is kept, public or off getqr.id; it is revoked and the call fails (502) |
| `MAIN-SHARE-0008` | start | `share.driver: local` in production |
| `MAIN-SHARE-0010` | runtime | `share.update` asked to keep a share (400). Use `extend`, or a placement |
| `MAIN-SHARE-0011` | runtime | `placements.create` failed its row checks (422); nothing was created |
| `MAIN-SHARE-0013` | runtime | Personal data in `title` or `metadata`, too many keys, or someone else's contact card (400). Use record ids |
| `MAIN-SHARE-0001`, `0007` | check | Another shortener, QR or deep-link library or host used in your code |

An expired or revoked link stops working; make a new one with `share()`. All codes are in [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors).

## Swapping drivers

`share` has one real driver, getqr.id, by design (ADR-0009), whichever `files` driver stores the file. `local` runs the same contract in process for development and tests: links look like `<origin>/_mainsdk/s/<id>` and open with no network.

## Testing

`createTestApp()` runs `share` on the local driver with a fake clock, so links expire with no real waiting.

The getqr.id contract itself is tested offline: 20 recorded calls are replayed through `httpTransport` and checked against the pinned `openapi.yaml` on every run, and `mainsdk contract record --vendor getqr` refreshes them (see [contract record](https://mainsdk.dev/docs/reference/cli#contract-record)).

```ts title="app/reports/share.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { share } from "mainsdk";

describe("share", () => {
  it("files-share AC5 expires a link after its window", async () => {
    const testApp = createTestApp({ main: "./main.yaml" });
    const link = await share({ url: "https://app.example.com/reports/r1" }, { expiresIn: "7d" });
    assert.ok(!link.url.includes("reports"));          // nothing of the target is in the URL
    testApp.clock.advance("8d");
    assert.equal((await share.get(link.id)).status, "expired");
  });
});
```

## Security and privacy

- Links are private by default, expiring and read-only. A link never authorises a write.
- The URL holds no email, name or record id in clear. Titles and metadata with an email, a phone number or a secret are refused before any request.
- Open counts are first-party and returned only with consent.
- Tenants are kept apart: a share made in one tenant is not found from another (`MAIN-TENANT-0005`).

### What Cloud adds

`share` is free in your app. The Fleet plan adds revoking shared files across apps from the Fleet console. See [Free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**Can I use my own short domain?** Not yet; links are on `getqr.id`.

**Can a share link let someone edit?** No. Links grant read-only access, and `access` other than `read` fails with `MAIN-SHARE-0003`.

**Why "Powered by getqr.id" on every share sheet?** Every share in a MainSDK app goes through getqr.id, and the components show it so people know where the link lives.

## Next

- [File uploads](https://mainsdk.dev/docs/interfaces/files): upload the files you share.
- [App links](https://mainsdk.dev/docs/interfaces/links): links that open the right screen in your app.
- [Protected files](https://mainsdk.dev/docs/interfaces/protect): files that stay under policy after they are shared.
