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.
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.
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,authanddbset 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). Agq_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.
share:
driver: local
default-expiry: 7d
max-expiry: 30dFor production, point an overlay at getqr.id. The key is a secret: reference; a literal fails with MAIN-CONFIG-0004.
share:
driver: getqr
key: secret:GETQR_API_KEYThe 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.
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
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
"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.
5. Count opens, with consent
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.
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.
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.
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 }npx mainsdk share webhooks add https://pinecart.in/hooks/getqrIt prints the signing secret once; store it as GETQR_WEBHOOK_SECRET. Then handle events:
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.
8. Send invite, deep and sign-in links
links builds on share. Declare your routes once.
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]"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
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
npx mainsdk check
npx mainsdk check --env prodBoth 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
| Code | What happened | Fix |
|---|---|---|
MAIN-SHARE-0001 | A shortener or QR library is imported in app code | Remove it and use share() or links |
MAIN-SHARE-0007 | Code calls a shortener or QR service, or network.allow lists one | Remove the call and the entry |
MAIN-SHARE-0004 | Counts were read without consent | Pass the person's analytics consent |
MAIN-SHARE-0008 | The local driver in production | Add the main.env.prod.yaml overlay with driver: getqr |
MAIN-SHARE-0013 | Personal data in a title or metadata | Use record ids your app resolves after sign-in |
MAIN-LINK-0002 | A screen or param isn't declared | Add it under links.routes |
MAIN-LINK-0005 | links.signIn without the link method | Add link to auth.methods |
For agents
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
- Share links and QR codes: every call and option.
- Invite, deep and sign-in links: routes and association files.
- Upload files: the files a share can point at.
- getqr.id: keys, sandbox and plans.
Was this page helpful?