@grizzlee/sdk
v0.31.0
Published
Track referrals and manage partner rewards with Grizzlee.
Readme
Grizzlee SDK
The SDK has separate imports for the merchant backend and the customer app.
import { GrizzleeServerClient } from "@grizzlee/sdk/server";
import { GrizzleeTrackingClient } from "@grizzlee/sdk/tracking";
import { createReferralLink } from "@grizzlee/sdk/links";Keep GrizzleeServerClient and its private key on your backend. A website or mobile app uses a publishable tracking key. It can capture a referral but cannot confirm payments or set commission amounts.
Register and update partners
Use your merchant system's externalId to create or refresh a partner. The private key selects the company. The SDK encodes the external ID as one URL segment, including slashes and literal percent signs.
const { partner, created } = await grizzlee.upsertPartner({
externalId: "merchant/customer-123",
customerIds: ["customer-123"],
name: "Alex Store",
email: "[email protected]",
});
const stored = await grizzlee.getPartnerByExternalId("merchant/customer-123");
const updated = await grizzlee.updatePartnerByExternalId(
"merchant/customer-123",
{ email: "[email protected]", labels: ["VIP"] },
);The server returns 201 for a new partner and 200 for an existing partner. upsertPartner returns { partner, created }, with created derived from that status. Identical retries return created: false. Contact refreshes keep the internal ID, referral code, creation time, active status, portal membership and earned rewards.
customerIds links the partner to their own merchant customer IDs. Send up to 100 unique IDs, each 1 to 120 characters. Omit it to keep existing links, or send [] to clear them. A store should link its owner's customer ID. Orders and milestone reports compare against these links, never externalId. Partner erasure removes the links and those in saved partner.created payloads.
Partner uniqueness uses the company and externalId. A store and its manager can use distinct external IDs with the same contact email. Email refreshes do not transfer portal ownership.
Portal access stays tied to the stored membership. A verified program join keeps that binding, then prefers the account's user: profile when unbound. Without either, it uses a single email match and rejects multiple matches with 409 partner_ambiguous. The current model binds one partner per account per company. Team invitations do not link partners by email. Missing external IDs on reads or updates throw status 404 and code not_found.
stripeAccountId is the partner's Stripe connected account on your Stripe platform, such as acct_1Nv0FGQ9RKHgCVdK. Scheduled Stripe payouts send transfers there, and Stripe payouts default to it. Setting it needs a key with payouts:write as well as partners:write, because it decides where money goes. Send null to remove it. Keys without payouts:write get partners without it.
locale is the partner's language for emails, the partner portal and embedded dashboards: "en", "fr-CA", "hy" or "ru". Without it, or when the company does not serve that language, they use the company's default language. Send null to remove it.
Omitted programIds, labels, properties, stripeAccountId and locale keep their stored values. Supplied lists and properties replace them. Send [] to clear a list or {} to clear properties. A new partner without programIds starts with an empty list. Every program must belong to the key's company. Properties must match its configured profile fields.
programIds replaces the whole enrollment list. Do not send it from a sync that runs alongside dashboard or placement enrollment. Use addProgramIds and removeProgramIds to change individual enrollments:
const { programId } = await grizzlee.getPlacement("customer_referral");
await grizzlee.upsertPartner({
externalId: "merchant/customer-123",
name: "Alex Store",
email: "[email protected]",
addProgramIds: [programId],
});
await grizzlee.updatePartnerByExternalId("merchant/customer-123", {
removeProgramIds: [oldProgramId],
});Each field accepts up to 100 entries. Additions keep existing enrollment order and append addProgramIds in array order. Repeated programs appear once. Removals drop only the listed programs. Removing a program the partner has not joined does nothing. New partners start with no enrollments before applying these fields. Adding a paused program follows the same rules as programIds.
Do not combine any addition or removal field with programIds, even with empty arrays. Invalid combinations return 400 validation_error. Unknown programs return unknown_program. A program on both sides returns 422 conflicting_programs. Grizzlee validates every entry before changing the partner. Enrollment changes use the same audit and event handling as programIds writes.
These methods and types are exported from @grizzlee/sdk/server:
upsertPartner(input: UpsertPartnerInput): Promise<UpsertPartnerResponse>;
getPartnerByExternalId(externalId: string): Promise<GetPartnerByExternalIdResponse>;
updatePartnerByExternalId(
externalId: string,
input: UpdatePartnerByExternalIdInput,
): Promise<UpdatePartnerByExternalIdResponse>;PartnerInput, PartnerUpsertInput and UpsertPartnerInput require externalId, name and email, with optional customerIds, programIds, addProgramIds, removeProgramIds, labels, properties, stripeAccountId and locale. UpsertPartnerInput also accepts signals. UpsertPartnerResponse is { partner: Partner; created: boolean }. Both lookup and update response types are Partner. PartnerUpdateInput accepts a nonempty combination of the same optional profile and enrollment fields, plus active, without externalId. UpdatePartnerByExternalIdInput adds optional signals. Partner, PartnerInput, PartnerUpdateInput, PartnerUpsertInput and PartnerUpsertResult are also exported. Partner responses include the resulting programIds, without the addition and removal fields.
registerPartner(input: PartnerInput & PartnerSignalsInput): Promise<Partner> accepts the same enrollment fields and updates an existing external ID when supplied fields change. Use upsertPartner when you need to distinguish creation from an update. updatePartner(id: string, input: PartnerUpdateInput & PartnerSignalsInput): Promise<Partner> remains available for internal IDs. Reads require read; writes require partners:write. Profile updates cannot change the external ID or company.
Tracking methods and provider promo codes
ProgramInput.tracking has links, qrCodes, attributionCodes and providerPromoCodes. The first three default to true on new programs. Provider promo codes default to false and need an active Stripe or Shopify connection. kind sets dashboard wording and defaults, without restricting tracking. Existing affiliate programs keep typed codes off after migration. Set attributionCodes: true to enable them. Omitted tracking on an update keeps the stored switches. A supplied object replaces them and fills omitted switches with defaults.
The partner's referralCode identifies the partner. Each partner also has alias, a short code such as KARL7Q: up to six letters of the name, a digit and a letter, without 0, O, 1 or I. It is unique in the company, never changes and matches in any case. Every call that takes a referral code accepts either one, and receipts and responses carry the long code. Partner summary programs return alias next to attributionCode. Your checkout applies a friend offer for that attribution code. Provider codes carry a separate discount in the connected merchant account:
const promo = await grizzlee.createPartnerPromoCode(
{ externalId: "creator-alex" },
{ programId: program.id, integrationId: stripeConnectionId, code: "ALEX20" },
);
const codes = await grizzlee.listPartnerPromoCodes({
externalId: "creator-alex",
});
const renamed = await grizzlee.renamePartnerPromoCode(
{ externalId: "creator-alex" },
promo.id,
{ code: "ALEX-NEW" },
);
await grizzlee.deactivatePartnerPromoCode(
{ externalId: "creator-alex" },
renamed.id,
);Reads require read; writes require partners:write. You can also address the partner as { id }. Enable tracking.providerPromoCodes, set a friend incentive and enroll the partner first. One active code is allowed per partner, program and provider connection. Names use 2 to 80 letters, digits or hyphens and normalize to uppercase. Deactivated names remain reserved. Repeating the same input resumes a pending operation or returns its completed code. Stripe renames create a replacement with the original discount and deactivate the old code. Shopify renames update the same discount and preserve its redemption history. Both keep the old name mapping for delayed orders. Program edits leave existing provider discounts and earned rewards intact.
Stripe needs Coupons write and Promotion Codes write, with Invoices and Checkout Sessions read to find applied codes. Shopify needs write_discounts and its existing order and customer reads. Missing access returns missing_provider_permission with the permission name. Unsupported rules return unsupported_promo_incentive.
Stripe supports a fixed or percentage discount once for a first-time transaction. Use orders: 1 and newCustomersOnly: true. Shopify supports one use per customer, including returning customers. Use orders: 1 and newCustomersOnly: false. Both support a minimum order amount. Both refuse a percentage ceiling, referral-relative expiry, category rules, refund-restored uses and multiple offer orders. See the integration guide for provider eligibility rules.
Partner summary programs expose tracking, attributionCode, promoCodes and qrLink. Provider codes include their saved incentive and program version. Provider orders append a type: "code" candidate with providerPromoCodeId; order webhook referralSelection.winner can carry that ID. The normal newest-valid and qualified-attribution rules apply. The webhook parser accepts the field through the core schema. Disabled methods do not create new attributions, and qualified referrals keep their partner.
TrackingMethods, ProviderPromoCode, ProviderPromoCodeInput and RenameProviderPromoCodeInput are exported from the server SDK. Provider promo operations require live mode; the local sandbox does not create discounts.
Placements
A placement maps a stable key in your code, such as customer_referral, to one program. Moving the key to another program changes which program your screens use without a deploy.
listPlacements(): Promise<Placement[]>;
getPlacement(key: string): Promise<Placement>;
setPlacement(key: string, input: SetPlacementInput): Promise<Placement>;
deletePlacement(key: string): Promise<Placement>;Placement is { key: string; programId: string; createdAt: string; updatedAt: string }. SetPlacementInput is { programId: string }. The server SDK exports both types.
Keys match ^[a-z][a-z0-9_]{1,62}$ and are unique per company. setPlacement creates the key or moves it to another program in one call, and never changes a program or its version. Setting the program a key already has is a no-op. deletePlacement removes the key and returns the mapping it had. The program must belong to the company. Reads require read; writes require programs:write. Unknown keys return 404 not_found, and an invalid program returns unknown_program.
Install
npm install @grizzlee/sdk
pnpm add @grizzlee/sdk
yarn add @grizzlee/sdkRequires Node >= 22. Every entry point supports ESM and CommonJS with matching TypeScript declarations. NestJS backends compiled to CommonJS can use @grizzlee/sdk/server with TypeScript moduleResolution: "node", also called node10. Next.js and Expo apps can import @grizzlee/sdk/tracking and @grizzlee/sdk/links as ESM.
Tracking and link imports contain no Node dependencies. The webhook verifier uses Node crypto. Zod supplies the schema types referenced by the declarations.
Versioning
The SDK follows semver. While the version is 0.x, a minor bump may include breaking changes. See CHANGELOG.md for release notes.
Capture in the app
const tracking = new GrizzleeTrackingClient({
baseUrl: "https://your-grizzlee-domain.example",
publishableKey: "grizzlee_pub_REPLACE_WITH_YOUR_KEY",
storage: window.localStorage,
});
await tracking.captureFromUrl(window.location.href);
const referral = await tracking.receipt(programId);
// Send referral?.receipt to your backend as part of checkout.Referral links carry the partner's short code as ?ref=KARL7Q. They add program=<id> only when the code and page do not identify the program, and QR links add channel=qr. createReferralLink({ destination, referralCode, programId?, channel? }) writes that form, and partner summary links already decide whether the program is needed. readReferralLink and captureFromUrl also read the grizzlee_ref, grizzlee_program and grizzlee_channel names of older links. A link without a program sends the page's origin and path as destination, and Grizzlee picks the partner's program whose signup page or destinationUrl leads there. A ref that cannot be a partner code, such as ?ref=producthunt, is not read as a referral, and captureFromUrl returns null when ref names no partner. capture takes the same optional programId and destination. ReferralLink.programId is optional.
Resolve a stable key when a screen needs a program, instead of saving a program ID in the app:
const placement = await tracking.resolvePlacement("customer_referral");
const referral = await tracking.receipt(placement.programId);resolvePlacement(key: string): Promise<PlacementResolution> returns { placement, programId, active, display }. display is the program's ProgramDisplay: its public name, headline and milestone label in each language, with fallbacks filled in. It uses the publishable key and the same browser origin and native-key checks as capture. An unknown key returns 404. Paused programs return active: false. Resolve when opening the screen to pick up a key that moved to another program.
capture, captureFromUrl, redeemInstallClaim and captureInstallReferrer return capture results with placements: string[], the keys currently mapped to that program. Typed-code and referral-claim responses include the same list, with [] when no placement maps to the program or a claim has no attribution. The exported CaptureReferralResponse describes a capture result. Capture, install claim, typed-code and incentive responses also carry the program's display, which is null on a typed code or incentive answer without a program. ReferralReceipt.placements stays optional because old stored receipts may lack it.
For a Stripe subscription checkout, send referral?.checkoutReference instead and put it in the Checkout Session's client_reference_id. A Stripe connection that records invoices claims it on the first paid invoice and pays the partner on every renewal the program covers. See Stripe subscriptions. Receipts an older SDK stored have no checkoutReference until the next capture.
Programs for subscriptions use duration: { type: "months", months: 12 }. Orders earn until the same calendar date 12 months after the customer's first rewarded order, in UTC.
For React Native, pass asynchronous storage with getItem, setItem and removeItem. Feed an incoming universal link or deep link to captureFromUrl. Enable native capture on its tracking key. No browser globals or URL polyfill are required.
The tracking client creates a random device ID on its first capture and keeps it in the same storage under grizzlee-device:<publishable key>. It sets no cookie. Each capture sends it, and Grizzlee keeps only keyed hashes of it and of the visitor's IP address for the fraud rules. Without crypto.randomUUID or working storage, captures go without it.
Installs from a link or QR code
A scan that ends in an app install reaches the app in one of three ways. The mobile guide has the full React Native and Expo setup.
Install claims need no third party. The landing page asks for one when it captures, and the app redeems it at first launch:
// Landing page
const captured = await web.capture({ ...referral, installClaim: true });
const playStore = createPlayStoreUrl({
packageName: "com.example.app",
installClaim: captured.installClaim!.code,
});
// On iOS, show captured.installClaim.code with a copy button.
// App, Android first launch
await tracking.captureInstallReferrer(installReferrerString);
// App, iOS: the person pastes or types the code
if (isInstallClaimCode(typed)) await tracking.redeemInstallClaim(typed);capture({ ..., installClaim: true }) adds installClaim: { code, expiresAt } to the response. The SDK never stores the code. redeemInstallClaim(code) exchanges a code for the receipt of that visit and stores it. A code works once for up to 7 days. Otherwise it throws 404 install_claim_unavailable. captureInstallReferrer(referrer) handles the Play Install Referrer string, redeeming grizzlee_claim or capturing grizzlee_ref and grizzlee_program. It remembers the string so the same referrer on later launches does nothing. It never replaces a receipt already stored for the same program, since anything the app captured is newer than the visit before the install.
With Branch or AppsFlyer, read their deferred deep link and capture it:
const referral =
readBranchReferral(branchEvent) ?? readAppsFlyerReferral(appsFlyerResult);
if (referral) await tracking.capture(referral);readBranchReferral(payload) reads grizzlee_ref, grizzlee_program and grizzlee_channel, or ref, program and channel, from the link data or the referring link. Without a page to match, a referral without a program resolves only for a partner with one linkable program. readAppsFlyerReferral(payload, { keys }) reads the same keys, a referral link in deep_link_value, or deep_link_sub1 (code), deep_link_sub2 (program) and deep_link_sub3 (channel). Both return the same ReferralLink shape as readReferralLink, or null.
Pure helpers: createPlayStoreUrl({ packageName, installClaim?, referral?, extra? }), readInstallReferrer(referrer), which returns { installClaim }, a ReferralLink or null, isInstallClaimCode(value), normalizeInstallClaimCode(value) and INSTALL_CLAIM_PARAM. Types: InstallClaim, InstallReferrer and AppsFlyerReferralKeys.
Claim a referral
claimReferral(input: {
candidates: ReferralCandidate[];
customerId: string;
customerDisplayName?: string;
orderId?: string;
expectedProgramId?: string;
}): Promise<ClaimReferralResult>;claimReferral calls POST /api/v1/referrals/claim with partners:write. Your server chooses customerId from its authenticated account. An only_order program needs orderId. Set expectedProgramId to restrict every candidate to your expected program. A mismatch is skipped with wrong_program. The result has attribution, or null when nothing wins, and referralSelection. Prefer completeOrder after confirming payment so the claim and order commit together.
Send every saved receipt and any typed code together:
const candidates: ReferralCandidate[] = [
...savedReceipts.map((receipt) => ({ type: "receipt" as const, receipt })),
...(typedCode
? [{ type: "code" as const, referralCode: typedCode, programId }]
: []),
];
const offer = await grizzlee.getReferralIncentive({ customerId, candidates });The newest valid referral wins while customer attribution is open, or for an only_order program. A qualified customer attribution keeps its partner, and candidates for that program are skipped with customer_already_referred. Receipt times come from signed capture data, including install claims. A code uses the server's checkout time by default, so it beats an older scan. Your backend can set enteredAt when saving a code with the order and reuse that timestamp on completion. At completion, an omitted code timestamp uses the order's occurredAt. A held order reuses the timestamp saved by its reservation. Future times are invalid. Equal times favor the later list entry. A code with several matching programs needs its own programId.
referralSelection.winner has programId, partnerId and occurredAt. A winning candidate also has the zero-based index and type. A retained attribution omits index and type. skipped has one entry for every other candidate with its index and reason. Reasons are expired, invalid, self_referral, wrong_program, program_inactive, partner_inactive, fraud, customer_already_referred and older. Selection reports do not echo receipt tokens or code values. Selection checks referral validity before checking the winning program's friend offer. A newer valid referral with no discount still wins.
An empty list or a list with no valid candidate returns no winner unless a qualified customer attribution is retained. Incentive responses have eligible: false, reason: "no_referral", and null programId, partnerId and terms. Checkout and payment continue. An explicit list chooses one winning program for that checkout. Omit candidates to use eligible recorded customer referrals across programs. Reservations and completed orders keep their original selection on retries. Completing an order with a saved candidate selection still needs partners:write, even when the request omits candidates.
Referral code at checkout
At checkout, send the typed code as a candidate alongside every saved receipt. Use attachReferralCode when you need a signed code receipt outside that checkout decision. The backend chooses the customer from authentication:
import type {
AttachReferralCodeInput,
AttachReferralCodeResponse,
} from "@grizzlee/sdk/server";
const input: AttachReferralCodeInput = {
referralCode: typedCode,
customerId: customer.id,
orderId: order.id,
};
const referral: AttachReferralCodeResponse =
await grizzlee.attachReferralCode(input);
// Store referral.receipt on the pending order.customerDisplayName is optional on attribute, claimReferral, attachReferralCode and the OrderEvent passed to completeOrder or sendEvent. Use a name you permit partners to see, such as Marie. Grizzlee trims it and accepts 1 to 80 characters. Empty or oversized names fail validation. Each accepted value replaces that referral's saved name; omission keeps it. Completed orders update customer-scoped referrals and the referral for that order. Order-only code attachments keep the name until the receipt is claimed, unless the claim supplies a newer name. Qualification and earned rewards do not change when a name changes.
The embedded dashboard and its JSON API never expose merchant customer or order IDs. Referrals show the display name or "Referred customer", translated for the session locale. Order rewards show date, amount and status. Merchant APIs retain real IDs and optional referral names. Display names are personal data. eraseCustomer removes them from referrals, orders, cached claims, fingerprints and webhook payloads, and later writes cannot restore them.
attachReferralCode(input: AttachReferralCodeInput): Promise<AttachReferralCodeResponse> calls POST /api/v1/referrals/code with partners:write. It requires live mode. Keep the private key on the merchant backend.
The input requires referralCode and customerId. Optional fields are programId, orderId, customerDisplayName and channel. The channel defaults to coupon and accepts link, qr, coupon and in_app. Without programId, Grizzlee selects the partner's single active enrolled referral program. Several candidates throw GrizzleeApiError with status 409, code ambiguous_program and details.programIds. Select a program and retry. No candidates return 404 with no_referral_program.
The response has receipt, programId, partnerId, partnerExternalId, expiresAt and duration. Customer programs also return attribution after attaching immediately. Qualification keeps the existing attribution's partner, which can differ from the code owner's top-level partner IDs. Order-only programs defer attachment until completeOrder. They can issue a receipt before you have an order ID. Every receipt binds to its customer and any supplied order ID.
Store the receipt with checkout and pass it to completeOrder after payment confirmation, just as you would a captured receipt. The backend supplies payment amounts from its own records. Repeating the same code, customer, resolved program and optional order returns the original response, even if the channel changes. It does not renew expiry or add history. Supply a new order ID for a later checkout that needs a fresh receipt. A checkout candidate list decides which receipt applies. Replaying code capture alone does not refresh or replace the saved referral.
Program updates
updateProgram(id, input) accepts ProgramUpdateInput, which is exported by the SDK. It keeps omitted currency, destinationUrl, delivery, friendIncentive, fixedRefundMode, trigger and orderCategories unchanged. Send destinationUrl: null to remove the destination, friendIncentive: null to remove the offer and orderCategories: null to remove the category rules. Explicit delivery replaces both modes, with omitted modes defaulting to "grizzlee_wallet". Other program fields retain their existing replacement behavior. createProgram accepts ProgramCreateInput, which is ProgramInput with an optional currency that defaults to the company currency. It defaults new programs to wallet delivery. destinationUrl is the HTTPS page partner links and QR codes open. A published signup page's destination takes precedence. The dashboard editor sends both delivery modes and the offer, or null to remove it.
Program versions
Every program has version and updatedAt. A new program is version 1. Each saved change adds 1, except a rename or a destinationUrl change, which only moves updatedAt. Pausing or resuming a program is a change. An update that changes nothing keeps the version. Each reward records programVersion, the version it was earned under, and each incentive reservation records the version its terms come from. Rewards and reservations made before versions existed have no programVersion. Their copied rules (reward.programSnapshot, reservation.terms) still say what applied.
const reward = event.data.reward;
if (reward.programVersion) {
const terms = await grizzlee.getProgramVersion(
reward.programId,
reward.programVersion,
);
console.log(terms.createdAt, terms.terms.text.reward.en);
}
const { versions } = await grizzlee.listProgramVersions(reward.programId);listProgramVersions(programId: string): Promise<ProgramVersionList>;
getProgramVersion(programId: string, version: number): Promise<ProgramVersionDetail>;Both need the read scope. ProgramVersionList is { versions: ProgramVersionDetail[] }, newest first. ProgramVersionDetail has programId, version, createdAt (when the version took effect), programSnapshot (every rule as saved, as ProgramInput), changedFields (top-level fields that differ from the previous version, empty for version 1) and terms (ProgramRewardTerms, with readable terms.text). Versions never change. An unknown program or version throws GrizzleeApiError with status 404 and code not_found. Programs that existed before versions start at version 1 with their rules at that time, dated at their last saved change.
Delete a program
const { result } = await grizzlee.deleteProgram(programId);
// "deleted" or "archived"deleteProgram(id) needs programs:write and returns ProgramDeletion, which is { id, result }. A program with no referrals, rewards, clicks, signup page, discount codes or other history is removed with its versions, placement keys and enrollments, and result is "deleted". A program with history is archived instead, and result is "archived". An archived program is paused, has archivedAt, loses its placement keys and keeps its referrals, rewards, versions and enrollments. updateProgram and setPlacement reject it with 409 program_archived. Deleting an archived program again changes nothing and returns "archived". An unknown program throws 404 not_found.
Friend incentives
A program's friendIncentive is what a referred customer gets, such as 20% off their first order. Your checkout computes and pays for the discount. Grizzlee checks eligibility, counts uses and records what you applied.
Partners can earn discounts too. Set rewardType: "offer" and partnerOffer on an order program. Each qualifying referred order earns an offer with copied terms and expiry. Link the partner's own merchant customerIds so the same lookup, reserve and completion calls can redeem it. An eligible friend offer wins; otherwise Grizzlee selects the earned offer that expires soonest. Responses identify it with incentiveKind: "earned" and rewardId. terms use the OfferTerms union. Upgrade to SDK 0.22.0 before enabling offer programs, because older webhook parsers reject offer rewards. Earned offers apply through API checkouts and do not sync to Stripe or Shopify promo codes.
The app can show the offer from the captured receipt. ReferralReceipt.incentive is an IncentiveOffer or null. It says nothing about whether this customer qualifies, so never apply a discount from it in the app.
Show the offer at quote time with getReferralIncentive. It holds nothing. Reserve on your backend when the customer places the order:
import {
type IncentiveReservationResult,
type ReserveIncentiveInput,
} from "@grizzlee/sdk/server";
const input: ReserveIncentiveInput = {
customerId: customer.id,
orderId: order.id,
newCustomer: order.newCustomer, // your server checks its own order history
candidates: order.candidates,
baseCents: order.subtotalCents,
};
const held: IncentiveReservationResult = await grizzlee.reserveIncentive(input);
const discountCents = held.eligible ? (held.suggestedDiscountCents ?? 0) : 0;reserveIncentive calls POST /api/v1/referrals/incentive/reservations with events:write. customerId and orderId are required. Send candidates with every saved receipt and any typed code. Omit the list to use the customer's recorded referral. programId restricts the list or recorded lookup to that program. Optional baseCents adds suggestedDiscountCents when eligible.
The result has reservationId, status, orderId, eligible, optional reason, programId, partnerId, terms, optional programVersion and suggestedDiscountCents, and customerOrderNumber. Nothing is held when ineligible, so reservationId, status and orderId are null. The hold copies the terms and program version. Later edits do not change it. It lasts until the order completes or your server releases it, with no expiry or maximum duration.
Repeating orderId returns the same hold. A consumed or released hold returns eligible: false and no suggested discount. If another order took the remaining use between quote and booking, reserve returns eligible: false, with reason reservation_open, uses_exhausted or order_limit_reached. Place the order without the discount or ask the customer to review the price. If the call fails or times out, retry with the same order ID before applying a discount.
Only the reserved order consumes the hold. completeOrder can omit reservationId, because Grizzlee finds it by order ID. Another order that supplies the ID gets flag reservation_locked and leaves the hold open. Completing with zero discount releases the order's hold.
releaseIncentive(reservationId) frees an open hold when its order is cancelled. Repeating release is safe. A consumed hold returns 409 reservation_consumed.
listIncentiveReservations({ customerId?, olderThanDays? }) lists up to 100 reservations with read. Omit both fields to list the company's latest reservations. olderThanDays limits the response to open holds created more than that many days ago. It accepts integers from 0 to 100,000. Zero lists all open holds. Add customerId to limit either list to one customer. Grizzlee never releases a hold because of its age.
getReferralIncentive(input) is a read-only preview with read. Its customerOrderNumber input is ignored, and the response uses Grizzlee's number. A preview can become ineligible before the order is placed.
newCustomer?: boolean is accepted by getReferralIncentive, reserveIncentive and OrderEvent. Your server checks its own history, including placed but unpaid orders, without counting the current order. Save that answer with the checkout and reuse it after payment. false blocks a newCustomersOnly friend offer. true applies the offer's order limit after the referral. Other terms still apply. Omitting the field uses Grizzlee's recorded purchase history.
A reservation keeps newCustomer on IncentiveReservation. A replay with a different supplied value returns 409 order_conflict. Omit the field on a reservation replay to keep the saved answer. Send the saved answer on the completed order too. Order events and order.completed webhooks keep their supplied value. Changing it on an order replay returns event_conflict or order_conflict.
With newCustomer on an order, first_order and first_n_orders count completed purchases after the referral. false still allows partner rewards, because these durations limit referred purchases rather than customer newness. Skipped and refunded purchases still count. Without the field, durations keep using the company purchase number. Report every purchase covered by a referral, including purchases excluded by reward categories, to keep the duration count complete.
To offer 20% off a new customer's first order, set friendIncentive to { type: "percent", rateBps: 2000, orders: 1, newCustomersOnly: true }. Reserve it with the order subtotal before the discount as baseCents. For a $100 subtotal, baseCents: 10000 gives suggestedDiscountCents: 2000 when the customer qualifies.
computeIncentiveCents(terms: IncentivePricingTerms, baseCents: number): number uses integer math. A percentage rounds half up and stops at maxDiscountCents. A fixed amount never exceeds baseCents. A base below minOrderCents gets 0. It throws RangeError for a base that is not a whole number of minor units. @grizzlee/sdk/tracking exports it too, and it runs in browsers. It does not check eligibility. suggestedDiscountCents uses the same math.
Report the discount you applied as incentiveAppliedCents and pass the reservation:
const result = await grizzlee.completeOrder(
{ ...orderEvent, incentiveAppliedCents: discountCents },
{
candidates: order.candidates,
reservationId: held.reservationId ?? undefined,
},
);Completion never fails over the incentive, because your checkout already charged the customer. Grizzlee consumes the reservation, records the order and its rewards, and stores incentiveAppliedCents as you sent it. When the amount is above the reserved terms, the order's categories fail the terms' orderCategories, or the reservation is missing, released, already used or reserved for another order, the result and the order get incentiveFlag (IncentiveFlag): reason (IncentiveFlagReason), programId?, reservationId?, maxCents (number | null) and reportedCents. The use still counts. The order.completed webhook carries the flag, and an incentive.flagged webhook follows it. Retries of an accepted order record no second use. A full refund gives the use back only when the terms have returnOnFullRefund: true. The sandbox returns 503 live_required for reservations and for a discounted order.
Types: FriendIncentive (stored terms), FriendIncentiveInput (terms with optional defaults), IncentiveOffer (terms plus currency), IncentivePricingTerms, ReserveIncentiveInput, IncentiveReservationResult, IncentiveReservation, IncentiveReservationStatus, ReferralIncentiveInput, ReferralIncentive, IncentiveIneligibleReason, IncentiveFlag, IncentiveFlagReason, OrderCategoryRules, OrderCategoryRulesInput, CustomerImportInput and CaptureReferralResponse.
Self-referral checks
Grizzlee compares the customer's ID against the partner's customerIds and follows fraudChecks.selfReferral.action. externalId never counts as a customer link. Send identity signals with the partner and referred customer to catch shared identities across accounts. Every field is optional:
import type { IdentitySignals } from "@grizzlee/sdk/server";
const signals: IdentitySignals = {
email: user.email,
phone: user.phoneE164,
paymentFingerprint: card.fingerprint,
deviceId: install.id,
ipAddress: request.ip,
};
await grizzlee.upsertPartner({ ...partner, signals });
await grizzlee.reserveIncentive({ customerId, orderId, candidates, signals });
await grizzlee.completeOrder(orderEvent, { candidates, signals });signals is accepted by upsertPartner, registerPartner, updatePartner, updatePartnerByExternalId, attachReferralCode, getReferralIncentive, reserveIncentive and completeOrder. An update can send signals alone. phoneHash, the lowercase hex SHA-256 of the E.164 number, can replace phone. Grizzlee normalizes each value, keeps only an HMAC hash keyed per company and never returns signals. Email is lowercased, plus tags are dropped, and Gmail dots are ignored. A phone needs its country code. IPv6 addresses compare by their /64 network. A phone, email or IP address that cannot be normalized is ignored, not rejected. The order's customerEmail and the partner's email count as email signals too. getReferralIncentive compares the signals and stores nothing.
A partner's side is their own signals plus those sent for every linked customerIds value. The customer's side is everything sent for their customer ID. Captures add the visitor's IP address and the tracking client's device ID to the customer whose order or claim uses the receipt. Joining from a program page and opening the partner portal add the partner's IP address. Email, phone, card and device are strong signals. An IP address is weak: it counts only when seen in the last 30 days, and a shared IP alone never blocks.
Set fraudChecks.selfReferral.action on the program:
block, the default: a linked customer ID or strong signal match blocks the partner's reward and the friend incentive. An IP-only match isflagged: the friend stays eligible, the reward followsreviewRewardsas usual and records the match.review: any match holds the reward aspendinguntil someone approves or rejects it. Automatic approval skips it.flag: record the match and follow the program's usual reward approval.off: disable all self-referral checks, including customer links.
Checkout skips blocked candidates with reason self_referral. If none wins, incentive checks return no_referral. Recorded referral checks still return self_referral when blocked. attachReferralCode throws GrizzleeApiError with status 400, code self_referral and details.signals. These responses, order decisions and rewards carry selfReferral (SelfReferralMatch): action ("blocked" | "review" | "flagged") and signals, the matching types ("customer_id" | "email" | "phone" | "card" | "device" | "ip"). completeOrder enforces the same rule and skips the reward when it blocks. It does not take back a discount you already applied.
rejectReward(id: string, input?: RejectRewardInput): Promise<Reward> rejects a pending reward with an optional reason. It needs rewards:approve, reverses the reward for good with a rejection ledger entry and sends reward.reversed. approveReward approves a held reward. The sandbox ignores signals.
Fraud checks
Fraud checks look for patterns across customers, using the same hashed signals. Each program sets fraudChecks (FraudChecksInput). Omitted rules and settings, and programs without the field, use these defaults:
await grizzlee.updateProgram(programId, {
...programInput, // the program's full ProgramInput, as for any update
fraudChecks: {
selfReferral: { action: "block" },
// Several customers this partner referred share a card or device.
duplicateAccounts: {
action: "flag",
accounts: 3,
windowDays: 30,
ipAccounts: null,
},
// Several referrals within minutes from one device.
rapidRepeats: {
action: "flag",
referrals: 3,
windowMinutes: 60,
ipReferrals: null,
},
// Shares a phone, card or device with another customer already rewarded in this program.
repeatCustomer: { action: "flag" },
// The customer's email uses a disposable domain. Cannot block.
disposableEmail: { action: "flag" },
},
});action is "block" | "review" | "flag" | "off" and defaults to "flag", so nothing is held or blocked until you opt in per rule. review holds the reward as pending until approveReward or rejectReward, and automatic approval skips it. flag records the rule and lets the reward follow reviewRewards. block stops the reward and the friend incentive, but only for a phone, card or device match: a rule that fired on an IP address alone is flagged. IP thresholds default to null, which leaves IP addresses out, because every customer on a store's Wi-Fi shares one address. Turn them on only for online referrals.
Checkout skips candidates blocked by fraud with reason fraud. If none wins, incentive checks return no_referral. Recorded referral checks still return fraud when blocked. attachReferralCode does not run fraud rules. Responses, order decisions and rewards carry fraud (FraudMatch): action ("blocked" | "review" | "flagged") and rules, each a FraudRuleMatch with rule (FraudRuleId), its own action, signals and, for the velocity rules, count. They never contain signal values. fraudRuleIds lists the rule IDs. Thresholds run from 2 to FRAUD_THRESHOLD_MAX (200). The customer being checked counts toward a threshold only when the partner referred them inside the rule's window.
getCompany({ rewardStatus: "review" }) lists rewards a self-referral check or fraud rule recorded, and report.heldRewards counts the ones waiting for a person.
Confirm on the backend
Send newCustomer from your own history on each referred order. You then need no history import or reports of unrelated orders. Without the field, report every completed order, referred or not, and import earlier purchases so Grizzlee can determine who is new. The backend reads payment status, customer identity and fee from its own records. Store the receipt with the pending checkout before payment confirmation so background processing can retry it.
const grizzlee = new GrizzleeServerClient({
baseUrl: process.env.GRIZZLEE_ORIGIN!,
apiKey: process.env.GRIZZLEE_SERVER_KEY!,
});
await grizzlee.completeOrder(
{
type: "order.completed",
eventId: payment.id,
orderId: order.id,
customerId: order.customerId,
currency: "CAD",
commissionableCents: payment.earnedFeeCents,
occurredAt: payment.confirmedAt,
},
{ candidates: order.candidates },
);completeOrder atomically claims the receipt, consumes an optional reservationId, or the open hold for the order, and records payment. Its private key needs events:write and, when a receipt is supplied, partners:write. The result's customerOrderNumber is Grizzlee's purchase number for the order. customerOrderNumber in the event is optional. A number that disagrees is stored as reportedCustomerOrderNumber and does not change rules.
If you omit newCustomer, existing customers look new until you import their earlier purchases. Before you start reporting orders in that mode, send them with importCustomers({ customers: [{ customerId, priorOrderCount }] }). It calls POST /api/v1/customers/import with events:write, takes up to 1,000 customers per call and is safe to repeat. Retry with the same event ID and financial payload. A changed payload returns 409. There are no automatic payment retries in the SDK.
Every order requires commissionableCents. The merchant's backend chooses the amount that percentage rewards use, such as merchandise after discounts or a marketplace fee. Optional totalCents is what the customer paid, used only for reporting. All percentage rules use the same commissionable amount, including phases, tier bonuses and custom terms.
Order categories
Send the order's categories, such as its product or service types, as categories: string[] on the event. Slugs are 1 to 40 letters, digits, hyphens or underscores. Grizzlee lowercases them, drops repeats and sorts them. An order takes up to MAX_ORDER_CATEGORIES (20).
await grizzlee.completeOrder({ ...orderEvent, categories: ["standard"] });A program's orderCategories (OrderCategoryRules, { include?: string[]; exclude?: string[] }, each list 1 to MAX_CATEGORY_RULES (50) slugs) decides which orders earn rewards. With include, the order needs at least one of those categories, and an order without categories does not qualify. Any exclude category rules the order out, even next to an included one. A failing order earns nothing in that program, and its decision is skipped with a reason naming the category. It still counts as a purchase, so it takes a purchase number and counts against first-order rules. Percentage rewards still use the whole order's amount. Milestone programs reject orderCategories. Changing the rules adds a program version. simulate takes categories on its event too.
friendIncentive.orderCategories works the same way for the friend's discount, apart from the program's rules. getReferralIncentive and reserveIncentive take categories and answer eligible: false with reason category_not_eligible when the categories fail the offer's rules. Without categories, a lookup or new reservation has none, and a reservation replay uses the ones the hold was made with. An empty list always means none. A failing replay keeps the hold open. At completion Grizzlee checks the order's own categories against the reserved terms and flags a discounted order that fails them category_not_eligible. orderCategoryMismatch(rules, categories) runs the same check in your code, trimming and lowercasing both sides with normalizeOrderCategory. It returns { reason: "excluded", category }, { reason: "not_included" } or undefined.
For a $100 order with a $10 discount and $5 in shipping and tax, send commissionableCents: 9000 and totalCents: 9500. A 10% commission earns $9.
Programs no longer select a commission base. The server entry point exports Program, Reward, RefundEvent and OrderAdjustmentEvent. Each reward saves the original commissionable amount in Reward.basisCents.
Partial percentage refunds and restorations use cumulative refundedCommissionableCents. Include refundedTotalCents for reporting when the order had totalCents. A missing required commissionable refund amount returns HTTP 400 with code missing_refund_commission_base. Historical rewards earned under a subtotal program carry Reward.historicalBasis: "total". They keep their reporting total as the basis for later refunds and adjustments, and use refundedTotalCents. fullRefund: true reverses the entire reward without cumulative fields. Only that flag marks the order fully refunded.
ProgramInput and ProgramUpdateInput accept optional fixedRefundMode?: FixedRefundMode. FixedRefundMode is "prorate" | "full_refund_only". New programs default to "prorate", which reduces fixed rewards in proportion to refundedCommissionableCents / commissionableCents. "full_refund_only" keeps fixed rewards until an event sends fullRefund: true. Later lower refund totals restore rewards. Reversals of delivered rewards create partner debt.
Each fixed reward saves the mode in Reward.calculation.fixedRefundMode. Later program edits cannot change it. Older rewards without a saved mode use "prorate". The mode applies to fixed custom terms too. Percentage rewards keep their saved commissionable basis. getPartnerSummary includes the program's current fixedRefundMode in each programs entry.
For an only_order program, one receipt can bind to one order. A $20 earned fee at 500 basis points earns $1.00. Later purchases need a new referral. After the backend accepts the checkout, call tracking.forgetAfterOrder(programId, usedReceipt) in the app. It retains receipts for customer referral programs and retains a different receipt captured during checkout.
If a refund arrives before its completion, GrizzleeApiError has status 409 and code order_not_completed. Send the completed order, then retry the unchanged refund with the same event ID. Grizzlee does not record the rejected refund event ID or change the ledger. The conflict status identifies a prerequisite that the merchant can resolve before retrying.
Send refunds with grizzlee.sendEvent. Use cumulative successful refund amounts and the original order ID. Errors have status and code fields. Only local development accepts HTTP on loopback. Production URLs require HTTPS.
A lower cumulative refund total restores the reward. When a refund fails or you reverse it, send the new, lower total:
// $50.00 of a $100.00 order was refunded, then $30.00 of that refund failed.
const result = await grizzlee.sendEvent({
type: "order.refunded",
eventId: `${refund.id}:failed`,
orderId: order.id,
refundedCommissionableCents: 2_000,
refundedTotalCents: 2_000,
occurredAt: refund.failedAt,
});
// result.restoredCents is the reward amount given back, 300 for a 10% reward.Grizzlee recalculates each reward from the rules it was earned under and appends a restoration ledger entry. A reward reversed by a full refund returns to pending or approved, whichever it was before. Refund events apply in occurredAt order. An event older than the latest refund already applied to that order is ignored and returns stale: true. A newer event applies even when its total is lower. Each restoration sends the reward.restored webhook with reward, restoredCents and the reward ledger fields described below.
Adjust a completed order
When an order's amount changes after completion, such as extra charges added at delivery or a price correction, call adjustOrder with the order's new totals. It sends an order.adjusted event to POST /events, so sendEvent works too.
const result = await grizzlee.adjustOrder({
eventId: `${charge.id}:adjusted`,
orderId: order.id,
commissionableCents: 15_000, // the new total, not the extra charge
totalCents: 15_000,
reason: "Extra charges at delivery",
occurredAt: charge.createdAt,
});
// result.increasedCents is the reward value added, 500 for a 10% reward on a $100.00 order.adjustOrder(input: Omit<OrderAdjustmentEvent, "type">): Promise<EventResult> takes eventId, orderId, commissionableCents?, totalCents?, reason? and occurredAt. Send at least one total, and only totals the order completed with. Use a new event ID for each adjustment and reuse it on retries. Grizzlee recalculates each reward from the rules it was earned under. Percentage rewards follow their saved commissionable basis. An increase in a reward's value after refunds stops at the customer and partner caps saved with it. Fixed rewards keep their amount. Rejected rewards stay reversed. An adjustment never creates a reward or counts as a new order, and the friend incentive does not change.
Each changed reward gets an adjustment ledger entry with the signed difference and sends reward.adjusted, and the order sends order.adjusted. Reward.originalCents keeps the first earning. Reward.adjustedCents is the signed total of adjustments, so a reward is worth originalCents + (adjustedCents ?? 0) - reversedCents. A lower total on a reward you already delivered records partner debt, the same way a refund does. The result has increasedCents and reversalCents.
Refunds after an adjustment measure against the adjusted totals. A total below what was already refunded throws GrizzleeApiError with status 400 and code invalid_adjustment. Only fullRefund: true marks an order fully refunded. Reaching the refunded amount in an adjustment does not. Adjusting a fully refunded order throws 409 order_fully_refunded. An unknown order throws 409 order_not_completed: send the completion and retry with the same event ID. Adjustments apply in occurredAt order, and an older one returns stale: true and changes nothing. The server entry point exports OrderAdjustmentEvent and OrderAdjustment, the record each applied adjustment adds to Order.adjustments.
Report milestones
A milestone program can pay for each occurrence, once at a count, or both. Set trigger: { type: "milestone", milestone: "task_completed" } and use explicit milestoneRewards. Omit order-only compensation and duration. Set attributionWindowDays to a number of days or null for referrals that never expire. Record the referral with a receipt, a code or attribute without an orderId.
For 5% of the first 10 tasks plus $50 at the 10th, use:
milestoneRewards: {
eachReport: {
compensation: { type: "percentage", rateBps: 500 },
limit: 10,
},
atCount: { count: 10, amountCents: 5000 },
}Use either rule alone or both. eachReport.compensation supports a fixed amountCents or percentage rateBps, without rate phases. Omit limit for no limit. Limits and bonus counts accept 1 to 10,000. Set the bonus count to 1 for a milestone your system counts itself. Custom partner and customer terms replace both rules with their own milestoneRewards.
Percentage report rewards require valueCents in the program currency. Without it, the report still counts and can earn a fixed reward or bonus, but its percentage rule returns a skipped decision. Zero is valid and earns no percentage reward. Percentages use integer arithmetic and round half up to the nearest cent. The each-report reward uses shared caps before the bonus. Retries must keep the same value.
Report each occurrence from your server, with an event ID tied to it:
const result = await grizzlee.reportMilestone({
eventId: `${task.id}:completed`,
customerId: recruit.id,
milestone: "task_completed",
valueCents: task.valueCents,
occurredAt: task.completedAt,
});
// result.count is the person's reports that are not reversed. Each decision has
// milestoneCount and milestoneThreshold, such as 7 and 10, for programs that reached the count check.
// result.rewardIds lists the rewards this report created.A program counts the person's reports from their referral in it, so a report from before the referral does not count for it. When the referral window is a number of days, the bonus count must be reached inside it, judged by the reports' occurredAt in time order rather than arrival order. The report that reaches milestoneRewards.atCount.count pays the bonus once per person. Each-report rules pay every eligible report. Their limit uses the current unreversed count since the referral, in arrival order. Late reports never move another report's reward. Reversals reduce the count used by the limit. A later report can pay if that count permits. Earlier unpaid reports are not backfilled. Retries with the same event ID and body return the first result with duplicate: true. A changed body throws GrizzleeApiError with status 409 and code event_conflict. Event IDs share one namespace with orders and refunds.
Changing count creates a new program version. Earned rewards keep the count they were earned with. Everyone else is measured against the new count from their next report, so lowering it pays people already past it on their next report, not at once.
When one occurrence no longer counts, for example a task cancelled after you reported it, reverse that report:
const reversed = await grizzlee.reverseMilestoneReport({
eventId: `${task.id}:cancelled`,
reportEventId: `${task.id}:completed`,
reason: "Task cancelled", // optional
});
// reversed.status is "reversed". reversed.reversalCents is the reward value this call reversed.The reversed report's each-report reward is reversed in full. A bonus whose count falls below the count it was earned with is reversed with a reversal ledger entry and sends reward.reversed. That happens after payout too, as with refunds: a reward you already delivered becomes partner debt. A later report that brings the count back restores the bonus and sends reward.restored, and a delivered one first reduces the partner's debt. A report can be reversed once, and a reversal cannot be undone. Report the occurrence again under a new event ID instead. An unknown reportEventId throws GrizzleeApiError with status 404.
When the milestone no longer counts for a person at all, for example after fraud, revoke it:
const revoked = await grizzlee.revokeMilestone({
// One event ID per revocation. A milestone can be revoked again after a reinstatement.
eventId: `${recruit.id}:recruit_10_tasks:revoked:${dispute.id}`,
customerId: recruit.id,
milestone: "recruit_10_tasks",
reason: "Two tasks were cancelled",
});
// revoked.reversalCents is the reward value this call reversed.A revocation reverses every reward for that person and milestone, like a full refund, and sends reward.reversed. A reward you already delivered becomes partner debt. While the milestone is revoked it pays nothing. A report still counts, but returns status: "revoked" with no rewards and no error.
To undo a revocation, for example after you win a chargeback dispute or lift a suspension, reinstate the milestone:
const reinstated = await grizzlee.reinstateMilestone({
eventId: `${recruit.id}:recruit_10_tasks:reinstated:${dispute.id}`,
customerId: recruit.id,
milestone: "recruit_10_tasks",
reason: "Chargeback won", // optional
});
// reinstated.restoredCents is the reward value this call gave back.Eligible rewards the revocation reversed come back with a restoration ledger entry and returns to pending or approved, and each sends reward.restored. A delivered reward first reduces the partner's debt and sends reward.debt_settled with kind: "restored". Rejected rewards and rewards from reversed reports stay reversed. A bonus stays reversed while its count is below the count it was earned with, until reports bring it back. The milestone counts again, so you can revoke it again later. A reinstatement does not create rewards the revocation prevented. Report the milestone again under a new event ID for those; the bonus still pays at most once per person, while each-report rewards can continue within their limit. Reinstating a milestone that is not revoked changes nothing and returns restoredCents: 0. Use a new event ID for every revocation and reinstatement, because a reused one returns the earlier result with duplicate: true.
reportMilestone(input: ReportMilestoneInput): Promise<MilestoneResult>;
revokeMilestone(input: RevokeMilestoneInput): Promise<MilestoneResult>;
reinstateMilestone(input: ReinstateMilestoneInput): Promise<MilestoneResult>;
reverseMilestoneReport(
input: ReverseMilestoneReportInput,
): Promise<MilestoneResult>;ReportMilestoneInput is { eventId: string; customerId: string; milestone: string; valueCents?: number; occurredAt: string }. RevokeMilestoneInput is { eventId: string; customerId: string; milestone: string; reason: string }. ReinstateMilestoneInput is { eventId: string; customerId: string; milestone: string; reason?: string }. ReverseMilestoneReportInput is { eventId: string; reportEventId: string; reason?: string }. MilestoneResult is { eventId, customerId, milestone, duplicate, status, decisions, rewardIds, reversalCents, restoredCents?, count?, reportEventId? }, where status is MilestoneStatus, "recorded" | "revoked" | "reinstated" | "reversed". restoredCents is set on reports and reinstatements, count on every result saved since counting started, and reportEventId on reversals. All four calls need events:write. The server entry point also exports ProgramTrigger, which is { type: "order" } | { type: "milestone"; milestone: string }.
Milestone rewards have milestone and no orderId. Each-report rewards also have reportEventId and optional valueCents. Both rules share review, delivery, caps, reversals and debt. Rewards keep their original settings and ledger earnings when the program changes. They carry milestoneCount, the reports that count for the reward's program now, and milestoneRevoked, true while the milestone is revoked for the person. They use the same webhooks and partner reads as order rewards. A rewarded milestone report counts once toward software usage, like a rewarded order.
Deliver rewards yourself
Grizzlee decides what a partner earned. A program can let your own system deliver it instead of the Grizzlee wallet. Set delivery on the program, one mode for each reward type:
await grizzlee.createProgram({
...rules, // name, compensation, duration and the other program fields
rewardType: "cash",
delivery: { cash: "merchant", credit: "grizzlee_wallet" },
});DeliveryMode is "grizzlee_wallet" | "merchant". Both default to "grizzlee_wallet". Merchant rewards never reach the Grizzlee wallet, so getWallets, payouts and redeemCredits cannot spend them. Each reward keeps the mode its program had when it was earned in reward.deliveryMode, so changing the program affects only rewards earned afterwards.
After your payout run or credit ledger pays an approved reward, confirm it. The key needs the rewards:deliver scope.
const delivery = await grizzlee.deliverReward(reward.id, {
externalReference: transfer.id,
deliveredAt: transfer.createdAt,
});
await grizzlee.failRewardDelivery(reward.id, {
reason: "Connected account is restricted",
externalReference: "attempt-1",
});
const history = await grizzlee.listRewardDeliveries(reward.id);Retry with the same externalReference to get the original record back. A new reference delivers the amount still owed. This includes restored credit after debt recovery or netting. When nothing is owed, a new reference throws GrizzleeApiError with status 409 and code delivery_conflict. A wallet reward throws 409 wallet_delivery. A pending or fully reversed reward throws 409 reward_not_approved. One transfer ID can cover several rewards. Each new record sends a reward.delivered or reward.delivery_failed webhook with reward, delivery and the reward ledger fields described below.
A refund that reverses part of a delivered reward records a delivery debt against the partner, in that currency and reward type. listRewardDeliveries returns it in debts, with outstandingCents and its settlements. Settle it by keeping part of a later delivery, or by recording a recovery:
await grizzlee.deliverReward(nextReward.id, {
externalReference: nextTransfer.id,
nettedDebtCents: 300,
});
await grizzlee.recoverPartnerDebt(partner.externalId, debt.id, {
externalReference: refund.id,
});recoverPartnerDebt recovers the whole outstanding amount unless you pass amountCents. Cash debt that an open merchant payout nets is held for that payout, so recovering or netting it elsewhere throws 409 debt_in_payout until the payout is paid or cancelled. A lower cumulative refund total reduces the reward's outstanding debt before anything else. Delivery records, debts and settlements are never edited or deleted.
These methods and types are exported from @grizzlee/sdk/server:
deliverReward(rewardId: string, input: DeliverRewardInput): Promise<DeliverRewardResponse>;
failRewardDelivery(rewardId: string, input: FailRewardDeliveryInput): Promise<FailRewardDeliveryResponse>;
listRewardDeliveries(rewardId: string): Promise<ListRewardDeliveriesResponse>;
recoverPartnerDebt(
externalId: string,
debtId: string,
input: RecoverPartnerDebtInput,
): Promise<RecoverPartnerDebtResponse>;DeliverRewardInput is { externalReference: string; deliveredAt?: string; nettedDebtCents?: number }. FailRewardDeliveryInput is { reason: string; externalReference: string }. This is a breaking change. Failure requests without a reference now return 400. Reuse the reference on retries to receive the original failed record. A changed reason for that reference returns 409 idempotency_conflict. RecoverPartnerDebtInput is `{ ex
