Skip to content
Developer docs
⌘ K
Dashboard ↗

Guides

Two-sided marketplace

This recipe covers a retail-partner QR program and a worker-recruitment program. A store shows a QR code. The merchant funds 20% off the referred customer's first order, and the store earns 5% of the merchant's fee before the discount. Store cash rewards use merchant delivery. The merchant can pay them manually or let Grizzlee send scheduled Stripe transfers from the merchant's balance. A worker who refers another worker earns a fixed reward after the recruit completes N tasks.

For a $100.00 order and a $20.00 merchant fee, the customer gets $20.00 off and pays $80.00. Report commissionableCents: 2000 for the fee before the discount and incentiveAppliedCents: 2000 for the discount. The store earns $1.00, or 5% of the fee. The examples use USD. Choose the program's currency for your marketplace.

Program

Set up the Retail partners program like this:

json
{
  "name": "Retail partners",
  "kind": "affiliate",
  "tracking": {
    "links": true,
    "qrCodes": true,
    "attributionCodes": true,
    "providerPromoCodes": false
  },
  "currency": "USD",
  "compensation": { "type": "percentage", "rateBps": 500 },
  "duration": { "type": "only_order" },
  "rewardType": "cash",
  "delivery": { "cash": "merchant" },
  "attributionWindowDays": 30,
  "reviewRewards": false,
  "active": true,
  "friendIncentive": {
    "type": "percent",
    "rateBps": 2000,
    "orders": 1,
    "newCustomersOnly": true
  }
}
  • The 5% applies to commissionableCents. The merchant sends its fee before the discount there.
  • duration: { "type": "only_order" } rewards the store for the order its QR referred. The customer can be new or returning. Another purchase needs another scan or code.
  • delivery: { "cash": "merchant" } keeps cash rewards outside the Grizzlee wallet. Pay manually or use scheduled Stripe transfers from the merchant balance.
  • reviewRewards: false makes new rewards start approved. Rewards also start approved by default when this field is omitted.
  • friendIncentive gives the customer 20% off the order subtotal for one order, funded by the merchant. newCustomersOnly: true limits it to customers with no earlier orders with the merchant. A returning customer who scans a QR still earns the store its 5%, but gets no discount. A quote holds nothing. The merchant reserves the offer when the order is placed.
  • Keep kind: "affiliate" for Retail partners and turn on tracking.attributionCodes. Program kind does not restrict links, QR codes or typed codes. The code identifies the store. The merchant applies the offer, so provider promo codes stay off.

In the dashboard, create Retail partners from the Affiliate commission template and choose Outside partners under Who shares your link. Then set Which orders earn to "Only orders made with their link or code" on the Advanced settings page before creating it. The API takes duration: {type: "only_order"} directly. To review it, open Programs and choose Retail partners. The What people get tab shows "This program uses advanced rules." and the closest answer under For how long. Leave that answer alone, because changing it replaces only_order. On the Advanced tab, Which orders earn shows "Only orders made with their link or code", Discounts shows Discount covers at 1, Sharing has Referral codes on, Safety has "Review rewards before approving" off, and Payouts has Payout method set to "I pay the partner myself".

The sandbox keeps no customer incentive counts. Test the discount against a live company.

Customer and worker partners use their own customer ID in customerIds, even when externalId already has that value. Store partners keep the store ID as externalId and link the owner's customer ID. store.ownerUserId below means the owner ID the merchant's backend reads from its records, not the store ID:

ts
await grizzlee.upsertPartner({
  externalId: store.id,
  customerIds: [store.ownerUserId],
  name: store.name,
  email: store.ownerEmail,
  addProgramIds: [retailProgram.id],
});

await grizzlee.upsertPartner({
  externalId: user.id,
  customerIds: [user.id],
  name: user.name,
  email: user.email,
  addProgramIds: [customerProgram.id],
});

An upsert without customerIds keeps stored links. Send the complete replacement list when ownership changes, or [] to remove all links. A partner's externalId alone never blocks an order.

Use addProgramIds in repeated partner syncs to keep enrollments made in the dashboard or by a placement embed session. programIds replaces the whole list and should not be used by a sync running alongside those enrollment sources. If a placement key such as store_qr names the program, read its programId with getPlacement("store_qr") and add that. To remove one enrollment, send removeProgramIds on a partner update. Moving a placement key to another program does not remove an earlier enrollment. Do not combine these fields with programIds. Each field accepts up to 100 entries, and a program on both sides returns 422 conflicting_programs before anything changes.

Send the customer history answer

The merchant decides whether the customer is new from its own order history, including placed but unpaid orders. Exclude the quote or order being priced. Send the answer as newCustomer when checking or reserving the discount. Save it with the quote and order, then send the same answer when reporting payment. An order placed weeks ago makes a later order a returning-customer purchase even before the first order is paid.

With a server-derived newCustomer answer, the merchant can report only referred orders without importing past order counts. The store's only_order reward still earns on a referred returning customer's order. newCustomersOnly: true blocks that customer's discount. An order that earns several rewards still counts once for usage.

Order categories

Send a server-derived category such as standard, express or custom with the quote, reservation and completed order:

ts
categories: [quote.category],

To reward stores on standard and express orders, but give the customer the discount only on standard orders:

json
{
  "orderCategories": { "include": ["standard", "express"] },
  "friendIncentive": {
    "type": "percent",
    "rateBps": 2000,
    "orders": 1,
    "newCustomersOnly": true,
    "orderCategories": { "include": ["standard"] }
  }
}

In the dashboard, open the program's Advanced tab, then Order categories, and add slugs under "Only these categories". The friend's discount has its own lists under Discounts. A custom order earns the store nothing. Its reservation returns eligible: false with reason: "category_not_eligible", so charge the full price. It still counts in the merchant's customer history and uses up first-order eligibility. An only_order program also claims the QR receipt sent with that order. A later order needs a new scan or code. See Order categories.

Capture in the app

The QR opens a link with the store's short code in ref and channel=qr, plus program when the store's code and page do not identify the program on their own. Links from before short codes use grizzlee_ref, grizzlee_program and grizzlee_channel and keep working. The app captures it with a publishable key, which can only capture referrals.

ts
import { GrizzleeTrackingClient } from "@grizzlee/sdk/tracking";

const tracking = new GrizzleeTrackingClient({
  baseUrl: apiOrigin,
  publishableKey,
  storage: window.localStorage,
});

const captured = await tracking.captureFromUrl(window.location.href);
// captured?.incentive is the offer, such as 20% off. Show it. Never apply it here.
// At checkout, send captured?.receipt to the merchant's backend.

For React Native, pass asynchronous storage and feed the incoming link to captureFromUrl. Enable native capture on the tracking key.

captured.incentive tells the app what the program offers. It does not say whether this customer qualifies. Only the merchant's backend decides the price.

Embed each referral screen

The merchant's backend can create a separate embed session for each customer or worker screen. Derive the partner from the merchant's authentication and select the program on the server:

ts
const session = await grizzlee.createEmbedSession({
  partner: { externalId: authenticatedUser.id },
  programIds: [workerRecruitProgram.id],
  locale: "fr-CA",
  embedOrigin: "https://app.example.com",
});

Use the customer program ID on the customer screen. The partner must already belong to the requested program. Every read, including QR and payout reads, stays within the session's programs. Wallet balances and wallet payouts have no program allocations, so a scoped screen omits them.

Load an authenticated merchant page containing the iframe in the WebView. Opt in to sharing with { type: "grizzlee:configure", share: true } after grizzlee:ready, validate the Grizzlee origin and iframe source, then forward grizzlee:share to the app's native share sheet. GrizzleeEmbed and mountGrizzleeEmbed do the opt-in when given onShare. The embed guide has the forwarding recipe. Worker referrals show task_completed : 6 sur 10 in French Canadian before the referring worker earns the reward. Earned rewards keep their original threshold when program rules change.

Installs after a scan

A QR code opens https://app.example.com/referral/customer?... for customers and https://app.example.com/join/worker?... for workers. With the app installed, the universal link or app link opens it and the app captures the link as above. Without it, the web page opens. That page becomes the landing page and hands the referral to the app through an install claim. No Branch or AppsFlyer account is needed, and nothing is matched by device or IP address. The mobile guide explains each step.

Web landing pages

Capture with installClaim: true. The web receipt remains available for web checkout:

ts
import { createPlayStoreUrl, readReferralLink } from "@grizzlee/sdk/tracking";

const referral = readReferralLink(window.location.href);
const captured = referral
  ? await tracking
      .capture({
        ...referral,
        installClaim: true,
        destination: window.location.origin + window.location.pathname,
      })
      .catch(() => null)
  : null;
const code = captured?.installClaim?.code ?? null;
const playStore = code
  ? createPlayStoreUrl({
      packageName: "com.example.marketplace.worker",
      installClaim: code,
    })
  : androidStoreUrl;

Use the customer app's package name on its landing page. Show an "Open the app" button and store links. On iOS, show the claim code with a "Copy code" button that copies only when tapped. Tell the customer or worker to paste it after installing. The SDK does not store this code. If the landing page redirects, keep it in sessionStorage and show the install controls on the destination page.

A code works once, for 7 days. When capture fails, fall back to plain store links.

Customer and worker apps

Use an asynchronous storage adapter with each app's tracking client.

  1. On Android, redeem the install referrer at first launch, before checkout or signup reads a receipt. In Expo, add expo-application and use a development build:

    ts
    import * as Application from "expo-application";
    
    if (Platform.OS === "android") {
      const referrer = await Application.getInstallReferrerAsync().catch(
        () => null,
      );
      await tracking.captureInstallReferrer(referrer).catch(() => null);
    }

    The SDK remembers the referrer. Later launches skip it and never replace a newer link's receipt.

  2. On iOS and Android, check the "Have a referral code?" field before sending it to the backend:

    ts
    import { isInstallClaimCode } from "@grizzlee/sdk/tracking";
    
    if (isInstallClaimCode(typed)) {
      const redeemed = await tracking
        .redeemInstallClaim(typed)
        .catch(() => null);
      if (redeemed) return;
    }
    // Otherwise send the partner's code to the backend.

    Retain the returned programId so checkout or signup can read tracking.receipt(programId) and send it to the backend. Do the same after captureInstallReferrer. Clear the typed code when redemption succeeds so it does not compete with the redeemed receipt. Claim a worker's receipt for the authenticated recruit.

Enable native capture on each app's tracking key. Allow https://app.example.com as an origin on the web key.

Typed code at checkout

The customer app sends its stored scan receipt and any typed code together. It does not choose one. The merchant's backend builds the candidate list when it prices the quote:

ts
const candidates = [
  ...(scanReceipt ? [{ type: "receipt" as const, receipt: scanReceipt }] : []),
  ...(typedCode
    ? [
        {
          type: "code" as const,
          referralCode: typedCode,
          programId: retailProgramId,
          enteredAt: new Date().toISOString(),
        },
      ]
    : []),
];
// Save candidates with the quote and reuse them when placing the order and reporting payment.

Send the list to getReferralIncentive, reserveIncentive and completeOrder. The newest valid referral wins while customer attribution is open, so a code entered at checkout beats an older scan. Qualified customer attributions keep their partner. Order-only programs select a referral for each purchase. Expired or invalid candidates do not fail checkout. Check referralSelection for the winner and skipped reasons. With no winner, charge the full order subtotal. Save enteredAt on the backend and reuse it after payment. A reservation keeps the selection made when the order was placed. Code candidates have channel coupon.

Show the offer on a quote

The merchant's backend previews the offer when it prices a quote. This call holds nothing:

ts
const offer = await grizzlee.getReferralIncentive({
  customerId: customer.id,
  newCustomer: quote.newCustomer,
  candidates: quote.candidates,
  categories: [quote.category],
});
const quotedDiscountCents =
  offer.eligible && offer.terms
    ? computeIncentiveCents(offer.terms, quote.subtotalCents)
    : 0;

Import computeIncentiveCents from @grizzlee/sdk/server. The lookup needs read. A returning customer gets not_new_customer and a paused store gets partner_inactive. Save the merchant's history answer with the quote. The offer can change or lose its remaining use before placing the order, so the quote must allow a price review when placing the order.

Reserve when the order is placed

When the customer places the order, the merchant reserves with the placed order's ID:

ts
const held = await grizzlee.reserveIncentive({
  customerId: customer.id,
  orderId: order.id,
  newCustomer: quote.newCustomer,
  candidates: quote.candidates,
  baseCents: quote.subtotalCents,
  categories: [quote.category],
});
const discountCents = held.eligible ? (held.suggestedDiscountCents ?? 0) : 0;
// Save held.reservationId and discountCents with the placed order.

The key needs events:write. The hold copies the terms and lasts until that order completes or the merchant releases it. It has no expiry or maximum duration. Program edits do not change the placed order's terms. Repeating the order ID returns the same hold. Keep the saved newCustomer answer when reporting payment.

If a second order took the offer between quote and order placement, the answer is eligible: false, with reason reservation_open, uses_exhausted or order_limit_reached. The merchant must place the order without the discount or ask the customer to review the new price. If Grizzlee is unreachable, retry with the same order ID before applying the discount.

When the order is cancelled, release its hold:

ts
await grizzlee.releaseIncentive(order.reservationId);

Repeating release is safe. Only this order's completion consumes the hold. Sending its ID with another order flags that order reservation_locked and leaves the hold open. Use listIncentiveReservations({ olderThanDays: 30 }) or the dashboard's open-hold list to find forgotten cancellations. Grizzlee never releases holds on its own.

Confirm payment

After the merchant verifies payment, its backend reports the order with the reservation. Every amount comes from the merchant's records.

ts
const result = await grizzlee.completeOrder(
  {
    type: "order.completed",
    eventId: payment.id,
    orderId: order.id,
    customerId: customer.id,
    newCustomer: order.newCustomer, // reuse the answer saved with the quote
    currency: "USD",
    commissionableCents: order.feeCents, // the fee before the customer's discount
    incentiveAppliedCents: discountCents,
    categories: [order.category],
    occurredAt: payment.confirmedAt,
  },
  {
    candidates: order.candidates,
    reservationId: order.reservationId ?? undefined,
  },
);
// result.customerOrderNumber is Grizzlee's count for this customer.

The key needs events:write and partners:write. The receipt claim, the order, the store's reward, the reservation and the customer's use commit in one transaction. The order can omit reservationId. Grizzlee finds its open hold by order.id.

Completion never fails over the customer's discount. The merchant already charged the customer, so Grizzlee records the order, evaluates the store's reward and stores incentiveAppliedCents as the merchant sent it. When the discount is above the reserved offer's maxDiscountCents, the order's category is not one the offer covers, or the reservation is missing, released, already used or reserved for another order, Grizzlee flags the order instead. The order and result get incentiveFlag with reason, maxCents, the offer's per-order limit, and reportedCents, what the merchant sent. The dashboard lists flagged orders and the incentive.flagged webhook reports each one. The customer's use still counts.

The merchant calculates the 20% discount itself. Grizzlee does not infer the order subtotal from commissionableCents.

Retry a failed network call with the same event ID and payload. A retry returns duplicate: true and records no second use. When the app hears that the backend accepted the order, call tracking.forgetAfterOrder(programId, usedReceipt).

A later purchase with no new scan or code goes without a receipt. It earns the store nothing and gets no discount. Report it anyway, so the next count is right.

Self-referral signals

Grizzlee blocks a store or worker that refers its own customer ID. To catch the same person behind two accounts with the merchant, the merchant's backend sends identity signals. Grizzlee keeps only keyed hashes per company, never returns them and never puts them in webhooks.

ts
const signals = {
  phoneHash, // lowercase hex SHA-256 of the verified E.164 phone
  paymentFingerprint: charge.payment_method_details?.card?.fingerprint,
  deviceId: install.id,
  ipAddress: request.ip,
};
  • With the partner: send signals on upsertPartner for each store or worker, with the owner's phone, device and IP address. The partner's email already counts. Set customerIds: [store.ownerUserId] so the store links its owner's customer ID and uses that user's stored signals too. Customer and worker partners use customerIds: [user.id], their own customer ID.
  • With the customer: send signals on getReferralIncentive when pricing the quote and reserveIncentive when placing the order and on completeOrder after payment. customerEmail on the order event counts as the customer's email, so the merchant can keep sending the email there. attachReferralCode takes signals too.

Grizzlee accepts phoneHash, paymentFingerprint, ipAddress, deviceId, email and a raw phone. Unknown fields still fail strict validation. A phone or IP address Grizzlee cannot normalize is ignored, not rejected, so a bad value never turns into a permanent 400.

Retail partners defaults to fraudChecks.selfReferral.action: "block". An owner buying under a linked customer ID is self-referral even when no identity signals match. When the customer shares an email, phone, card or device with the store, reserveIncentive returns eligible: false with reason: "self_referral" and the merchant charges the full order subtotal. completeOrder skips the store's reward, and a self_referral.detected webhook names the matching signal types. A shared IP address alone, such as a customer on the store's Wi-Fi, leaves the reward approved and records the match with action flagged. Set fraudChecks: { selfReferral: { action: "review" } } to hold every match for review. Use flag to record matches while paying normally, or off to disable all self-referral checks.

Fraud checks

Every program also runs the fraud rules on the same hashed signals. Send verified phone or card signals for account checks, and device signals for rapidRepeats. Rules without the signals they need cannot fire.

The other fraud rules only flag by default. A flagged reward follows the usual approval, records fraud and sends fraud.flagged, so the merchant can watch what the rules catch before anything is held. For Retail partners, keep IP counting off and opt in like this once signals are on:

  • duplicateAccounts flags the store's reward when 3 customers the store referred in 30 days used one card. A store farming its own QR code with a handful of accounts and one card shows up here. Move it to "review" if the flags turn out to be real.
  • rapidRepeats needs a device or an IP address. Leave ipReferrals at null. Customers on a store's Wi-Fi share one address.
  • repeatCustomer fires when the customer's phone or card belongs to another customer who already earned a store a reward. That is the second account chasing another new-customer discount. Set it to "review" once the merchant is ready to approve or reject those rewards. "block" would stop the discount at reserveIncentive with reason fraud, but couples who share a card would lose the offer too, so start with review.
  • disposableEmail flags throwaway addresses in customerEmail.
json
{ "fraudChecks": { "repeatCustomer": { "action": "review" } } }

A customer referral program can use the same settings. For online referrals, an IP threshold such as rapidRepeats.ipReferrals: 10 counts 10 referrals from one address within an hour. Keep IP counting off for store QR scans.

A rule set to "review" holds the reward as pending, so reward.created arrives with status: "pending" and no reward.approved follows until someone approves it under Rewards, Needs review in the dashboard or with approveReward. The merchant should pay a store only for approved rewards, so held ones stay unpaid. A blocked reservation returns reason fraud; charge the full price or review the quote.

Pay the store

Store rewards start approved with reviewRewards: false. For manual payments, send the money and confirm delivery to Grizzlee. The key needs payouts:write for payouts and rewards:deliver for single deliveries.

For a cash payout run, create a merchant payout listing the rewards it pays, send the money, then mark it paid:

ts
const payout = await grizzlee.createPartnerPayout(
  { externalId: store.id },
  {
    mode: "merchant",
    eventId: payoutRun.id,
    allocations: owedRewards.map((r) => ({
      rewardId: r.id,
      amountCents: r.owedCents,
    })),
  },
);
// Transfer payout.netAmountCents. After the transfer succeeds:
await grizzlee.markPayoutPaid(payout.id, { externalReference: transfer.id });

Each allocation must be an approved merchant cash reward and cannot exceed what the merchant still owes after earlier deliveries and other open payouts. owedRewards contains those rewards and the remaining amount for each in owedCents. Marking the payout paid records a delivery for every listed reward. For a single cash payment, confirm the reward directly:

ts
await grizzlee.deliverReward(reward.id, {
  externalReference: transfer.id,
});

Automatic Stripe payouts

Grizzlee can pay stores on a schedule from the merchant's Stripe balance, straight to each store's Stripe connected account. Stripe transfers the money. Grizzlee only decides when and how much, and sends the transfer with the merchant's key.

Keep the program as it is, with delivery: { "cash": "merchant" }. The rewards stay merchant rewards, the merchant still pays them, and nothing enters the Grizzlee wallet. Then:

  1. In Settings, then Connections, add a Stripe connection for the merchant's platform account with a restricted key that has Transfers: Write. If the same connection reads refunds, it also needs charges and refunds read access.
  2. Onboard each store as a connected account on the merchant's Stripe platform with the transfers capability. Workers can use the same setup for milestone payouts.
  3. Set each store's connected account once, from the dashboard or with the store upsert. The key needs payouts:write as well as partners:write:
ts
await grizzlee.upsertPartner({
  externalId: store.id,
  customerIds: [store.ownerUserId],
  name: store.name,
  email: store.ownerEmail,
  stripeAccountId: storeConnectAccountId,
});
  1. Save payout terms with the connection's ID and missingStripeAccount: "wait". Saving replaces all the terms, so always send both. Leaving out stripeIntegrationId turns Stripe payouts off, and leaving out missingStripeAccount goes back to manual payouts for stores without an account:
ts
await grizzlee.updatePayoutSchedule({
  enabled: true,
  frequency: "weekly",
  weekday: "monday",
  minimumCents: 2_000,
  holdPeriodDays: 14,
  stripeIntegrationId: merchantStripeConnectionId,
  missingStripeAccount: "wait",
});

On each due date Grizzlee creates one payout per store that is owed at least the minimum, nets any debt the store owes after refunds, and sends payout.created. Stores with a stripeAccountId get provider: "stripe", and Grizzlee transfers netAmountCents and sends payout.paid with the tr_ ID in providerReference. Subscribe to transfer.reversed in the Stripe account that sends these transfers. A full or partial reversal changes the payout to reversed, appends returned cash to the delivery ledger and makes approved cash payable again. Previously netted debt stays settled.

payout.reversed carries reversalCents, the new amount returned, reversedCents, the cumulative total, partial, the payout ID and partner IDs. Re-read the store summary after it. Repeated or older events restore nothing twice.

For a Stripe-only payout workflow, use missingStripeAccount: "wait". The default "manual" creates manual payouts for stores without an account, which require the merchant to send the money. With "wait", a store can join and earn before it finishes Connect onboarding. Its rewards stay payable and roll over. The first due date after the merchant sets its stripeAccountId pays them by Stripe transfer.

The first run that holds a store's payout sends payout.account_required with the amount it would have paid. Use that event or partner emails to remind the store to finish onboarding. getPartnerSummary shows the same state in payoutAccountRequired, awaitingPayoutAccount and awaitingPayoutAccountSince. Customers earning only credit never need a payout account. See Waiting for a Stripe account.

The merchant's payout.created handler must send money only when event.data.provider === "manual". payout.failed only comes before any transfer request, for example when the Stripe connection was disabled, and its rewards go into the next run. payout.needs_action requires attention. Check for an empty Stripe balance, incomplete store onboarding or a transfer Stripe never confirmed after up to 18 hours of retries. Read lastError, then look in Stripe for transfer group grizzlee_payout_<payout ID>. If Stripe paid it, call markPayoutPaid with the tr_ ID. If not, fix the cause and, 24 hours after the last transfer request, call cancelPayout(payout.id, { confirmNoTransfer: true }). The rewards then go into the next run. The full rules are in Stripe transfers.

For a single payment through Stripe, add provider: "stripe" and integrationId to the merchant payout above. Leave out destination to use the store's registered account.

Use the selected entry in summary.programs for the store or worker program's totals. Each has earnedCents, reversedCents, referrals.referred, referrals.qualified, orderCount, payouts.paidOutCents and balances. Merchant balances keep the partner-wide field names, including approvedUndeliveredCents, inOpenPayoutsCents and deliveredGrossCents. payouts.paidOutCents counts gross allocations from paid merchant payouts in this program, even when a payout spans programs. It excludes direct deliveries and wallet payouts. Program wallet balances only have pending and approved reward amounts. Read wallet spending and debt from the partner-wide balances. A customer's renewals count once per attribution. Separate order-scoped referrals count separately.

For the store hub's "orders referred", read the store program's orderCount from the summary. Grizzlee computes it in the database across all referrals, so a partner with more than 1,000 referrals gets the complete total. Do not sum a capped set of listPartnerReferrals pages. The total equals the sum of every referral's orderCount in that program. Each referral counts distinct orders that earned this store a reward, including rewards later reversed. Orders that earned nothing and milestone rewards do not count. Leaving the program keeps the total.

When a backend claims candidates for a known store, customer or worker program, pass expectedProgramId to claimReferral. A candidate for another program is skipped with wrong_program. Code attachments keep their programId selector.

getPartnerSummary({ externalId: store.id }) shows what the merchant still owes the store in balances.merchant.cash. Each program in the summary includes incentive, so the store's own screen can say "Your QR gives new customers 20% off their first order". listPartnerReferrals shows each referred customer with rewardedCents and incentiveAppliedCents.

Customer credit in merchant billing

For customers who earn referral credit, set rewardType: "credit" and delivery: { credit: "merchant" }. The merchant holds and spends that credit in its own billing system. Grizzlee keeps the reward rules, deliveries and refund debt.

After a reward webhook, read getPartnerSummary({ externalId: customerPartnerId }) and use balances.merchant.credit. The merchant needs only the last applied balance version and its own ledger entries, not a table of reward amounts or a reward state machine. Link the customer partner's customerIds to the ID the merchant sends on orders.

  1. Serialize reconciliation for that partner. Finish retrying any saved Grizzlee confirmation before writing a new ledger entry.
  2. Read the summary and ignore a version older than the last applied version. Save the version with the billing work.
  3. When approvedUndeliveredCents is positive, add that amount to the merchant's credit ledger. Save the entry ID and the confirmation request together.
  4. Confirm the amount with the partner-level delivery call.
  5. Re-read the summary. When debtCents is positive and the customer has unspent credit, take back up to the debt amount and confirm the collection. Re-read after confirmation.
ts
await grizzlee.deliverPartnerCredit(customerPartnerId, {
  externalReference: creditEntry.id,
  amountCents: creditEntry.amountCents,
  currency: "USD",
});
await grizzlee.recoverPartnerDebts(customerPartnerId, {
  externalReference: recoveryEntry.id,
  amountCents: recoveryEntry.amountCents,
  currency: "USD",
  rewardType: "credit",
});

The key needs read and rewards:deliver. Skip calls for zero amounts. Delivery allocates oldest approval first; recovery settles oldest debt first. Both return the per-reward allocations for inspection. Retry the same entry ID and request after a timeout. Grizzlee returns the original result without sending the webhooks again. Changed details under the same reference return 409.

Handle reward.approved, reward.reversed, reward.restored, reward.adjusted, reward.debt_recorded and reward.debt_settled by queuing another balance read. Initial approval already sends reward.approved. Delivery sends reward.delivered, which can also trigger reconciliation. Several events can describe one change, and they may arrive out of order. Use the summary's version rather than webhook arrival order.

Grizzlee's version orders reads; it does not lock the merchant's ledger while rewards change. An amount above current undelivered credit or outstanding debt returns 409. If that happens after the merchant wrote its ledger, reconcile the entry against a fresh summary before issuing more credit. A restoration reduces unpaid debt first. If the merchant already collected it, the restored amount becomes credit owed again and the same flow delivers it with a new ledger entry ID. Checkout spending and credit holds for scheduled orders stay in merchant billing.

Refunds

Send refunds to sendEvent with the original order ID and a new event ID. refundedCommissionableCents is the cumulative refunded share of the fee you reported as commissionableCents, before the discount. fullRefund: true reverses the whole reward.

ts
await grizzlee.sendEvent({
  type: "order.refunded",
  eventId: refund.id,
  orderId: order.id,
  refundedCommissionableCents: 1000, // half of the $20.00 fee
  occurredAt: refund.createdAt,
});

A refund of half the fee reverses $0.50 of the store's $1.00. The reward keeps the 5% rule it was earned under, even if the program changes later. The customer's discount stays used. A refund gives the discount back only when the offer has returnOnFullRefund: true and the order is fully refunded.

If a refund fails or the merchant cancels it, send the lower cumulative total with a new event ID. Grizzlee restores the reward by the difference and sends reward.restored. A reward that a full refund reversed returns to the status it had before.

If the merchant already paid the store, a reversal records a delivery debt. Keep part of the next payment with nettedDebtCents on deliverReward or a merchant payout, or record a separate recovery with recoverPartnerDebt. A later restoration lowers the outstanding debt first.

Fee adjustments

If the merchant's fee changes after payment, send the new whole fee with adjustOrder. Use a new event ID for each change and reuse it on retries. Report the fee before the customer's discount, not just the change:

ts
await grizzlee.adjustOrder({
  eventId: `${order.id}:fee:${adjustment.id}`,
  orderId: order.id,
  commissionableCents: 2600, // the updated whole fee
  reason: "Fee correction",
  occurredAt: adjustment.createdAt,
});

The store keeps its saved 5% rule. A fee increase from $20.00 to $26.00 raises its reward from $1.00 to $1.30. Grizzlee appends a $0.30 adjustment ledger entry and sends reward.adjusted with changeCents: 30 and order.adjusted. A lower fee lowers the reward. If the merchant already paid it, the reduction becomes delivery debt. An increase adds to what the merchant owes. An adjustment neither counts as a new order nor changes the customer's discount, and never creates a reward for an order that had none.

Adjust before refunding any added fee. Refunds then measure against the new fee, so refundedCommissionableCents can reach 2600. The fee cannot fall below the amount already refunded, and a fully refunded order cannot be adjusted. Use fullRefund: true to fully refund an order.

Webhooks

Subscribe to the events the merchant acts on:

  • order.completed carries the submitted order, including incentiveAppliedCents, Grizzlee's customerOrderNumber, and the store in partnerId and partnerExternalId. It adds incentiveFlag when the discount did not match the reservation.
  • incentive.flagged follows order.completed for a flagged order.
  • reward.created tells the merchant a store earned a reward. Check status before paying it. With this configuration, it starts approved and also sends reward.approved. Both events name the same reward. Pay it once.
  • reward.reversed and reward.restored follow refunds and their reversals.
  • order.adjusted and reward.adjusted follow adjustOrder. changeCents on reward.adjusted is signed.
  • reward.delivered and reward.delivery_failed follow deliveries, including the ones a paid payout records.
  • payout.created, payout.paid, payout.reversed and payout.cancelled follow merchant payouts. Stripe payouts can also send payout.failed and payout.needs_action. Send money on payout.created only for provider: "manual".
  • payout.account_required says a scheduled run held a store's payout until the store has a Stripe account. It is not a payout and orders no money. Add it to the endpoint's event list after updating the SDK.
  • reward.debt_recorded follows a refund or a lower adjusted fee that leaves a store holding part of a paid reward. reward.debt_settled follows each recovery, netting or restoration of that debt.
  • referral.displaced tells the merchant another partner took a customer over before the customer's first qualifying order.
  • fraud.flagged and self_referral.detected name the rules or matching signals behind a held or blocked reward.

Reward webhooks for an order repeat its incentiveAppliedCents and incentiveFlag, so the merchant can match a store's reward to the customer's discount without waiting for order.completed. An endpoint receives only the types it lists, so create a new endpoint with the debt and referral types after updating to SDK 0.7.0. Verify the untouched body with verifyWebhook, then parse it with parseWebhookEvent. Check that its ID matches the Grizzlee-Event-Id header. Deduplicate by event.id. Delivery is at least once, and events may arrive out of order.

See the SDK reference and the API reference.

Partner emails

If the merchant sends its own store and worker notifications, turn off Grizzlee's partner emails. Open Settings, then Notifications, turn off every switch under Emails to send and save. New companies start with every switch on. There is no API for these settings. See Partner emails.

Account deletion

The merchant uses user.id as the customer ID on orders and as the external ID of customer and worker partners. When a user deletes their account, the erasure process calls eraseCustomer(user.id) and erasePartner({ externalId: user.id }). A user who never became a partner returns 404 not_found on the second call, which means there is nothing to erase. Both calls are safe to retry.

Store partners use store.id. Deleting an owner's personal account leaves the store partner intact, so the merchant must also remove that user's ID from the store's customerIds. Erasing a store partner removes its links and personal data, suppresses its identity signals from later reports and leaves the owner's customer record intact. Customers are erased only through eraseCustomer. Customer orders, earned rewards and ledger entries stay.

After customer erasure, late order and milestone reports still count, but Grizzlee drops their identity signals, emails and personal reasons. The merchant does not need to send erasure again after its outbox drains. See Data erasure for what Grizzlee keeps.

Worker recruits

An independent worker can refer another worker to the marketplace. When the recruit completes their 10th task, the referring worker earns $50 cash. The merchant pays it through Stripe Connect from its own system and tells Grizzlee. The recruit earns nothing from this program.

Milestone program

json
{
  "name": "Worker recruits",
  "kind": "referral",
  "currency": "USD",
  "trigger": { "type": "milestone", "milestone": "task_completed" },
  "milestoneRewards": { "atCount": { "count": 10, "amountCents": 5000 } },
  "rewardType": "cash",
  "delivery": { "cash": "merchant" },
  "attributionWindowDays": null,
  "reviewRewards": false,
  "active": true
}
  • trigger makes the program pay for the task_completed milestone instead of orders. Orders never earn from it.
  • milestoneRewards.atCount.count: 10 pays once the recruit has 10 completed tasks reported. Set it to N for another threshold. Report each completed task and read the program's count to show progress.
  • This program pays a fixed amount once per recruit. Omit order-only compensation and duration.
  • attributionWindowDays: null keeps the referral open however long the recruit takes. Set a number of days to require the 10th task within that time after the referral.
  • delivery: { "cash": "merchant" } means the merchant pays the worker.

In the dashboard, choose Other / for apps when you start a new program, then Reward an action. Its sentence pays $50 in cash once someone you invite completes 10 of an action. Type tasks as what people complete and keep 10, 50, cash and once. Before creating, open Advanced settings and set Payout method to "I pay the partner myself" under Payouts, unless the Payouts page already makes merchant delivery the default. "Referrals never expire" is already on under Sharing. Create the program. The dashboard names the milestone key after the typed word, so open Developer, choose Change next to the program's key under Program keys, enter task_completed and choose Save key. That adds a program version, which does not matter before any reports arrive.

Changing the count later creates a new program version. A worker who already earned keeps the reward. Recruits still counting are measured against the new number from their next completed task.

Add each referring worker as a partner in this program with upsertPartner, using the worker's customer ID as externalId and customerIds: [worker.id]. A milestone for that linked user follows the same self-referral rule as an order.

Record the referral

The recruit signs up through a worker's link, redeems an install claim code, or enters the worker's code. See Installs after a scan. The merchant's backend claims link and install receipts for the authenticated recruit with claimReferral, selecting the worker program with expectedProgramId. Attach a typed code to the recruit's ID:

ts
await grizzlee.attachReferralCode({
  referralCode: typedCode,
  customerId: recruit.id,
  programId: workerProgramId,
});

Do not send an orderId. The latest referral wins until the recruit's 10th task pays. After that it stays with the worker who earned it. A newer referral before then restarts the count, because the program counts tasks from the referral. A worker cannot refer their own worker ID.

The worker recruiting program could also pay per task with milestoneRewards.eachReport, as a fixed amount or a percentage of the report's valueCents, with an optional report limit. The example here keeps the $50 bonus at 10 tasks.

Report each completed task

When the recruit completes a task, report it:

ts
const result = await grizzlee.reportMilestone({
  eventId: `${task.id}:task_completed`,
  customerId: recruit.id,
  milestone: "task_completed",
  occurredAt: task.completedAt,
});
// result.count is the recruit's unreversed report count. Each decision has
// milestoneCount and milestoneThreshold for program progress, such as 7 of 10.

The key needs events:write. Using the task in eventId makes every retry the same event and every task a new one. Tasks completed before the referral do not count. The 10th counted task creates the reward. It starts approved, and reward.created and reward.approved carry milestone: "task_completed" with no orderId. Later tasks keep counting but earn nothing more.

A milestone.reported webhook follows each new report, with the worker in partnerId and partnerExternalId, the new rewards in rewardIds and the progress in decisions.

Report tasks for referred workers. Reports for workers without a referral create records but earn no reward.

Reverse a task

If a reported task is cancelled or refunded so it no longer counts, reverse its report:

ts
await grizzlee.reverseMilestoneReport({
  eventId: `${task.id}:task_completed:reversed`,
  reportEventId: `${task.id}:task_completed`,
  reason: "Task cancelled",
});

If that leaves the recruit below 10 counted tasks after the reward was earned, Grizzlee reverses the full $50 with a reversal ledger entry and sends reward.reversed and milestone.reversed. This happens after payment too, the same as a refund after delivery: if the merchant already paid the worker, the reversal becomes delivery debt and reward.debt_recorded follows. Keep it from the next payment with nettedDebtCents or record a recovery. The recruit's next completed task brings the count back to 10, restores the $50 and sends reward.restored. A restoration settles outstanding debt first with reward.debt_settled of kind: "restored".

A report can be reversed once. If the task completes again, report it under a new event ID.

Pay the worker

Pay the reward from Stripe Connect, then confirm it the same way as store rewards:

ts
await grizzlee.deliverReward(rewardId, { externalReference: transfer.id });

A merchant payout can also list milestone rewards in its allocations. getPartnerSummary({ externalId: worker.id }) shows what the merchant still owes in balances.merchant.cash. Its worker program has terms.text.reward.en, "$50 cash when your referral completes 10 steps", or your own word when the program sets display.en.milestoneLabel, the French text for the worker's screen, and terms.milestoneRewards.atCount.count. listPartnerRewards lists the reward with trigger: "milestone".

Revoke

If the recruit is suspended or found to be fraudulent, revoke the milestone for them:

ts
await grizzlee.revokeMilestone({
  eventId: `${recruit.id}:task_completed:revoked:${suspension.id}`,
  customerId: recruit.id,
  milestone: "task_completed",
  reason: "Account suspended",
});

Grizzlee reverses the full $50, keeps the earned entry in the ledger and sends reward.reversed. It also sends milestone.revoked with the reason and reversalCents. If the merchant already paid the worker, the reversal becomes delivery debt and reward.debt_recorded follows. While the milestone is revoked, a completed task still counts but returns status: "revoked" and earns nothing. To take back single tasks, reverse them instead.

Give each revocation its own event ID, for example from the suspension or dispute that caused it. A milestone can be revoked again after a reinstatement, and a reused event ID returns the first revocation's result with duplicate: true and changes nothing.

Reinstate

If the merchant lifts the suspension or wins the dispute, reinstate the milestone:

ts
await grizzlee.reinstateMilestone({
  eventId: `${recruit.id}:task_completed:reinstated:${suspension.id}`,
  customerId: recruit.id,
  milestone: "task_completed",
  reason: "Suspension lifted",
});

Grizzlee restores the $50 with a restoration ledger entry, puts the reward back to approved and sends reward.restored and milestone.reinstated with restoredCents. A reward stays reversed if reversed tasks left the recruit below 10, until new tasks bring the count back. If the reversal had become delivery debt, the restoration settles what is still outstanding and sends reward.debt_settled with kind: "restored". If the merchant already recovered that debt, the merchant owes the worker $50 again. Deliver it with a new reference or a payout. The milestone counts again, so a later revocation works the same way as the first. Reinstatement does not pay a recruit who reached 10 tasks while revoked. The next completed task pays.

Usage

Each task report that creates a reward counts once toward the merchant's software usage, like a rewarded order. Other task reports, revocations and reversals do not change it.

Customer names in partner dashboards

The merchant's backend can send optional customerDisplayName when claiming a referral or attaching a code, and inside the completed order event. Send only a name intended for the partner, such as the customer's first name. Grizzlee trims it and accepts 1 to 80 characters. The latest accepted name replaces the referral's saved name without changing earned rewards.

Embedded dashboards, including mobile WebViews, show that name or "Referred customer". French Canadian sessions use "Client parrainé". Their JSON API omits raw customer and order IDs too. Merchant APIs retain real IDs. eraseCustomer(user.id) removes display names and prevents queued reports from restoring them. This guide describes the available Grizzlee input; the merchant must choose whether to send it.