Share links and QR codes
shareShare a link or file as a private, expiring, read-only getqr.id link and QR code, with open counts only under consent.
files-shareFree 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.
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 driverimport { share } from "mainsdk";
const { url, qr } = await share(order, { expiresIn: "7d", access: "read" });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");
});
});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.
share:
driver: getqrChange this one line to swap providers; your calls stay the same.getqr.id integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SHARE-0001 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | Two error registry entries share a code, or a code does not match MAIN-<AREA>-<NNNN>; the message names both entries |
MAIN-CONTRACT-0003 | Checkmainsdk check, before deploy | No driver version satisfies every interface range during sync |
MAIN-CONTRACT-0004 | Checkmainsdk check, before deploy | contract diff --base found no contract snapshot at the given ref or file |
MAIN-CONTRACT-0021 | Checkmainsdk check, before deploy | A contract has no threat model (mainsdk contract check) |
MAIN-CONTRACT-0030 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | contract diff --vendor getqr found an upstream change that breaks an operation or field MainSDK uses; the finding names both |
MAIN-SHARE-0002 | RuntimeError responses and logs | expiresIn is longer than share.max-expiry (400) |
MAIN-SHARE-0003 | RuntimeError responses and logs | A share asked for access other than read (400); share links never grant writes |
MAIN-SHARE-0004 | RuntimeError responses and logs | share.stats() was called without the person's consent (403) |
MAIN-SHARE-0005 | RuntimeError responses and logs | getqr.id answered a share or stats call with an error |
MAIN-SHARE-0006 | RuntimeError responses and logs | 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 | RuntimeError responses and logs | share.driver: local in production; the app refuses to start (500) |
MAIN-SHARE-0009 | RuntimeError responses and logs | A local share link was opened after it expired or was revoked (410) |
MAIN-SHARE-0010 | RuntimeError responses and logs | share.update was asked to keep a share forever (400); share links always expire, and placements are the only kept links |
MAIN-SHARE-0011 | RuntimeError responses and logs | share.placements.create failed its row checks and created nothing (422; the problem lists each row, field and reason) |
MAIN-SHARE-0012 | RuntimeError responses and logs | A getqr.id webhook event type that isn't in the pinned contract; acknowledged, logged once per type, not passed on |
MAIN-SHARE-0013 | RuntimeError responses and logs | 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
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| share@1 contract | Phase 1 | 0.3 | files-share#contract |
| Private, expiring, read-only links and QR codes | Phase 1 | 0.3 | files-share#share |
| ShareBar and QRCode from @getqr/react | Phase 1 | 0.3 | files-share#react |
| Check against other sharers and shorteners | Phase 1 | 0.3 | files-share#check-rule |
| Vendored getqr.id contract with contract diff | Phase 1 | 0.3 | files-share#vendor-contract |
| Sandbox contract test in stage | Phase 1 | 0.3 | files-share#sandbox-contract |
| Offline contract replay of recorded calls and contract record | Phase 1 | 0.3 | files-share#contract-replay |
| Get, list and update shares | Phase 1 | 0.3 | files-share#share-manage |
| QR in SVG or PNG and story images | Phase 1 | 0.3 | files-share#share-qr-story |
| Usage against the getqr.id plan | Phase 1 | 0.3 | files-share#share-usage |
| Campaigns and offline QR placements with consent-gated counts | Phase 1 | 0.3 | files-share#campaigns |
| Inbound getqr.id webhooks preset and share.on | Phase 1 | 0.3 | files-share#webhook-preset |
| Counts into files status and analytics with consent | Phase 1 | 0.3 | files-share#webhook-feeds |
| Local share driver | Phase 1 | 0.3 | files-share#share-local |
| Check against shortener and QR libraries and hosts | Phase 1 | 0.3 | files-share#check-sharers |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| 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.
files:
driver: getqr
key: secret:GETQR_API_KEY
share:
default-expiry: 7dimport { 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:.
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:
share:
max-expiry: 14dSee 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 }.
"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;
}"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.
"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).
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.
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).
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 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).
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
- File uploads: upload the files you share.
- App links: links that open the right screen in your app.
- Protected files: files that stay under policy after they are shared.
Was this page helpful?