Guides
Embedding the partner dashboard
A merchant can show a partner their referrals, rewards and payouts in an iframe on their site or a WebView in their native app. The partner never signs in to Grizzlee, and the merchant's server key stays on the server. This page describes the session flow and what each step protects against. The request and response fields are in the API reference.
The dashboard shows each program's attribution code, the partner's short code such as KARL7Q, and active Stripe or Shopify promo codes. A promo code carries its saved provider discount. An attribution code identifies the partner and leaves discount application to your checkout. Program tracking switches control links, QR codes and code displays independently of kind. QR sharing can stay on when links are off. Partners can copy codes in the frame; merchants manage provider discounts in the dashboard or server API.
Setup
An owner or admin opens Developers and lists under Allowed embed origins the origins of the pages that will hold the iframe, such as https://your-business.com. Each entry is an exact origin with no path. Origins must use HTTPS. http://localhost and http://127.0.0.1 are accepted for development. A company can list up to 20 origins.
Settings, then Business details, has a Brand card that sets how the dashboard looks. Partner emails and the partner portal use the same logo and color, and program signup pages and partner sign-in show the logo:
- Logo URL. An HTTPS image shown above the dashboard. URLs with a user name or password are refused.
- Primary color. A six-digit hex color such as
#1F6FEB. It fills buttons, the active tab and focus rings. Button text switches between white and near-black, whichever contrasts more. The color never colors text on a page surface. - Font.
grizzlee,system,seriforrounded. Each one uses fonts bundled with Grizzlee or installed on the device. The dashboard never loads a font from another site. - Appearance.
systemfollows the device's light or dark setting, or chooselightordark. Withsystem, a dark device can show the light colors for a moment while the page starts.
The merchant creates a server key with the embed:write scope.
Create a session on the merchant server
This Next.js route lives in the merchant app at app/partner/embed-session/route.ts. Replace getAuthenticatedPartner with your app's authentication lookup. It must return the signed-in partner's merchant externalId, or null. The browser never supplies the partner ID or the server key.
import { GrizzleeServerClient } from "@grizzlee/sdk/server";
import { getAuthenticatedPartner } from "@/lib/partner-auth";
const merchantOrigin = "https://your-business.com";
const grizzlee = new GrizzleeServerClient({
baseUrl: process.env.GRIZZLEE_ORIGIN!,
apiKey: process.env.GRIZZLEE_SERVER_KEY!,
});
export async function POST(request: Request) {
if (request.headers.get("origin") !== merchantOrigin)
return new Response("Invalid origin", { status: 403 });
const partner = await getAuthenticatedPartner(request);
if (!partner) return new Response("Unauthorized", { status: 401 });
const session = await grizzlee.createEmbedSession({
partner: { externalId: partner.externalId },
locale: "en",
placement: "customer_referral",
embedOrigin: merchantOrigin,
});
return Response.json(
{ url: session.url },
{ headers: { "Cache-Control": "no-store" } },
);
}Point customer_referral at a program once with setPlacement("customer_referral", { programId }) from your server. Pass placement instead of programIds. Sending both fails validation. Sending neither keeps the existing unrestricted session behavior.
At session creation, Grizzlee resolves the placement's current program. If the partner has not joined that program, Grizzlee adds it to the partner's programIds through the normal partner update path, in the same transaction as the session. The partner audit log records the enrollment. Other enrollments stay intact. A paused program returns 409 placement_inactive when the partner needs enrollment, and creates nothing. A partner already enrolled can open a session even if the program is paused. Unknown placements return 404 unknown_placement.
The session stores that one program in programIds and the key in placement. Creation, revocation and exchange return placement: string | null. Moving the key to another program affects only new sessions and new enrollments. Existing sessions keep their saved program. Existing enrollments stay intact.
Optional programIds restricts every embed read to those programs, including summary totals and QR codes. Send one ID for a screen dedicated to one program. The list must be nonempty, contain no duplicates and name programs the partner currently belongs to in this company. Invalid memberships return 400 with code invalid_embed_program. Omit the list to include all programs, including former memberships with activity. The session and exchange return programIds, or null when unrestricted.
Optional layout: "simple" shows each program's offer, referral link, progress and Share button. View details opens Referrals and Rewards, plus Payouts for cash programs, with a back control. The simple layout shows the partner's short code under the link when the program accepts typed codes. It hides promo codes, QR tools, click totals, balances and payout setup notices. Its share text includes the short code. Program names, share text and QR codes use the program's public name in the session's language. Milestone progress counts in the program's milestone label, such as "3 lessons completed", or in steps when it has none. A program's headline replaces the simple layout's first offer line. Programs without a link keep the offer and show "Invitations will be available soon." Send "full", or omit layout, to use the four-tab dashboard. A placement does not choose the layout. Session creation and exchange return the selected layout.
Scoped summaries return balances.wallet: null. Wallet payouts, spending, reservations and debt have no program allocations. Program cards still show that program's pending and approved wallet rewards. Merchant balance totals include only the selected programs. Scoped summaries omit partner-wide payout wait dates. Scoped payouts exclude wallet payouts and show only the selected programs' allocations. On a mixed-program payout with debt kept, netAmountCents is null because the deduction has no program split. The dashboard labels that amount as gross before refunds.
Set merchantOrigin from trusted server configuration and add that origin to the company's allowed origins. Mint a fresh URL for each page view and renewal. Start tokens work once, so saving a URL for a later page load will not reopen the dashboard.
React and framework-free hosts
@grizzlee/react is separate from the server SDK. It keeps React peer dependencies and browser lifecycle code out of backend installations. React hosts need React 18 or later. React, React Native and React Native WebView are optional peers, so the framework-free entry point can install without them. Only @grizzlee/react/native imports the native peers.
"use client";
import { GrizzleeEmbed } from "@grizzlee/react";
async function newSession() {
const response = await fetch("/partner/embed-session", { method: "POST" });
if (!response.ok) throw new Error("Session creation failed");
const { url } = await response.json();
return url as string;
}
export function PartnerDashboard({ startUrl }: { startUrl: string }) {
return (
<GrizzleeEmbed
url={startUrl}
onSessionExpired={async () => {
try {
return await newSession();
} catch {
alert("The dashboard could not reopen. Refresh to try again.");
}
}}
onReady={() => console.log("Partner dashboard ready")}
title="Partner dashboard"
minHeight={320}
style={{ width: "100%" }}
/>
);
}Fetch startUrl from the merchant endpoint before rendering this component. It supports server-side rendering and attaches its listener after mounting. Only url is required. Optional props are onSessionExpired, onReady, onShare, title, className, style and minHeight. The expiry callback can return a new URL directly or through a promise, or return nothing when your app handles recovery. A rejection leaves the expired dashboard visible. Handle errors in the callback to tell the partner what happened. Only one renewal runs at a time. A new url prop opens a new session, and unmounting removes the listener and ignores pending renewal results. onReady fires whenever a session's summary loads.
The default title is "Partner dashboard". Height starts at minHeight, which defaults to 320 CSS pixels and is clamped to 120 through 10,000. Resize messages stay between that minimum and 10,000. The host controls style.height and style.minHeight.
For an app without React, use the dependency-free entry point:
import { mountGrizzleeEmbed } from "@grizzlee/react/embed";
const container = document.getElementById("partner-dashboard")!;
const dashboard = mountGrizzleeEmbed(container, {
url: startUrl,
onSessionExpired: newSession,
onReady: () => console.log("Partner dashboard ready"),
});
// When your app closes this view:
dashboard.destroy();The helper appends an iframe and accepts the same options except style. destroy() removes that iframe and its message listener. Both hosts check the current URL's origin and the iframe's contentWindow before handling any message. They set allow="clipboard-write; web-share", referrerPolicy="no-referrer" and loading="lazy", with no sandbox attribute.
If you add sandbox to a plain iframe, include allow-scripts allow-same-origin allow-downloads. Without allow-scripts, the dashboard cannot run. Without allow-same-origin, its exchange sends Origin: null and fails. allow-downloads permits QR downloads.
Supply onShare to either host to take over the Share button. The callback receives { programId, url, text }. text is an invitation in the session's language and includes the partner's short code when the program accepts typed codes, in either layout. The hosts opt in by replying to grizzlee:ready with { type: "grizzlee:configure", share: true }. A React host also updates that setting when its callback changes. Without opt-in, the frame keeps its copy field and uses its own share action. That action needs web-share in the iframe's allow attribute. Without it, or on a device with no share sheet, the frame copies the invitation and link and shows "Copied".
const dashboard = mountGrizzleeEmbed(container, {
url: startUrl,
onShare: ({ url, text }) => openShareSheet({ url, text }),
});openShareSheet is your host's callback. GrizzleeEmbed accepts the same onShare prop. The iframe accepts configuration only from its parent window at the saved parent origin. The callback listener validates the share fields and accepts only HTTP or HTTPS URLs without credentials.
Install the host package with npm install @grizzlee/react. All three entry points support ESM and CommonJS with matching declarations. See the host reference.
Session flow
- The partner opens the merchant's page. The merchant's server calls
POST /api/v1/embed/sessionswith its server key, the partner's ID, an optional locale andembedOrigin. The SDK call iscreateEmbedSession. The server can omitembedOriginwhen the company allows exactly one origin. With several origins, omission returns 409 with codeembed_origin_required. - Grizzlee checks that the partner belongs to the key's company and that
embedOriginis on the company's list. It stores the session and returns a URL such ashttps://<app>/embed/start?session=<id>#token=<start token>. - The merchant's page renders an iframe with that URL.
- The embed page reads the start token from the fragment, removes it from the address, and sends it to
POST /api/embed/exchangefrom the app origin. - Grizzlee marks the session consumed and returns a bearer, the session's locale, its allowed actions, its parent origin and its expiry.
- The frame keeps the bearer in a JavaScript variable and sends it as
Authorization: Bearer <bearer>on every read. - The frame reads
GET /api/embed/summary,/api/embed/referrals,/api/embed/rewardsand/api/embed/payouts. Lists acceptcursorandlimit, and rewards also acceptstatusandprogramId, as in the partner read API.GET /api/embed/qr?programId=<id>returns a PNG QR code of the partner's referral link for one of their programs. - The session ends at its expiry, at most 600 seconds after creation, or when the merchant's server calls
DELETE /api/v1/embed/sessions/:id. After that, reads return 401 with codesession_expired, and the host page creates a new session.
Customer privacy
The embedded dashboard never returns or displays the merchant's customerId or orderId. There is no setting to reveal them. Referral and reward JSON responses use opaque row keys and omit raw IDs, including firstOrderId, report event IDs and delivery references. Merchant-authenticated APIs and the merchant dashboard keep their real IDs.
Your server can send customerDisplayName, such as Marie, on attribution creation, referral claims, code attachments or completed orders. Grizzlee trims it and accepts 1 to 80 characters. Each referral keeps the latest accepted non-empty name. Omission keeps the saved name. Completed orders update the customer's customer-scoped referrals and the referral for that order. A name update never changes the credited partner or an earned reward.
Referrals show that name when present, otherwise "Referred customer" in English or "Client parrainé" in French Canadian. Order rewards show dates, amounts and statuses without order IDs. The React and DOM hosts render this same dashboard.
A display name is personal data that partners can see. Send only a name you intend to share, rather than an email or a full billing name. Customer erasure removes saved names, including cached claims and order webhook payloads. Later requests for an erased customer cannot restore them. See Data erasure.
The dashboard
/embed/start is the whole dashboard. The default full layout has four tabs, and switching tabs never loads a new document, because a new document would lose the bearer.
- Overview shows the partner's referral code and each program with its referral link, a QR code to show or download, what the partner earns and what a referred friend gets. Balances follow, split by who pays. The partner's wallet holds some rewards, and the business pays others directly. Each side has cash and store credit.
- Referrals lists referred customers with their status, program, referral date, order count and rewards earned. Milestone programs with an at-count bonus show current reports against
milestoneRewards.atCount.count, such aslesson_completed: 6 of 10in English andlesson_completed : 6 sur 10in French Canadian. Reversed reports do not count. Before the bonus is earned, the threshold follows the current rules, including custom terms. Earned bonuses retain their saved threshold. Each-report-only programs show no progress toward a bonus. - Rewards lists money rewards with their approval status and, for rewards the business sends, whether they were sent. Earned offers show localized discount terms, remaining uses, expiry and an offer status of Available, Pending approval, Used, Voided or Expired. French Canadian sessions use the corresponding French labels. Open holds do not change offer status. Offers stay out of wallet balances and payouts.
- Payouts lists payouts with amount, status and date.
Embed reward rows include rewardType. Offer rows add offer with status, usesRemaining, expiresAt and text for en, fr-CA, hy and ru. They contain no merchant order or customer IDs. Checkout redeems offers through linked customer IDs using the existing incentive endpoints. See earned offers.
Each program's totals show orders referred from its summary orderCount, across all referrals without pagination. The count includes orders that earned the partner a reward, even if that reward was later reversed. Orders that earned nothing and milestone rewards do not count. Former programs keep their totals.
Lists show 50 rows and a "Load more" button while more remain. Copy buttons and every label follow the session's locale, en, fr-CA, hy or ru. Lists use stacked rows instead of tables, and balance cards wrap to one column on narrow screens.
The server looks up the session ID from the query string to choose the language, page title, theme and parent origin before the frame has a bearer. A reload of the frame keeps that ID but has no token, so it shows a "Session ended" message in the right language and tells the host to create a new session.
Embed pages send Referrer-Policy: no-referrer and Cache-Control: no-store.
Messages to the host
In an iframe, the dashboard sends these objects to window.parent through postMessage. As a native WebView's top document, it sends the same objects as JSON strings through window.ReactNativeWebView.postMessage:
| Message | When it is sent |
|---|---|
{ type: "grizzlee:ready" } | The partner summary has loaded. |
{ type: "grizzlee:resize", height: 480 } | The content height changes. height is a positive integer in CSS pixels. |
{ type: "grizzlee:share", programId: "...", url: "https://...", text: "..." } | The partner taps Share after the host opts in. |
{ type: "grizzlee:session_expired" } | The start token is missing or no longer works, or a read returns 401. Reloading the frame sends this message too. |
The target is the session's saved embedOrigin, or the company's sole allowed origin for an older session without one. It is never "*". The frame gets this origin from the server's session lookup, so it can send session_expired without an exchange. The exchange's origin must match the lookup before the frame loads the summary. An unknown session, a failed lookup or an older session with several possible parent origins sends no messages.
The host must check event.origin against the Grizzlee app origin and event.source against its iframe. This example calls a merchant endpoint that creates a session with the server key and the host's embedOrigin, then returns { url }. Authenticate the partner at that endpoint. Keep the server key on the server.
<iframe
id="grizzlee"
title="Partner dashboard"
allow="clipboard-write; web-share"
referrerpolicy="no-referrer"
loading="lazy"
style="width: 100%; border: 0; height: 320px; min-height: 320px"
></iframe>
<p id="grizzlee-status" role="status"></p>
<script>
const appOrigin = "https://app.grizzlee.example";
const frame = document.getElementById("grizzlee");
const status = document.getElementById("grizzlee-status");
let opening = false;
async function openDashboard() {
if (opening) return;
opening = true;
try {
const response = await fetch("/partner/embed-session", {
method: "POST",
});
if (!response.ok) throw new Error("Session creation failed");
const { url } = await response.json();
if (new URL(url).origin !== appOrigin)
throw new Error("Unexpected app origin");
frame.src = url;
frame.style.height = "320px";
status.textContent = "Loading partner dashboard.";
} catch {
status.textContent =
"The dashboard could not open. Refresh to try again.";
} finally {
opening = false;
}
}
window.addEventListener("message", (event) => {
if (event.origin !== appOrigin || event.source !== frame.contentWindow)
return;
const message = event.data;
if (!message || typeof message !== "object") return;
switch (message.type) {
case "grizzlee:ready":
// Enable this only when the host handles grizzlee:share.
if (window.ReactNativeWebView) {
frame.contentWindow.postMessage(
{ type: "grizzlee:configure", share: true },
appOrigin,
);
}
status.textContent = "";
break;
case "grizzlee:resize":
if (Number.isSafeInteger(message.height) && message.height > 0)
frame.style.height = `${Math.min(10000, Math.max(320, message.height))}px`;
break;
case "grizzlee:session_expired":
void openDashboard();
break;
}
});
void openDashboard();
</script>Mobile webviews
For React Native or Expo, install @grizzlee/react and react-native-webview in your app. Use React Native >= 0.78 and React Native WebView >= 13, with the React version required by your React Native release. The native subpath keeps these peers out of web bundles.
import { Alert } from "react-native";
import { GrizzleeNativeEmbed } from "@grizzlee/react/native";
export function PartnerDashboard({
startUrl,
appAccessToken,
}: {
startUrl: string;
appAccessToken: string;
}) {
async function newSession() {
const response = await fetch(
"https://your-business.com/partner/native-embed-session",
{
method: "POST",
headers: { Authorization: `Bearer ${appAccessToken}` },
},
);
if (!response.ok) throw new Error("Session creation failed");
const { url } = await response.json();
return url as string;
}
return (
<GrizzleeNativeEmbed
startUrl={startUrl}
onExpired={newSession}
onError={(error) => Alert.alert("Partner dashboard", error.message)}
style={{ flex: 1 }}
/>
);
}Fetch the initial startUrl from your authenticated merchant API before rendering. The native endpoint verifies your app's access token, derives the partner from that identity and calls createEmbedSession with its server key. It returns only { url } with Cache-Control: no-store. Use the same session options as the server example above, including any programIds scope. Authenticate the native endpoint with your app's credentials instead of copying that browser example's Origin header requirement. The Grizzlee server key never reaches the app.
Session creation still requires an allowed merchant origin. Keep an HTTPS merchant origin under Allowed embed origins in Developers and pass it as embedOrigin; you do not need to host a wrapper page there. The component loads Grizzlee's /embed/start as the WebView's top document. Token exchange and reads stay on the Grizzlee origin, and the bearer stays in the document's memory. The existing iframe framing policy and parent origin checks still apply to web embeds.
Only startUrl is required. It must be an absolute HTTP or HTTPS start URL with a session ID and no credentials. A malformed initial URL throws during rendering. onExpired returns a fresh URL directly or through a promise. It may return nothing if your app updates startUrl instead. Only one renewal runs at a time. A rejected renewal calls onError and leaves the expired dashboard visible. A changed startUrl opens a new session, and the component ignores pending results from the old session or after unmounting. If the WebView's rendering process ends, it asks for a fresh URL because the old bearer was in that process's memory.
onReady runs after each session's summary loads. Share opens React Native's share sheet by default. Optional onShare receives { programId, url, text } and takes over sharing; it may return a promise. onError receives an Error for document loading, renewal and sharing failures. Your app owns loading indicators, error messages and retry controls. The component fills its parent by default. Give the parent a height or flex: 1; style accepts native view styles. The WebView scrolls the document, so grizzlee:resize does not change its native height.
The component checks event.nativeEvent.url against the active start URL's Grizzlee origin, /embed/start path and session query before handling a message. It validates share fields with the same rules as the web host and blocks navigation to other documents. On ready, it injects grizzlee:configure into that document to enable native sharing. The top-level dashboard sends protocol objects as JSON strings through window.ReactNativeWebView.postMessage. Native configuration must come from the document's own window and Grizzlee origin. The iframe's saved-parent checks are unchanged.
Other native apps can load the start URL directly if they expose the same window.ReactNativeWebView.postMessage bridge. Validate the sender document's Grizzlee origin and session, handle expiry through your authenticated merchant API, and send grizzlee:configure with share: true only after installing a native share handler. If your WebView uses a different bridge, adapt it to this interface. See React Native WebView's bridge guide.
Why each piece exists
The start token sits in the URL fragment. Browsers do not send the fragment to the server, so it never appears in Grizzlee's request logs, in proxy logs or in a Referer header. The session ID in the query string is not secret. It only lets the embed page find the company's framing policy before the frame has a bearer.
The start token works once. One conditional UPDATE sets consumed_at and the bearer hash together, and only when the session is unconsumed, unrevoked and unexpired. If two requests race with the same token, PostgreSQL lets one of them change the row. Someone who copies the URL from browser history after the frame loaded holds a dead token.
The frame does not use cookies. Safari and other browsers block or partition third-party cookies inside iframes, so a cookie session would fail there. The bearer lives in memory only. It is gone when the frame reloads, and the host page then creates a new session. The bearer is not stored in localStorage or sessionStorage, so another page on the app origin cannot pick it up later.
Tokens and bearers are 256 random bits. Grizzlee stores only their SHA-256 hashes, as it does for server keys, so a database read does not reveal a usable token. Neither value appears in error messages or logs.
The bearer is bound to one session. The session fixes the company and the partner. Embed reads take no partner ID from the path or the query string, so the frame cannot read another partner. The QR route only encodes a link from that partner's own summary, so it cannot make a QR code of other text. Summary responses omit customer and partner emails. Referral and reward responses use the privacy rules above instead of the merchant API row shapes. Payouts are trimmed to ID, currency, amounts, status, delivery mode, paid time and creation time. Provider errors, transfer references, the Stripe connected account and internal IDs stay with the merchant.
Theme values reach a style attribute and an image source, so the server checks them strictly before saving. Colors must be six-digit hex, the logo must be an HTTPS URL without credentials, and fonts must come from the list above.
Only the app origin can exchange a token. POST /api/embed/exchange rejects any other Origin header with 403 and code invalid_origin. The embed API sends no CORS headers, so scripts on other sites cannot read its responses or send the Authorization header.
Exchange attempts are rate limited per client IP and per token. Failed attempts count too. Too many attempts return 429 with code rate_limited.
The origin list decides who may frame the dashboard. Session creation checks embedOrigin against it. Every /embed/* document sends a server-side Content Security Policy. frame-ancestors uses the session's saved embedOrigin, or the company's current origin list for an older session without one. An unknown or missing session ID sends frame-ancestors 'none'. Unknown embed paths and failed lookups also refuse framing. Removing an origin from settings stops new sessions for that origin. Existing sessions keep their saved parent origin. Their read access ends at expiry, at most 600 seconds after creation, or when revoked. After that, the saved origin still lets the frame show "Session ended" and notify the host.
The rest of the policy is img-src 'self' blob: plus the theme logo's origin, connect-src 'self', font-src 'self', base-uri 'none', object-src 'none' and form-action 'none'. Images can use local resources, generated QR blobs and the configured logo host. API requests and fonts stay on the app origin. The policy blocks base URL changes, plugins and form submissions.
Errors the frame handles
| Status | Code | Meaning |
|---|---|---|
| 401 | session_expired | The token was used, expired or revoked, or the bearer's session ended |
| 401 | unauthorized | The read had no embed bearer |
| 400 | invalid_embed_program | A requested program is outside this partner's memberships |
| 404 | not_found | The QR code's program has no referral link for this partner |
| 403 | invalid_origin | The exchange came from a page outside the app origin |
| 429 | rate_limited | Too many exchange attempts. Wait a minute |
| 503 | live_disabled | The deployment runs without live companies |
The frame treats session_expired as a signal for the host page to create a new session. It shows "Session ended" and sends grizzlee:session_expired to the host.
Milestone program cards show both each-report rewards and the at-count bonus, including percentage rates and report limits. The text uses the session locale, en, fr-CA, hy or ru, and the partner's custom milestone terms when present.
A Stripe payout can change from paid to reversed after a full or partial transfer reversal. The embed shows the cumulative returned amount in reversedCents. That field is null when a mixed-program payout extends beyond the session scope. Its original amount and payment date stay visible. Partner balances reflect the returned cash and approved merchant rewards can be paid again.