MainSDK
Search⌘K
Interfaces

Files, sharing and media

Share links and QR codes

share

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

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec files-shareFree 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

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

At a glance

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

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)

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

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

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

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

Codes

CodeWhere it appearsMeaning
MAIN-SHARE-0001Checkmainsdk check, before deployA 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-0007Checkmainsdk check, before deployCode 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-0001Checkmainsdk check, before deployTwo error registry entries share a code, or a code does not match MAIN-<AREA>-<NNNN>; the message names both entries
MAIN-CONTRACT-0003Checkmainsdk check, before deployNo driver version satisfies every interface range during sync
MAIN-CONTRACT-0004Checkmainsdk check, before deploycontract diff --base found no contract snapshot at the given ref or file
MAIN-CONTRACT-0021Checkmainsdk check, before deployA contract has no threat model (mainsdk contract check)
MAIN-CONTRACT-0030Checkmainsdk check, before deployThe 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-0031Checkmainsdk check, before deploycontract diff --vendor getqr found an upstream change that breaks an operation or field MainSDK uses; the finding names both
MAIN-SHARE-0002RuntimeError responses and logsexpiresIn is longer than share.max-expiry (400)
MAIN-SHARE-0003RuntimeError responses and logsA share asked for access other than read (400); share links never grant writes
MAIN-SHARE-0004RuntimeError responses and logsshare.stats() was called without the person's consent (403)
MAIN-SHARE-0005RuntimeError responses and logsgetqr.id answered a share or stats call with an error
MAIN-SHARE-0006RuntimeError responses and logsgetqr.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-0008RuntimeError responses and logsshare.driver: local in production; the app refuses to start (500)
MAIN-SHARE-0009RuntimeError responses and logsA local share link was opened after it expired or was revoked (410)
MAIN-SHARE-0010RuntimeError responses and logsshare.update was asked to keep a share forever (400); share links always expire, and placements are the only kept links
MAIN-SHARE-0011RuntimeError responses and logsshare.placements.create failed its row checks and created nothing (422; the problem lists each row, field and reason)
MAIN-SHARE-0012RuntimeError responses and logsA getqr.id webhook event type that isn't in the pinned contract; acknowledged, logged once per type, not passed on
MAIN-SHARE-0013RuntimeError responses and logsA 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

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

FeaturePhaseMilestoneSpec ticket
share@1 contractPhase 10.3files-share#contract
Private, expiring, read-only links and QR codesPhase 10.3files-share#share
ShareBar and QRCode from @getqr/reactPhase 10.3files-share#react
Check against other sharers and shortenersPhase 10.3files-share#check-rule
Vendored getqr.id contract with contract diffPhase 10.3files-share#vendor-contract
Sandbox contract test in stagePhase 10.3files-share#sandbox-contract
Offline contract replay of recorded calls and contract recordPhase 10.3files-share#contract-replay
Get, list and update sharesPhase 10.3files-share#share-manage
QR in SVG or PNG and story imagesPhase 10.3files-share#share-qr-story
Usage against the getqr.id planPhase 10.3files-share#share-usage
Campaigns and offline QR placements with consent-gated countsPhase 10.3files-share#campaigns
Inbound getqr.id webhooks preset and share.onPhase 10.3files-share#webhook-preset
Counts into files status and analytics with consentPhase 10.3files-share#webhook-feeds
Local share driverPhase 10.3files-share#share-local
Check against shortener and QR libraries and hostsPhase 10.3files-share#check-sharers

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
FleetRevoke 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.

main.yaml
files:
  driver: getqr
  key: secret:GETQR_API_KEY
share:
  default-expiry: 7d
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:.

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
KeyTypeWhat it does
driverstringgetqr by default, local for development. local in production stops the app with MAIN-SHARE-0008
keysecret referenceThe 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-expirydurationExpiry used when share() has no expiresIn. Default 7d
max-expirydurationUpper 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
baseUrlURLgetqr.id API origin, for a sandbox
originURLlocal only: the origin of minted links; defaults to app.url or http://localhost:3000
track.analytics, track.engagementbooleanAllow track: true shares to feed analytics and engagement counts

Overlays by market

A market can tighten expiry without touching code:

main.eu.yaml
share:
  max-expiry: 14d

See main.yaml for overlay order and merge rules.

What you get

CallWhat 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 }.

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;
}
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.

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

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.

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

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

CodeWhereMeaning and fix
MAIN-SHARE-0002runtimeexpiresIn or extend is above share.max-expiry (400). Use a shorter one, or raise the max on purpose
MAIN-SHARE-0003runtimeaccess other than read (400). Shares are read-only; give the person an account and an access rule to write
MAIN-SHARE-0004runtimetrack or stats without the person's analytics consent (403). Ask for consent, then pass consent: true
MAIN-SHARE-0005runtimeA resource share cannot send, or getqr.id answered with an error. The fix says which
MAIN-SHARE-0006runtimegetqr.id returned a share that is kept, public or off getqr.id; it is revoked and the call fails (502)
MAIN-SHARE-0008startshare.driver: local in production
MAIN-SHARE-0010runtimeshare.update asked to keep a share (400). Use extend, or a placement
MAIN-SHARE-0011runtimeplacements.create failed its row checks (422); nothing was created
MAIN-SHARE-0013runtimePersonal data in title or metadata, too many keys, or someone else's contact card (400). Use record ids
MAIN-SHARE-0001, 0007checkAnother 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 and 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).

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet