MainSDK
Search⌘K
How-to

Share anything

Share a record, a file or a screen as a private, expiring getqr.id link and QR code, print QR codes for posters, count opens with consent, send invite, deep and sign-in links, and do it all offline with the local driver.

Building for 0.3

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

You will add sharing to Pinecart, a Next.js app on MainSDK: a customer shares an order as a link and QR code, the shop prints QR codes for its counter and flyers, and the team sends invite and sign-in links. Every link goes through one call, share, and lands on getqr.id, so it expires, can be revoked, carries nothing personal in its URL and shows "Powered by getqr.id". You build it offline with the local driver, switch to getqr.id for production, and finish with a passing mainsdk check. It takes about 30 minutes.

You need

  • A Next.js App Router app with a main.yaml, auth and db set up (see Quickstart).
  • For production only: a getqr.id account and a gq_live_ secret key, stored as a reference in your secrets manager (see Secrets). A gq_test_ key uses the getqr.id sandbox, where links always expire in 24 hours.

Why one call

share is the only way a MainSDK app hands out a link, QR code or share page (ADR-0056). auth invites, files, feeds, imports reports, the seal record page and engagement messages all call it inside the SDK, so the same rules hold everywhere. mainsdk check fails on any other shortener or QR library (MAIN-SHARE-0001) and on calls to their hosts (MAIN-SHARE-0007).

1. Declare share in main.yaml

Use the local driver while you build. It mints links on your dev server, keeps them in process and needs no account or network.

main.yaml
share:
  driver: local
  default-expiry: 7d
  max-expiry: 30d

For production, point an overlay at getqr.id. The key is a secret: reference; a literal fails with MAIN-CONFIG-0004.

main.env.prod.yaml
share:
  driver: getqr
  key: secret:GETQR_API_KEY

The local driver refuses to start in production (MAIN-SHARE-0008), and mainsdk check --env prod reports MAIN-CHECK-0026 if the overlay is missing.

2. Share a record

Share a read-only page of the record, never the record's data. The page is your own route; the link to it is a share.

app/api/orders/[id]/share/route.ts
import { auth, models, s, share } from "mainsdk";

const { orders } = models({ orders: s.object({ item: s.string(), total: s.int() }) });

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req);
  const order = await orders.for(session.user).get(id);
  const link = await share({ url: `https://pinecart.in/orders/${order.id}/view`, title: "Your Pinecart order" }, { expiresIn: "7d" });
  return Response.json(link); // { url, qr, id, expiresAt }
}

The URL holds nothing from the order. A title or metadata with an email or phone number is refused with MAIN-SHARE-0013, because getqr.id shows it in lists and webhooks. Share links are read-only: asking for any other access fails with MAIN-SHARE-0003.

To share an uploaded file, pass the FileRef from files directly: share(fileRef, { expiresIn: "1d" }).

3. Show the QR code and a story image

app/api/shares/[id]/qr/route.ts
import { share } from "mainsdk";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const qr = await share.qr(id, { format: "png", size: 1024 });
  return new Response(qr.bytes, { headers: { "content-type": qr.type } });
}

share.story(id) returns a 1080×1920 image with the QR, title and link, sized for Instagram and WhatsApp Status. QR codes are always dark on white with a quiet zone, so they scan; MainSDK never draws one itself. In the browser, <QRCode/> and <ShareBar/> from @getqr/react add the caption and "Powered by getqr.id".

4. List, change and revoke

app/shares/actions.ts
"use server";
import { share } from "mainsdk";

export async function myShares(cursor?: string) {
  return share.list({ status: "live", cursor, limit: 20 }); // only this tenant's shares
}

export async function rename(id: string) {
  return share.update(id, { title: "Order 1042", extend: "24h" }); // same link, same QR
}

export async function stop(id: string) {
  await share.revoke(id); // the link stops opening
}

An extend past max-expiry fails with MAIN-SHARE-0002. Share links always expire; asking to keep one forever fails with MAIN-SHARE-0010.

Counts are plain numbers from getqr.id's own domain (time, country, device type, placement), never anything about the person who opened the link. Read them only after the person who shared has agreed.

app/shares/stats.ts
import { share } from "mainsdk";

// `analyticsConsent` is the person's recorded analytics consent from your own consent store.
export async function opens(id: string, analyticsConsent: boolean) {
  return share.stats(id, { consent: analyticsConsent, range: "30d" }); // MAIN-SHARE-0004 without consent
}

share.usage() returns this billing period's shares, opens and storage against your getqr.id plan, and logs a warning once when a meter passes its alert level.

6. Print QR codes for posters and counters

A campaign holds one kept link and QR code per placement: a flyer, a counter card, a newspaper insert. Placements are the only links that never expire, and they point at a public page or a WhatsApp chat, never at a record.

app/marketing/diwali.ts
import { share } from "mainsdk";

export async function diwaliCampaign() {
  const campaign = await share.campaigns.create({ name: "Diwali boxes", handle: "pinecart" });
  const { placements, printSheet } = await share.placements.create(campaign.id, [
    { slug: "counter", name: "Shop counter", medium: "counter", kind: "link", url: "https://pinecart.in/diwali" },
    { slug: "flyer-kothrud", name: "Kothrud flyers", medium: "print", kind: "link", url: "https://pinecart.in/diwali" },
  ]);
  return { placements, printSheet }; // printSheet: the URL of a printable sheet of the QR codes
}

If any row fails getqr.id's checks, nothing is created and you get MAIN-SHARE-0011 with each row listed. Read scan counts with share.placements.list(campaign.id, { consent }), with the campaign owner's consent, as in step 5.

7. Receive getqr.id events

Declare the inbound endpoint and register it once.

main.yaml
webhooks:
  inbound:
    getqr: { preset: getqr, secret: secret:GETQR_WEBHOOK_SECRET, path: /hooks/getqr }
share:
  webhooks:
    events: [share.opened, share.removed, upload.scanned, placement.scanned]
  track: { analytics: true }
bash
npx mainsdk share webhooks add https://pinecart.in/hooks/getqr

It prints the signing secret once; store it as GETQR_WEBHOOK_SECRET. Then handle events:

app/hooks/getqr.ts
import { share, webhooks } from "mainsdk";

// Route handler for /hooks/getqr: the declared endpoint verifies the signature, then share.on handlers run.
export async function POST(req: Request) {
  return (await webhooks.handle(req)) ?? new Response("Not found", { status: 404 });
}

share.on("share.removed", async (event) => {
  console.info("share removed", event.id);
});

Each delivery is verified before your handler runs (a bad signature is refused with a 400 problem), runs once even if getqr.id retries, and runs in the tenant that made the share. upload.scanned marks a getqr.id upload ready without polling. Opens of shares made with track: true feed the share.opens metric with no person attached.

links builds on share. Declare your routes once.

main.yaml
links:
  base: https://pinecart.in
  routes:
    order: { web: "/orders/:id", app: "order/:id", params: [id] }
    list: { web: "/lists/:id", app: "list/:id", params: [id] }
auth:
  methods: [passkeys, link]
app/links/actions.ts
"use server";
import { links } from "mainsdk";

export async function orderLink(orderId: string) {
  return links.create({ screen: "order", params: { id: orderId } }); // opens the app when installed
}

export async function inviteToList(listId: string, email: string) {
  return links.invite({ screen: "list", params: { id: listId } }, { email, uses: 1, expiresIn: "7d" });
}

export async function emailSignIn(email: string) {
  return links.signIn({ email }, { next: { screen: "order", params: { id: "latest" } } }); // { sent: true }
}

A deep link grants nothing: the person still signs in and passes access. An invite accepted with another email fails with MAIN-LINK-0001 and stays usable. A sign-in link is never returned to your code, and the answer is the same whether or not the address has an account. links.signIn and links.accept need auth to provide its link method and sessions, and delivery by email or WhatsApp needs a sender; until those interfaces are built they fail with MAIN-LINK-0005 and MAIN-AUTH-0001. With links.apps set, links.route(req) serves the Universal Links and App Links files; call it from a route handler (see App links).

9. Test it offline

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

describe("sharing an order", () => {
  it("expires after 7 days", async () => {
    const testApp = createTestApp({ main: "./main.yaml" }); // share.driver: local
    const link = await share({ url: "https://pinecart.in/orders/ord_1/view" }, { expiresIn: "7d" });
    assert.ok(link.url.includes("/_mainsdk/s/"));
    testApp.clock.advance("8d");
    const [found] = (await share.list({ status: "expired" })).items;
    assert.equal(found?.id, link.id);
  });
});

Verify

bash
npx mainsdk check
npx mainsdk check --env prod

Both pass with no MAIN-SHARE- or MAIN-LINK- finding. Revoke a share link and open it again: it no longer opens (on the local driver it answers 410 with MAIN-SHARE-0009).

If it fails

CodeWhat happenedFix
MAIN-SHARE-0001A shortener or QR library is imported in app codeRemove it and use share() or links
MAIN-SHARE-0007Code calls a shortener or QR service, or network.allow lists oneRemove the call and the entry
MAIN-SHARE-0004Counts were read without consentPass the person's analytics consent
MAIN-SHARE-0008The local driver in productionAdd the main.env.prod.yaml overlay with driver: getqr
MAIN-SHARE-0013Personal data in a title or metadataUse record ids your app resolves after sign-in

For agents

text
Add sharing to this app with MainSDK. Use share() for every link, QR code and share page, and links for invite,
deep and sign-in links; never add a shortener or QR library. Use share.driver: local in main.yaml and getqr with
secret:GETQR_API_KEY in main.env.prod.yaml. Share read-only pages of records, never their data, and keep emails and
phone numbers out of titles and metadata. Read counts only with the person's analytics consent. Finish with a
passing npx mainsdk check and npx mainsdk check --env prod.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet