Guides
Merchant API
Server endpoints use /api/v1 and Authorization: Bearer <server-key>. The key selects the company. A body cannot supply or override a company ID. Send JSON with Content-Type: application/json.
Success responses contain { "data": ... }. Errors contain { "error": { "code": "...", "message": "..." } }.
| Method | Path | Required scope |
|---|---|---|
| GET | /company | read |
| POST | /programs | programs:write |
| PUT | /programs/:id | programs:write |
| DELETE | /programs/:id | programs:write |
| GET | /programs/:id/versions | read |
| GET | /programs/:id/versions/:version | read |
| GET | /placements | read |
| GET | /placements/:key | read |
| PUT | /placements/:key | programs:write |
| DELETE | /placements/:key | programs:write |
| POST | /partners | partners:write |
| PUT | /partners/:id | partners:write |
| GET | /partners/by-external-id/:externalId | read |
| PUT | /partners/by-external-id/:externalId | partners:write |
| POST | /attributions | partners:write |
| POST | /referrals/code | partners:write |
| POST | /referrals/claim | partners:write |
| POST | /referrals/incentive | read |
| POST | /referrals/incentive/reservations | events:write |
| POST | /referrals/incentive/reservations/:id/release | events:write |
| GET | /referrals/incentive/reservations?customerId= | read |
| POST | /customers/import | events:write |
| POST | /orders/complete | events:write, plus partners:write when claiming candidates |
| POST | /events | events:write |
| POST | /milestones | events:write |
| POST | /milestones/revoke | events:write |
| POST | /milestones/reinstate | events:write |
| POST | /milestones/reverse | events:write |
| POST | /simulate | events:write |
| POST | /rewards/:id/approve | rewards:approve |
| POST | /rewards/:id/reject | rewards:approve |
| GET | /partners/:id/wallets | read |
| POST | /credits/redeem | credits:write |
| POST | /credits/:id/restore | credits:write |
| POST | /rewards/:id/deliveries | rewards:deliver |
| GET | /rewards/:id/deliveries | rewards:deliver |
| POST | /partners/by-external-id/:externalId/credit/deliveries | rewards:deliver |
| POST | /partners/by-external-id/:externalId/debts/recover | rewards:deliver |
| POST | /partners/by-external-id/:externalId/debts/:debtId/recover | rewards:deliver |
| POST | /partners/:id/payouts | payouts:write |
| POST | /partners/by-external-id/:externalId/payouts | payouts:write |
| GET | /partners/:id/payouts | read or payouts:write |
| GET | /partners/by-external-id/:externalId/payouts | read or payouts:write |
| GET | /partners/:id/summary | read |
| GET | /partners/by-external-id/:externalId/summary | read |
| GET | /partners/:id/referrals | read |
| GET | /partners/by-external-id/:externalId/referrals | read |
| GET | /partners/:id/rewards | read |
| GET | /partners/by-external-id/:externalId/rewards | read |
| GET | /payouts/:id | payouts:write |
| POST | /payouts/:id/paid | payouts:write |
| POST | /payouts/:id/cancel | payouts:write |
| GET | /payout-schedule | read or payouts:write |
| PUT | /payout-schedule | payouts:write |
| POST | /embed/sessions | embed:write |
| DELETE | /embed/sessions/:id | embed:write |
| GET | /webhooks | read or settings:write |
| POST | /webhooks | settings:write |
| PUT | /webhooks/:id | settings:write |
| DELETE | /webhooks/:id | settings:write |
| POST | /partners/:id/erase | partners:write |
| POST | /partners/by-external-id/:externalId/erase | partners:write |
| POST | /customers/:customerId/erase | partners:write |
Placements
A placement maps a stable key in your code, such as customer_referral, to one program. Keys match ^[a-z][a-z0-9_]{1,62}$ and are unique within the company. Several keys can point at the same program.
PUT /placements/:key with { programId } creates the key or moves it to another program in one step. It returns { key, programId, createdAt, updatedAt }. Sending the program the key already has changes nothing. Moving a key leaves both programs and their versions unchanged. DELETE /placements/:key removes the key and returns the mapping it had. GET /placements returns Placement[], sorted by key, and GET /placements/:key returns one. Success responses wrap these values in { data }. Writes enter the company audit log.
Unknown keys return 404 not_found. A program outside the company returns unknown_program. A key may point to a paused program. Embed session creation resolves the key's current program and enrolls the partner when needed. A move changes new sessions and new enrollments. Existing sessions and enrollments keep their programs.
GET /api/tracking/:key/placements/:placementKey uses a publishable tracking key in :key. It returns { data: { placement, programId, active, display } } and nothing else. display is the program's public name, headline and milestone label. Unknown placement keys return 404. It uses the same browser origin and native-key checks as capture. Browser preflights use OPTIONS on the same path.
Capture, install claim redemption and typed code responses include placements: string[], the keys currently mapped to the captured program. Referral claims also return placements for the claimed attribution's program, or [] when no attribution exists. The list is empty when no placement maps to the program. Typed-code retries return the current mappings along with their original receipt.
Capture and install claim redemption responses also include the program's display. Typed code responses include it, or null when the program no longer exists.
Partners
POST /partners creates or updates a partner by the merchant's externalId within the key's company. It requires externalId, name and email. Email addresses are trimmed and lowercased. A new partner returns 201. An existing partner returns 200, including an identical retry. Both responses contain { "data": Partner }, so existing clients keep the same response shape.
Optional customerIds, programIds, labels and properties change only when supplied. Omitted fields keep their stored values. Supplied lists and properties replace the stored values; [] clears a list and {} clears properties. A new partner without programIds starts with an empty list. A partner can earn only from programs in this list, and every ID must be a program in the same company. labels accepts up to 10 free-text tags, each up to 60 characters. Labels help you sort partners and never affect eligibility or rewards.
programIds replaces the whole enrollment list. Do not use it in a sync that runs alongside dashboard or placement enrollment. POST /partners, PUT /partners/:id and PUT /partners/by-external-id/:externalId also accept optional addProgramIds and removeProgramIds, each with up to 100 entries. Use them to change individual enrollments while preserving the rest:
{
"externalId": "merchant/customer-123",
"name": "Alex Store",
"email": "alex@example.com",
"addProgramIds": ["program-referral"],
"removeProgramIds": ["program-signup"]
}Additions keep existing enrollment order and append addProgramIds in array order. Duplicates appear once. Removals drop only the listed programs and do nothing for missing enrollments. To enroll a partner in the program behind a placement key, read it with GET /placements/:key and send its programId. A new partner starts with an empty list before applying additions and removals. Adding a paused program follows the same rules as programIds.
Combining programIds with any addition or removal field returns 400 validation_error, even with empty arrays. An unknown program returns the existing unknown_program error. If a program appears on both sides, Grizzlee returns 422 conflicting_programs. Grizzlee validates every entry before writing the profile, enrollments, audit entries or events. Enrollment changes use the same audit and event handling as programIds. The response stays { "data": Partner } and includes the resulting programIds, without the addition and removal fields.
customerIds contains the merchant customer IDs belonging to this partner, up to 100 unique IDs. Each ID is trimmed and must contain 1 to 120 characters. Omit the field to keep existing links, or send [] to clear them. For a business partner, link its owners' customer IDs. IDs need not already have orders and may belong to several partner profiles. Partner responses return the links when supplied. A matching externalId alone never identifies self-referral.
stripeAccountId is the partner's Stripe connected account on your Stripe platform, such as acct_1Nv0FGQ9RKHgCVdK. Scheduled Stripe transfers go there, and Stripe payouts use it when the request has no destination. Because it decides where money goes, sending stripeAccountId on POST /partners or PUT /partners/... also needs payouts:write, and a key without it gets 403. null removes it. Grizzlee checks the acct_ format only. Stripe confirms the account when the first transfer is sent. Keys without payouts:write never see it. Partner write responses, GET /partners/by-external-id/:externalId and GET /company leave it out for them, and so does the dashboard for roles that cannot manage payouts. Each change is recorded in the audit log with the last four characters of the old and new account. Sample companies have no payouts and reject stripeAccountId with 503 live_required.
locale is the partner's language for partner emails, the partner portal and embedded dashboards: en, fr-CA, hy (Armenian) or ru (Russian). Without it, or when the company does not serve that language, they use the company's default language. null removes it, and omitting it keeps the stored value.
Contact refreshes preserve the partner's internal ID, referral code, creation time, active status, portal membership and earned rewards. Changed profiles record a partner.updated audit entry. Identical retries add no audit entry. Uniqueness uses the company and externalId. Several partners in the same company may share a contact email, including a business and its manager. Other companies may use the same external ID or email.
{
"externalId": "merchant/customer-123",
"customerIds": ["customer-123"],
"name": "Alex Store",
"email": "alex@example.com"
}GET /partners/by-external-id/:externalId returns 200 with { "data": Partner }. PUT /partners/by-external-id/:externalId accepts the same body as PUT /partners/:id and returns the same response. Both return 404 with code not_found when the key's company has no matching partner. URL-encode the external ID as one path segment. For example, merchant/customer-123 becomes merchant%2Fcustomer-123. Encode a literal % as %25; the server decodes the segment once. The SDK encodes external IDs for you.
PUT /partners/:id accepts any nonempty combination of name, email, customerIds, programIds, addProgramIds, removeProgramIds, labels, active, properties, stripeAccountId and locale. programIds and labels replace the stored lists, so send the full list you want to keep when using them. properties replaces the custom profile values, so include every value you want to retain. Its keys and value formats must match the company's profile fields. The merchant's externalId, referral code and company cannot change. Profile edits affect future eligibility and leave earned rewards untouched. Contact email changes do not transfer portal ownership.
POST /partners, PUT /partners/:id and PUT /partners/by-external-id/:externalId accept an optional signals object with the partner's identity signals. A PUT may send signals alone, which records them and leaves the partner unchanged. See Self-referral checks.
Portal access uses the account's stored partner membership, never all partners sharing its email. The current membership model binds one partner per account per company.
A verified user joining a published program keeps that binding even if their contact email changed. Without a binding, joining first uses the account's user:<identityId> profile, then a single email match. Multiple email matches return 409 partner_ambiguous before any profile, membership or program enrollment changes. Grizzlee never chooses one by database row order. A profile bound to another account cannot be claimed by email.
Partners sign in at /portal/sign-in, which never links to company setup. When Join program on /programs/<slug> finds no session, it opens /portal/sign-in?program=<slug>, which shows that company's name and Brand logo. Sign-in, Google sign-in and the email confirmation link return to /programs/<slug>?join=1. That page sends the same join request as the button, so the verified email, published page and active program checks still apply, and joining twice changes nothing. It joins only when the browser that started sign-in saved the same slug, so a link from another site cannot enroll a signed-in account. On success it opens /portal?company=<id>. On failure it stays on the program page with the error. /portal without a session redirects to /portal/sign-in?next=.... An account whose only memberships are partner memberships goes to /portal from / and from the company setup page. An account with no memberships at /portal sees a page that asks them to join from a program page.
Team invitations grant the requested merchant role to the verified invitee. They do not select or link partner profiles by email. The invitation page shows the company, the role, the invited email and the teammate who created it. Invitations created before that teammate was recorded leave the name out. A login belongs to at most one company as owner, admin, editor or viewer. Accepting a team invitation while the account holds one of those roles in another company returns 409 already_in_company, and POST /api/console/companies returns 409 company_limit once the account holds any of those roles. Partner memberships do not count, so a partner of any number of companies can still create or join one company as staff through the API. The dashboard's company setup page sends a partner-only account to the portal.
Owners and admins manage custom profile fields from Manage fields on the Partners page of the merchant console. Field definitions appear in the company response for SDK clients.
Programs always credit the latest referral until a purchase or milestone qualifies. attributionWindowDays sets how long after a referral a first purchase or milestone still counts. Only milestone programs accept attributionWindowDays: null, which means a referral never expires. Rewards start approved. Set reviewRewards: true on a program to keep its new rewards pending until someone calls POST /rewards/:id/approve. POST /rewards/:id/reject rejects a pending reward instead. Its optional body is { "reason": "..." }. For cash and credit, Grizzlee appends a negative rejection ledger entry for what remains. For offers, it records no ledger entry. It sets the reward to reversed with rejectedAt and rejectionReason, and sends reward.reversed. Later refunds and restorations leave a rejected reward alone. Rejecting it again returns it unchanged. Any other status returns 409 reward_not_pending. Programs set fraudChecks.selfReferral.action; see Self-referral checks. Programs accept fraudChecks; see Fraud checks. Programs no longer accept promoterTypes, attribution, holdDays or autoApprove, and a request that sends them fails validation.
duration decides whether a referral credits the customer or one purchase, and which orders earn a reward.
Customer attribution is the default model for referral links. The partner gets credit for referring the customer. After a purchase qualifies, that customer attribution keeps its partner. Later purchases can earn within the program's duration without a new link, scan or code. first_order, first_n_orders, days, months and lifetime use this model.
Purchase attribution is the usual model for influencer codes. With duration: { "type": "only_order" }, the partner gets credit only for the purchase where the link, QR code or code was used. It does not refer the customer for later purchases. Returning customers can earn the partner a reward too. Each purchase needs a new link capture, scan or code. Links, QR codes and typed codes can use either model where the program kind accepts them. The duration determines attribution, not the referral channel.
duration | Orders that earn |
|---|---|
{ "type": "only_order" } | Only the purchase where the link, QR code or code was used |
{ "type": "first_order" } | The first purchase, using the counting mode described below |
{ "type": "first_n_orders", "count": 3 } | The first count purchases, using the counting mode described below |
{ "type": "days", "days": 365 } | Orders up to days days after the first rewarded order |
{ "type": "months", "months": 12 } | Orders before the same calendar date months months after the first rewarded order, in UTC |
{ "type": "lifetime" } | Every order |
months must be an integer from 1 to 1,200. This duration is for subscriptions. With 12 months and a first rewarded order on January 15, orders through January 14 of the next year earn, which is 12 monthly invoices or one yearly invoice. When that date does not exist, the month's last day takes its place, so 1 month from January 31 covers orders through February 27 and the February 28 renewal does not earn. first_order and first_n_orders count purchases after the referral when the order supplies newCustomer. Returning customers can earn too. Without that field they use Grizzlee's company purchase count, as before.
Every percentage rule uses the order's required commissionableCents, including phases, performance tier bonuses and custom terms. Each reward saves its original base value in basisCents. Programs do not choose a different order field.
Programs accept fixedRefundMode: "prorate" | "full_refund_only". New programs default to "prorate". The setting applies to every fixed reward in the program, including custom terms. Percentage rewards keep their commission base. Each fixed reward saves the mode in calculation.fixedRefundMode, so later program edits do not change its refund rules. Older rewards without a saved mode use "prorate".
Programs accept rewardType: "cash" | "credit" | "offer". Cash and credit programs use compensation rules. An offer program earns one discount per qualifying referred order.
Offer programs require partnerOffer and duration, and use the order trigger. They reject compensation, performance, limits, customTerms and milestoneRewards. partnerOffer accepts the friend incentive's percent or fixed fields except newCustomersOnly. For example:
{
"rewardType": "offer",
"duration": { "type": "first_order" },
"partnerOffer": {
"type": "percent",
"rateBps": 2000,
"maxDiscountCents": 2500,
"orders": 1,
"expiresAfterDays": 30
}
}duration decides which referred orders earn an offer. The offer's orders sets how many of the partner's own orders each earned offer covers and defaults to 1. expiresAfterDays counts from the earning order's occurredAt, saved as reward.createdAt, rather than approval. Null means no expiry. returnOnFullRefund defaults to false and specifies whether a full refund of an order using the offer gives its use back. minOrderCents, maxDiscountCents and orderCategories have the same meanings as for friend incentives.
Each offer reward saves offer: { terms, expiresAt } with a copy of the earned terms. Later program edits leave that copy alone. Offers follow the program's approval and fraud review rules. Rejecting one or fully refunding its earning order reverses it. Partial refunds and order adjustments leave it alone. Lower cumulative refund totals restore its previous approval status, unless someone rejected it. Discounts already applied stay applied. Offer rewards have zero monetary amounts and no ledger entries, wallet balance, delivery or payout.
Redeem earned offers through the checkout endpoints described in Earned offers. Earned offers do not sync to Stripe or Shopify promo codes.
Programs accept delivery: { "cash": mode, "credit": mode }, where each mode is "grizzlee_wallet" or "merchant". New programs default both to "grizzlee_wallet". Cash and credit programs use the matching entry. Offer programs ignore delivery modes. See Reward delivery.
Programs accept an optional friendIncentive, the offer a referred customer gets. See Friend incentives.
Order programs accept an optional orderCategories: { "include": [...], "exclude": [...] }, which decides which orders earn rewards. See Order categories.
Programs accept trigger: { "type": "order" } or trigger: { "type": "milestone", "milestone": "customer_10_orders" }. Omitting it on create gives an order program. Existing programs are order programs. See Milestones.
currency is optional on POST /programs and defaults to the company currency. Any other currency returns 400 currency_mismatch. Programs accept an optional destinationUrl, the HTTPS page that partner referral links and QR codes open, such as https://shop.example.com/. It cannot contain a user name or password. A published program signup page's destination takes precedence. Without either, partner summaries return no link.
Programs accept optional display copy per language, under en, fr-CA, hy and ru. Each language takes publicName (up to 100 characters), headline (up to 120) and, for milestone programs only, milestoneLabel: { one, other } (up to 40 each), such as { "one": "lesson", "other": "lessons" }. In Russian, other is the genitive plural, such as "уроков", because reward terms read "число уроков". Order programs that send milestoneLabel return 400. Responses that show a program to partners or customers carry display with every language filled in. publicName falls back to the public name in the company's default language and then the program name. headline falls back to the generated headline. milestoneLabel falls back to "step" and "steps" in each language, and is null for order programs. Its responses carry one, few, many and other, because Russian needs three forms. A custom label fills few and many from other. Pick the form with Intl.PluralRules.
A company serves one to four languages, listed in company.languages on GET /company with the default first. When it serves more than one, POST /programs, and PUT /programs/:id with display, return 400 missing_translation unless every other language has a publicName, and headline and milestoneLabel are each set in every language or in none. The message names the language to add. Removing a language from the company keeps its saved copy.
company.delivery on GET /company, when present, is the payout method the dashboard gives programs it creates, set on the Payouts page. The API does not use it. POST /programs without delivery still defaults both modes to "grizzlee_wallet".
PUT /programs/:id still requires the existing program fields. Omitting currency, destinationUrl, display, delivery, friendIncentive, fixedRefundMode, trigger, tracking or orderCategories keeps its stored value. Send destinationUrl: null to remove the destination, display: null to remove the display copy, friendIncentive: null to remove the offer and orderCategories: null to remove the category rules. A friendIncentive you send replaces the stored one whole, including its orderCategories. An explicit delivery object replaces both delivery modes, with omitted modes defaulting to "grizzlee_wallet". Other optional rule fields keep their replacement behavior. Omitting partnerOffer removes it, like compensation; it does not accept null. Switching an offer program to cash or credit must omit partnerOffer. Switching a milestone program to orders without sending display keeps its stored copy and drops the milestone labels. Earned rewards keep their original terms. The dashboard editor sends both delivery modes and the current offer, or null when removing it. Existing rewards keep their saved rules and delivery modes. Recorded incentive uses keep their original offer terms.
Use the HTTP reference for program, partner, event and attribution schemas. The SDK exposes typed calls. Any backend language can call the HTTP endpoints.
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 or display change, which only moves updatedAt. Pausing or resuming counts as a change, because it decides who can earn. An update that changes nothing keeps the version and writes nothing. Grizzlee keeps every version and never changes one. PUT /programs/:id rejects version and updatedAt in the body, like id, companyId and createdAt.
Each new reward records programVersion, the version it was earned under, next to its programSnapshot. Each new incentive reservation records the version its copied terms come from. Rewards and reservations made before versions existed have no programVersion, because Grizzlee cannot tell which rules were live when they were made. Their own copies of the rules still say what applied. Programs that existed before versions start at version 1 with their rules at that time, dated at their last saved change, or their creation when they were never changed.
GET /programs/:id/versions lists a program's versions, newest first. GET /programs/:id/versions/:version returns one. Both need read.
{
"versions": [
{
"companyId": "merchant",
"programId": "program-1",
"version": 2,
"programSnapshot": {
"name": "Ride referrals",
"compensation": { "type": "percentage", "rateBps": 1000 },
"...": "..."
},
"changedFields": ["compensation"],
"createdAt": "2026-10-04T00:00:00.000Z",
"terms": {
"text": {
"reward": {
"en": "10% of your friend's first order after your referral",
"fr-CA": "..."
},
"...": "..."
},
"...": "..."
}
}
]
}programSnapshot holds every rule as it was saved, in the ProgramInput shape. changedFields lists the top-level fields that differ from the previous version, and is empty for version 1. createdAt is when the version took effect. terms is the version's readable terms, the same shape as a partner summary's terms, with the program's own compensation. A program or version that does not exist returns 404 not_found. A version that is not a whole number from 1 returns 400 validation_error. The company response lists every version of every program in programVersions, oldest first.
Delete a program
DELETE /programs/:id returns { id, result }. A program with no referrals, rewards, clicks, signup page, incentive uses or reservations, payout allocations or provider discount codes is removed with its versions, placement keys and enrollments, and result is "deleted". Any of those means the program has history, so Grizzlee archives it instead and returns "archived". An archived program is paused, has archivedAt, loses its placement keys and keeps its referrals, rewards, versions and enrollments. Pausing an active program this way adds a version, like any pause. PUT /programs/:id and PUT /placements/:key reject an archived program with 409 program_archived. Deleting it again changes nothing and returns "archived". Both outcomes enter the company audit log. An unknown program returns 404 not_found.
Complete an order
Send newCustomer from your server's order history on referred orders through POST /orders/complete. You then need no history import or reports of unrelated orders. If you omit it, report every completed order, referred or not, and import earlier purchases so Grizzlee can determine newness. POST /orders/complete accepts a confirmed order, an optional captured receipt and an optional incentive reservation. It claims the receipt, consumes the reservation and records the payment in one company transaction. The backend supplies the customer identity and financial amounts from its own records.
{
"event": {
"type": "order.completed",
"eventId": "payment-123",
"orderId": "order-123",
"customerId": "customer-123",
"newCustomer": true,
"currency": "CAD",
"commissionableCents": 2000,
"occurredAt": "2026-10-08T12:00:00.000Z"
},
"candidates": [
{ "type": "receipt", "receipt": "REPLACE_WITH_CAPTURED_RECEIPT" }
]
}With an order-only program at 500 basis points, the store earns 100 cents. No purchase subtotal is needed. Omit candidates to use eligible recorded customer referrals. An explicit list chooses one winning program for that checkout. An empty or invalid-only list records the order without referral rewards unless a qualified customer attribution is retained.
commissionableCents is required on every order. The merchant's server chooses the amount percentage rewards use. Stores usually send merchandise after discounts, before tax and shipping. Marketplaces can send their fee. Optional totalCents records what the customer paid for reporting and never changes new rewards.
For a $100 order with a $10 discount and $5 in shipping and tax, send commissionableCents: 9000 and totalCents: 9500. A 10% commission, rateBps: 1000, earns $9.
Money uses integer minor units. Percentages use integer basis points. Keep the same event ID and financial payload on retry. An accepted order replays even after its original receipt expires. A new order cannot reuse that receipt.
Customer display names
POST /attributions, POST /referrals/claim and POST /referrals/code accept optional customerDisplayName beside customerId. POST /orders/complete accepts it inside event, as does an order.completed event sent to POST /events. No display name is accepted on incentive lookups or reservations, which do not create referrals.
Send a name partners may see, such as Marie. Grizzlee trims the value and validates 1 to 80 characters. Empty or oversized values fail validation. Omission keeps the referral's saved name. Each accepted name replaces that referral's previous name, even after qualification, without changing its partner or rewards. 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. Name changes follow accepted request order, rather than the event's reported time.
Merchant referral responses include optional customerDisplayName and keep customerId, orderId and firstOrderId. Stored orders and completed order webhooks keep the submitted name. Embedded referral and reward JSON responses omit raw customer and order IDs. The embed displays the referral name or a translated neutral label. See Customer privacy.
Merchant customer history
getReferralIncentive, reserveIncentive and order.completed accept optional newCustomer: boolean. Your server decides it from your own order history, including placed orders that have not been paid. Exclude the checkout or order being evaluated. Save the answer with that checkout and reuse it at payment, even when your history has since changed. Browsers and mobile apps must not decide this value.
With newCustomersOnly: true, false makes the friend ineligible with not_new_customer. true uses the order limit after the referral instead of the company purchase number. The terms still limit uses, open holds, expiry and eligible categories. A program with newCustomersOnly: false accepts returning customers regardless of the answer.
On an order with newCustomer, first_order and first_n_orders count the customer's completed purchases at or after the referral. Both true and false use that count. These durations limit partner rewards after a referral. They do not require a new customer. Skipped purchases and fully refunded purchases still count, so repeatedly sending true cannot restart a reward duration.
Supply the field on every referred order in this mode. You only need to report purchases covered by referrals, including ones excluded by reward categories, and need no past order-count import. Leaving it out preserves the company-count fallback described below. Do not mix the two modes and expect omitted orders to appear in Grizzlee's history.
Reservations save the supplied answer with their copied terms. Repeating orderId with a different supplied newCustomer returns 409 order_conflict. Omit it on a reservation replay to keep the saved answer. Orders save their own supplied answer and include it in order.completed webhooks. A changed value on an event or order replay returns 409 event_conflict or order_conflict. An omitted order value uses the fallback, even if its reservation supplied one.
Purchase numbers
Grizzlee counts each customer's purchases in the company. The customer is the merchant's customerId. Every completed order counts, including one that is later fully refunded, so numbers never change once given. Grizzlee numbers orders in the order it records them, so a payment reported late gets the next number, not its place by occurredAt.
The response includes customerOrderNumber, the number Grizzlee gave the order. Without newCustomer, first-order and first-N-orders programs and friend incentives for new customers use this number. customerOrderNumber in the event is optional. When you send one that disagrees, Grizzlee keeps its own number, stores yours on the order as reportedCustomerOrderNumber and still records the order. Stripe metadata grizzlee_customer_order_number and the number Shopify reports work the same way.
If you omit newCustomer, Grizzlee only knows the orders you report, so existing customers look new until you import earlier purchases. In that mode, import them before you start reporting orders:
{
"customers": [{ "customerId": "customer-123", "priorOrderCount": 4 }]
}POST /customers/import needs events:write, because purchase counts decide rewards and incentives the same way order events do. It takes up to 1,000 customers per request. priorOrderCount is the number of successful orders the customer completed before you reported orders to Grizzlee, from 0 to 100,000. Sending the same list again changes nothing. A later import changes only future numbers. The next number is one more than the larger of the highest number already given and priorOrderCount plus the orders Grizzlee recorded.
When your checkout applied a friend incentive, add incentiveAppliedCents to the event and reservationId to the body. See Friend incentives.
Order categories
Send categories on the order.completed event to say what kind of order it is, such as its product or service types:
{
"type": "order.completed",
"eventId": "payment-123",
"orderId": "order-123",
"customerId": "customer-123",
"currency": "CAD",
"commissionableCents": 2000,
"categories": ["standard"],
"occurredAt": "2026-10-08T12:00:00.000Z"
}Each category is a short slug of 1 to 40 letters, digits, hyphens or underscores, starting with a letter or digit. Grizzlee trims and lowercases each one, drops repeats and sorts the list, and stores it that way. An order takes up to 20. An empty list is the same as no categories. Slugs are your own. Grizzlee keeps no catalog of them. Categories are part of the order's payload, so a retry of a recorded order with different categories fails with 409 order_conflict, including an order first recorded without them.
A program's orderCategories decides which orders earn its rewards:
{ "include": ["standard", "express"], "exclude": ["custom"] }include: the order needs at least one of these categories. An order without categories does not qualify.exclude: an order with any of these categories does not qualify, even when it also has an included one.- Without
orderCategories, every order qualifies, with or without categories.
Each list holds 1 to 50 slugs, stored sorted and without repeats. Set at least one list. A slug cannot be in both. Milestone programs reject orderCategories, because they have no order. Changing the rules adds a program version with orderCategories in changedFields. Rewards already earned keep the rules they were earned under.
An order that fails the rules earns nothing in that program. Its decision has outcome: "skipped" and a reason naming the category, such as Order category custom is excluded from this program or Order has none of this program's categories: express, standard. In a customer program the referral stays open, so a later order that qualifies can still earn within the referral window. An only_order referral belongs to the one order it was claimed for, so an excluded order uses it up: a receipt sent with that order is claimed by it, and a later order needs a new referral.
A skipped order is still a purchase. It gets the next purchase number, and first-order programs and new-customer offers count it. A customer whose first order was excluded is no longer on their first purchase. A program paying on percentages uses the whole order's commissionableCents, whatever the categories. Send the amount you want rewarded when only part of an order should count.
The friend incentive has its own orderCategories, checked apart from the program's. See Terms. The order.completed webhook carries categories as stored. Stripe charges send them in the grizzlee_categories metadata. See Stripe. If you verify webhooks with the SDK, upgrade it before you set orderCategories on a friend incentive. Older versions reject events that carry the new category_not_eligible flag reason or a friend incentive with orderCategories.
Add signals to the body with the customer's identity signals. See Self-referral checks.
Milestones
A milestone program pays when a referred person reaches a goal your system tracks, instead of on an order. A customer who refers another customer, for example, can earn $50 when the referred customer completes 10 orders.
You can count in your own system and report the milestone once, or report every occurrence and let Grizzlee count. With counting, you report order_completed each time an order completes, and the program pays once the person has 10 reports. The number lives in the program, so you change it in the dashboard without a code change.
Create the program with a milestone trigger. Milestone programs use explicit milestoneRewards in the program currency, as cash or credit. Omit order-only compensation and duration. Custom milestone terms use the same milestoneRewards shape. Set attributionWindowDays to a number of days, or null so the referral never expires. When it is a number, the window runs from the referral to the milestone's occurredAt.
{
"name": "Customer referrals",
"kind": "referral",
"currency": "CAD",
"trigger": {
"type": "milestone",
"milestone": "order_completed"
},
"milestoneRewards": {
"eachReport": {
"compensation": { "type": "percentage", "rateBps": 500 },
"limit": 10
},
"atCount": { "count": 10, "amountCents": 5000 }
},
"rewardType": "cash",
"delivery": { "cash": "merchant", "credit": "grizzlee_wallet" },
"attributionWindowDays": null,
"active": true
}A milestone name uses lowercase letters, digits and underscores, starts with a letter and has at most 80 characters. Reports must use the exact name. Grizzlee does not change case or trim it.
milestoneRewards.atCount.count is how many reports of the milestone a person needs, from 1 to 10,000. Set it to 1 and the first report pays, which suits a milestone you count yourself, such as customer_10_orders. Changing count creates a new program version. Rewards already earned keep the count they were earned with, so a reversed report checks them against that count, not the new one. People without a reward are measured against the new count from their next report. Lowering the count does not pay anyone on its own, even someone already past the new number. Their next report pays.
milestoneRewards.eachReport pays on each counted report. Its compensation is a fixed amountCents or percentage rateBps, without rate phases. An optional limit pays for the first N unreversed reports since the referral, from 1 to 10,000. Omit it for no limit. The limit uses arrival order, so 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. Use either rule or both. Existing programs retain only their at-count rule. A new rule or limit applies to later reports, never recalculates earned rewards, and uses the same review, delivery and caps as the bonus.
valueCents is optional on a report and uses the program currency. A percentage each-report rule requires it. Without it, the report still counts and can earn a fixed reward or bonus, but the 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. When both rules pay, the each-report reward uses the shared caps first and the bonus uses what remains. A retry must keep the same value.
Referrals work as they do for orders. Record them with a receipt, a referral code or POST /attributions without an orderId. The latest referral wins until a milestone qualifies, then the referral stays with that partner. A milestone for a person in the partner's customerIds follows fraudChecks.selfReferral.action, as an order does. The default blocks the reward. A capture or code receipt for a program without a referral window can be claimed for 365 days, so its expiresAt is a year out. The referral it records never expires.
Report a milestone
POST /milestones reports that a referred person reached a milestone. Send it from your server with an events:write key:
{
"eventId": "order-4812-completed",
"customerId": "customer-2",
"milestone": "order_completed",
"valueCents": 10000,
"occurredAt": "2026-10-08T12:00:00.000Z"
}customerId is your ID for the referred person. Each report under a new event ID is one more occurrence, so use an event ID tied to the occurrence, such as the order, and reuse it for retries. Every active program with this milestone checks the person's referral and counts their reports. A program counts only reports whose occurredAt is at or after the person's referral in that program, minus reversed ones. A report from before the referral is saved but does not count for that program, and it never pays by itself. Each eligible report pays its each-report reward. The report that reaches milestoneRewards.atCount.count pays the bonus once. When the referral window is a number of days, the count must be reached inside it. Grizzlee takes the occurredAt of the counted reports in time order, not arrival order, so with count: 10 the 10th earliest counted report must fall inside the window. A late report of an earlier occurrence can bring that point inside the window.
The count starts from the person's referral in each program, so two programs with the same milestone can count differently. The latest referral wins until a reward locks it, as for orders, so a newer referral from another partner restarts the count for that program.
One person earns a program's at-count bonus at most once, whatever order reports arrive in. Later reports can still earn each-report rewards within their limit.
The response is { "data": MilestoneResult }:
{
"eventId": "order-4812-completed",
"customerId": "customer-2",
"milestone": "order_completed",
"duplicate": false,
"status": "recorded",
"decisions": [
{
"programId": "program-7",
"programName": "Customer referrals",
"partnerId": "partner-1",
"outcome": "rewarded",
"reason": "$5.00 for report order-4812-completed at 5% of $100.00",
"reasonParts": [
{
"code": "report_percentage",
"params": {
"amountCents": 500,
"currency": "CAD",
"reportEventId": "order-4812-completed",
"rateBps": 500,
"valueCents": 10000
}
}
],
"amountCents": 500,
"milestoneCount": 10,
"milestoneThreshold": 10
},
{
"programId": "program-7",
"programName": "Customer referrals",
"partnerId": "partner-1",
"outcome": "rewarded",
"reason": "$50.00 fixed reward for 10 order_completed reports",
"reasonParts": [
{
"code": "milestone_bonus",
"params": {
"amountCents": 5000,
"currency": "CAD",
"count": 10,
"milestone": "order_completed"
}
}
],
"amountCents": 5000,
"milestoneCount": 10,
"milestoneThreshold": 10
}
],
"rewardIds": ["reward-8", "reward-9"],
"reversalCents": 0,
"restoredCents": 0,
"count": 10
}count is the person's reports of this milestone that are not reversed, from any time. Each decision that reached the count check has milestoneCount, the reports that count for its program, and milestoneThreshold, the effective bonus count, or 1 for each-report-only programs. A program still short of its count skips with a reason such as "7 of 10 order_completed reports counted". restoredCents is the reward value this report gave back, described in Reverse a report.
Event IDs share one namespace with order and refund events. Repeating an event ID with the same body returns the first result with duplicate: true. The same event ID with a different body returns 409 with code event_conflict.
Partner referrals include milestone and milestoneCount for milestone programs, including referrals that have not earned a reward yet. Counts include reports at or after the referral and exclude reversed reports. For programs with an at-count bonus, milestoneThreshold comes from the effective milestoneRewards.atCount.count, including custom terms. Until the bonus is earned, the threshold follows the current rules. Earned bonuses retain their saved threshold. Each-report-only programs omit milestoneThreshold and show no progress toward a bonus. Partner rewards with a saved milestoneCount also include it. At-count bonuses include their saved milestoneThreshold; each-report rewards omit it.
Milestone rewards follow the same rules as order rewards after they are earned. reviewRewards keeps them pending until approval. The program's delivery mode decides whether the Grizzlee wallet or your system pays them. Reward webhooks, the partner read API and merchant payouts include them. Milestone rewards have a milestone and no orderId. Each-report rewards also carry their source reportEventId and optional valueCents in the saved reward, reward webhook and partner read API. Bonuses omit reportEventId. Their milestoneCount is the reports that count for the reward's program now. milestoneRevoked is true while the milestone is revoked for the person. Grizzlee updates both with every report, reversal, revocation and reinstatement for the person and milestone.
Each new report sends a milestone.reported webhook, rewarded or not. A repeated event ID sends nothing.
Reverse a report
POST /milestones/reverse takes back one earlier report, for example an order that was cancelled after you reported it:
{
"eventId": "order-4812-cancelled",
"reportEventId": "order-4812-completed",
"reason": "Order cancelled by the customer"
}reportEventId is the event ID of the POST /milestones report. The reversal finds the person and milestone from it. An unknown report returns 404 with code not_found. reason is optional, up to 500 characters. Grizzlee saves it with the reversal and sends it in the webhook.
The report stops counting and its each-report rewards reverse in full. Every at-count bonus for the person and milestone whose count is below the count it was earned with reverses in full, in every program. The ledger keeps the earned entry and adds a negative reversal entry. Each reward sends reward.reversed.
This happens whether or not the reward was paid, the same way a refund reverses an order reward after delivery. A reward you already delivered becomes partner delivery debt, as described in Refunds after delivery. If you do not want to claw back a paid reward, do not reverse the report.
A later report that brings the count back to the bonus's saved count restores the bonus in full. Each-report rewards of reversed reports stay reversed. The ledger adds a positive restoration entry, the reward returns to the status it had before, pending or approved, and sends reward.restored. For a delivered merchant reward, the restoration first reduces the partner's outstanding delivery debt. A restoration does not check reward caps again, the same as a reinstatement. A rejected reward stays reversed.
A report can be reversed once. Reversing it again under a new event ID changes nothing, sends no webhook and returns reversalCents: 0. A reversal cannot be undone. Report the occurrence again under a new event ID instead.
The response has the same shape as a report, with status: "reversed", empty decisions, every reward for the person and milestone in rewardIds, reversalCents, the amount this event reversed, count and reportEventId. A new reversal sends a milestone.reversed webhook.
Revoke a milestone
POST /milestones/revoke takes the milestone back for a person, for example after fraud or a chargeback:
{
"eventId": "customer-2-orders-revoked",
"customerId": "customer-2",
"milestone": "order_completed",
"reason": "Customer suspended for fraud"
}It reverses every reward the person earned for that milestone, in every program, like a full refund. The ledger keeps the earned entry and adds a negative revocation entry. Each reward sends reward.reversed. A reward you already delivered becomes partner delivery debt, as described in Refunds after delivery.
A revoked milestone earns nothing for that person until you reinstate it. A report under a new event ID returns 200 with status: "revoked" and no rewards. The report still counts, so the count is right after a reinstatement. A reversal while the milestone is revoked lowers the count the same way. This also holds when the revocation arrives before any report. Revoking again while it is revoked changes nothing and returns reversalCents: 0.
A revocation sends a milestone.revoked webhook, even when no reward existed yet. Revoking a milestone that is already revoked sends none.
The response has the same shape as a report, with status: "revoked", empty decisions, every reward for the person and milestone in rewardIds, and reversalCents, the amount this event reversed.
Reinstate a milestone
POST /milestones/reinstate undoes a revocation, for example after you win a chargeback dispute or lift a suspension:
{
"eventId": "customer-2-orders-reinstated-1",
"customerId": "customer-2",
"milestone": "order_completed",
"reason": "Chargeback won"
}reason is optional, up to 500 characters. Grizzlee saves it with the reinstatement and sends it in the webhook.
Every reward the revocation reversed comes back in full, unless its report was reversed, in every program, the way a lower refund total restores an order reward. A reward whose count is below the count it was earned with stays reversed, because reports were reversed. It comes back when new reports bring the count back. The ledger keeps the earned and revocation entries and adds a positive restoration entry. Each reward returns to the status it had before the revocation, pending or approved, and sends reward.restored. A rejected reward stays reversed. For a delivered merchant reward, the restoration first reduces the partner's outstanding delivery debt and sends reward.debt_settled with kind: "restored". If you already recovered or netted that debt, the partner is owed the difference, as described in Refunds after delivery.
The milestone then counts again. A reinstatement does not create rewards the revocation prevented, such as those for a report that arrived while the milestone was revoked. To earn them, report the milestone again under a new event ID. Each bonus still pays at most once per person. Each-report rewards can continue within their limit. A reinstatement records no report and does not count toward usage.
You can revoke the milestone again later, and reinstate it again after that. Use a new event ID for every revocation and reinstatement. Reusing the event ID of an earlier revocation returns that revocation's saved result with duplicate: true and changes nothing.
Reinstating a milestone that is not revoked changes nothing, sends no webhook and returns restoredCents: 0.
The response has the same shape as a report, with status: "reinstated", empty decisions, every reward for the person and milestone in rewardIds, reversalCents: 0 and restoredCents, the amount this event gave back. A reinstatement that changed something sends a milestone.reinstated webhook.
Usage
A completed order that earns an offer counts once toward software usage, including when it also earns money rewards in other programs. Offer refunds and restorations do not change that count.
A milestone is not an order, but it is a conversion you pay for. A milestone report that creates at least one reward counts once toward software usage, however many programs it rewards. A repeated report, a revoked milestone and a report that rewards nothing do not count, and neither does a report that only restores a reward. A later revocation or reversal does not remove the count, just as a refund does not remove a rewarded order, and a reinstatement does not add one. The company response shows the total in usage.rewardedTransactions and the milestone share in usage.rewardedMilestones.
The local sandbox accepts all four endpoints with its sample keys.
Tracking methods and partner promo codes
Programs accept tracking with independent links, qrCodes, attributionCodes and providerPromoCodes switches. New programs default the first three to true and provider promo codes to false. Omitted tracking on an update keeps the program's saved switches. A supplied object replaces the switches and fills omitted fields with their defaults. kind supplies dashboard wording. It does not change reward defaults or restrict tracking. Existing affiliate programs migrate with typed attribution codes off. Existing referral and both programs keep them on.
in_app captures follow the links switch. QR captures follow qrCodes. Typed code attachments and code candidates follow attributionCodes, even when the attachment labels its channel as a link. Switching a method off stops new attributions from it. Qualified customer referrals and earned rewards remain intact. Provider promo codes require an active Stripe or Shopify connection before the program can enable them.
An attribution code identifies a partner and your checkout applies the offer. Each partner has two: the 32-character referralCode and a short alias such as KARL7Q. The alias is up to six letters of the partner's name, a digit from 2 to 9 and a letter, without 0, O, 1 or I, so it can be read aloud and does not look like a discount. It is unique in the company, never changes and matches in any case. Grizzlee creates it with the partner. Every request that takes a referralCode, including captures, typed codes, checkout candidates and attributions, accepts either code. Responses and receipts always carry the long code. Erasing a partner replaces its alias with its long code in uppercase. A provider promo code is a separate discount in the connected merchant account. It stores the friend incentive, currency and program version used at creation. Program edits do not change an existing provider discount.
Address promo codes by /partners/:id/promo-codes or /partners/by-external-id/:externalId/promo-codes. GET needs read and returns all codes, including inactive codes and pending operations. POST needs partners:write:
{
"programId": "creator-program",
"integrationId": "stripe-connection",
"code": "ALEX20"
}The program must be active, accept provider promo codes and have a friend incentive. The active partner must belong to it. One partner can have one active code per program and provider connection. Codes use 2 to 80 letters, digits or hyphens. Grizzlee trims and uppercases them. Names remain reserved in that connection after deactivation, so a delayed order cannot credit a different partner.
PUT .../promo-codes/:codeId takes { "code": "ALEX-NEW" }. Stripe renames create a replacement with the original discount and deactivate the old code. Shopify renames update the existing discount, preserving its redemption history. Both keep the original friend offer and program version, with an old-name mapping for delayed orders. DELETE .../promo-codes/:codeId deactivates the provider discount. Both require partners:write. Neither removes attribution history, rewards or ledger entries. Repeating a create or rename with the same input resumes its saved operation or returns the finished code. A pending code can be deactivated after a failed creation. Deleting a pending Shopify rename restores the original name while it is still active. Retrying with a different name creates a different operation.
Codes return id, partnerId, programId, integrationId, provider, code, providerCodeId, status, active, incentive, programVersion and createdAt. Stripe codes also have couponId. A replacement has replacesId, and an inactive code has deactivatedAt. Status is pending, active or inactive. providerCodeId is null until Grizzlee has saved the provider discount ID.
Stripe and Shopify order adapters identify applied provider codes without referral links or metadata. They append code candidates with providerPromoCodeId and the verified order time. The normal newest-valid rule selects among receipts, typed codes and provider codes. Qualified customer attribution keeps its partner. The selection reports a provider winner as type: "code" with providerPromoCodeId. Provider usage must belong to that stored partner and program. Orders from an already discounted checkout can still use the old mapping after deactivation.
Partner summaries expose each program's tracking, attributionCode, alias, promoCodes and qrLink. alias is the partner's short code and follows the same switch as attributionCode. Disabled or unavailable sharing methods return null or an empty list. Active provider codes carry their own saved incentive. The dashboard, partner portal and embed show these codes separately.
Missing permissions return missing_provider_permission and name the permission. Unsupported discount rules return unsupported_promo_incentive. See provider permissions and supported friend incentives. Promo operations need live mode. The sandbox returns live_required.
Claim before payment
Referral code at checkout
This is an attribution code. It identifies the partner and your checkout applies the offer. Provider promo codes apply discounts in the connected account and use the separate promo code endpoints.
At checkout, send the typed code and every saved receipt together as candidates. POST /referrals/code issues a signed code receipt when you need server capture outside that decision. It requires a private server key with partners:write. The full path is /api/v1/referrals/code. The merchant backend takes the typed code from the app and chooses customerId from the authenticated account. Never send this key to a browser or mobile app.
{
"referralCode": "store-code",
"customerId": "customer-123",
"programId": "retail",
"orderId": "checkout-456",
"channel": "coupon"
}Only referralCode and customerId are required. channel defaults to coupon and also accepts link, qr and in_app. Grizzlee supplies the referral time. The request cannot override the company or send payment amounts.
Without programId, Grizzlee selects the partner's single active enrolled program with tracking.attributionCodes on. Program kind does not restrict typed codes. Several candidates return HTTP 409 with code ambiguous_program and error.details.programIds. Send one of those IDs in a new request. No candidates return HTTP 404 with code no_referral_program. A code outside the key's company returns HTTP 404 with invalid_referral_code. Explicit selection still requires an active partner, active program and enrollment. Inactive records return HTTP 409 with partner_inactive or program_inactive. Missing enrollment returns HTTP 409 with ineligible_partner. A program with typed codes off returns HTTP 409 with tracking_disabled. The program's self-referral rule checks linked customer IDs and identity signals.
The response uses the usual { "data": ... } envelope:
{
"data": {
"receipt": "opaque-encrypted-receipt",
"programId": "retail",
"partnerId": "grizzlee-partner-id",
"partnerExternalId": "merchant-store-id",
"expiresAt": "2026-11-08T12:00:00.000Z",
"duration": "only_order",
"incentive": null
}
}incentive is the program's current friend incentive, or null when it has none. It says nothing about whether this customer qualifies. A retry returns the original receipt with the program's current incentive.
Store receipt with the pending order and pass it as a receipt candidate to /orders/complete after the backend verifies payment. Code receipts use the same encryption and expiry checks as captured receipts. They record source code and the chosen channel. They require no publishable tracking key. Every code receipt belongs to the supplied customer. If supplied, orderId also binds it to that order, including for customer programs.
For only_order, issuance creates no attribution. It accepts an omitted orderId; /orders/complete binds the receipt when recording payment. A direct /referrals/claim still requires an order ID. Payment failure rolls back the claim. Once claimed, an order-only receipt cannot be used for another order.
Other durations attach immediately and include attribution in the response, so partner reads can show the referral before checkout completes. The latest referral wins until qualification. A qualified attribution keeps its partner, including when checkout supplies candidates. The top-level partner IDs identify the typed code's owner; attribution.partnerId identifies the retained partner when qualification blocks replacement. An old receipt cannot restore a referral displaced by a newer one.
Retries with the same code, customer, resolved program and optional order return the original response, including the receipt and expiry. Changing only the channel does not create another referral. Automatic and explicit selection of the same program share that response. A replay does not revalidate current eligibility, refresh expiry or add attribution history. The original response is a snapshot, so its attribution may differ from the customer's current attribution. Supply a new order ID for a later checkout that needs a fresh receipt. Receipt validity and attribution precedence still apply when a new payment claims it.
Code receipt issuance requires live mode. The sandbox returns HTTP 503 with live_tracking_required.
POST /referrals/claim accepts candidates, customerId, optional orderId, optional expectedProgramId and optional signals. Candidates are { "type": "receipt", "receipt": "..." } or { "type": "code", "referralCode": "...", "programId": "...", "enteredAt": "..." }. A code's programId and enteredAt are optional. Your server chooses the customer and the expected program. An only_order program requires an order ID. The response has attribution and referralSelection. If no candidate wins and no qualified attribution is retained, attribution is null and checkout continues. Prefer atomic completion after confirming payment.
The newest valid candidate wins while the customer attribution is open, or for an only_order program. A qualified customer attribution keeps its partner. Candidates for that program are skipped with customer_already_referred, and the response reports the retained partner. A candidate older than an open attribution is skipped with older, and the response reports that recorded partner. Grizzlee reads capture times from signed receipts. A typed code defaults to checkout time and beats an older scan while attribution is open. A merchant server can save enteredAt with the order and reuse it on completion. At completion, an omitted code timestamp uses the order's occurredAt. A held order reuses its saved timestamp. Future timestamps and captures after the completed order's time are invalid. For equal times, the later list entry wins. No precedence setting exists.
referralSelection.winner is null or has programId, partnerId and occurredAt. A winning candidate also has index and type. A retained attribution omits those fields because it did not come from the submitted list. The index starts at zero. skipped reports every skipped index with expired, invalid, self_referral, wrong_program, program_inactive, partner_inactive, fraud, customer_already_referred or older. A mismatched expectedProgramId skips that candidate with wrong_program. A newer valid referral can win an open attribution even when its friend offer is ineligible. The selection report contains no receipt tokens or code values. Lists accept up to 50 candidates.
A backend that already knows the trusted referral identity can use POST /attributions. Include orderId only for duration.type: "only_order". Other durations bind the referral to the customer. Record order attribution before recording the payment. Completed orders cannot change their referring partner.
Public app capture
POST /api/tracking/:publishableKey/capture accepts referralCode, optional programId, optional destination and a channel of link, qr, coupon or in_app. referralCode takes the long code or the alias. Without programId, Grizzlee picks among the partner's active programs that take the channel and have a link destination: a published signup page's destination, or else the program's destinationUrl. A program whose destination has the same origin and path as destination wins, and the earliest enrollment breaks a tie. Without a match, a partner with exactly one such program uses it. Otherwise the capture returns 404 invalid_referral, the same answer as an unknown code. It returns an opaque receipt, checkoutReference, program ID, expiry, reward duration and incentive, the program's friend incentive or null. The app can show the offer, such as "20% off your first order". For milestones, the capture response uses duration: "lifetime" to identify a customer-scoped referral. This does not define milestone rewards. The response carries no eligibility and no customer data, so the app must not apply a discount from it. It accepts no customer ID, order ID or financial amount. It also accepts an optional deviceId, up to 255 characters. The tracking SDK creates a random one in its storage and sends it with each capture. Grizzlee hashes deviceId and the request's client IP address with the same keyed HMAC as other identity signals and keeps the hashes on the capture. When a claim or order uses its receipt, they count like signals sent with that request, and the claim moves them to the customer's stored signals with the capture's time. The IP address counts only for 30 days after the capture.
checkoutReference stands for the same capture in 112 characters: grz_ and 108 lowercase hex characters, signed for your company. It carries the receipt's expiresAt and stops working at the same time. A receipt is too long for a Stripe Checkout Session's client_reference_id, which allows 200, so put the reference there instead. A Stripe connection that records invoices claims it for the paying customer, with the same checks as a receipt: the company, the expiry, an unrevoked tracking key, and no claim by another customer. See Stripe subscriptions. Receipts an older SDK stored have no reference until the next capture.
Configure exact website origins on the key. Native capture must be enabled explicitly. A publishable key never grants access to merchant endpoints. Public capture requires a configured live company.
Install claims
A visitor who scans a QR code without the app installed lands on your website, then installs the app from a store. The store drops the link, so the app starts with no referral. An install claim carries the visit across.
Send "installClaim": true with the capture on the landing page. The response then has installClaim:
{
"receipt": "...",
"checkoutReference": "grz_...",
"programId": "program-1",
"expiresAt": "2026-11-10T14:00:00.000Z",
"duration": "first_order",
"incentive": null,
"installClaim": {
"code": "K7Q2-M9XD-4HPA",
"expiresAt": "2026-10-18T14:00:00.000Z"
}
}code is 12 characters of Crockford base32 in groups of four. Hyphens, spaces and case don't matter. It works once and expires 7 days after the capture, or when the receipt expires if that comes first. Put it in the Play Store link's install referrer as grizzlee_claim, or show it on the page for an iOS visitor to copy. The capture records one click either way.
POST /api/tracking/:publishableKey/install-claims/redeem accepts { "code": "K7Q2-M9XD-4HPA" } and returns HTTP 200 with the same fields as a capture, without installClaim. The receipt belongs to the click the landing page recorded, with its original channel and time. Your backend claims it like any captured receipt. Call it from the app with a key that has native capture enabled. A code that is malformed, unknown, expired or already used returns HTTP 404 install_claim_unavailable. A program or partner that is no longer active returns HTTP 404 invalid_referral. Each key allows 600 redemption attempts a minute, failed ones included, then returns HTTP 429.
Grizzlee stores only a SHA-256 hash of each code. It never matches devices by IP address or fingerprint. On iOS the visitor moves the code themselves. See the mobile guide.
The web receipt and the app receipt share one click. The first customer who claims either one owns it. Checkout skips it as invalid for a second customer.
Friend incentives
A program can give the referred customer, the friend, an offer such as 20% off their first order. Your checkout computes the discount and pays for it. Grizzlee stores the terms, tells your backend whether this customer qualifies, counts each use and records what your checkout applied. Grizzlee never computes your final price and never accepts an incentive from a browser or app.
Terms
Set friendIncentive on a program:
{
"type": "percent",
"rateBps": 2000,
"maxDiscountCents": 2500,
"minOrderCents": 1000,
"orders": 1,
"newCustomersOnly": true,
"expiresAfterDays": null,
"returnOnFullRefund": false,
"orderCategories": { "include": ["standard"] }
}typeispercentwithrateBps, orfixedwithamountCentsin the program currency.maxDiscountCentsis the most apercentoffer takes off one order. Optional.minOrderCentsis the smallest order, before the discount, that gets the offer. Optional. Your checkout applies it.ordersis how many of the friend's orders get the offer. It defaults to 1.newCustomersOnlydefaults totrue. A merchant-suppliednewCustomer: falsemakes the friend ineligible. Withtrue, the offer covers the firstorderspurchases after the referral. Without a merchant answer, Grizzlee's company purchase number must be at mostorders. See Merchant customer history. WithnewCustomersOnly: false, the offer covers the firstorderspurchases after the referral for any customer.expiresAfterDayscounts from when the referral was captured, claimed or entered. It defaults tonull, which never expires.returnOnFullRefunddefaults tofalse. Withtrue, a full refund gives the use back, so the customer can get the offer again.orderCategoriesdecides which orders get the offer. It works like a program's order categories, against the categories your checkout sends. Optional. It is separate from the program'sorderCategories, which decide rewards: an order can get the discount and earn nothing, or the reverse. Set both to keep them in line.
Every offer allows at most orders uses per program and customer. A new referral of the same customer does not refill it. Refunds give a use back only under returnOnFullRefund. Grizzlee then adds a release record and keeps the use. The released order also stops counting against orders. Saving new terms leaves recorded uses and open reservations unchanged, and each use keeps the terms it was recorded under.
One referral belongs to one program, so at most one friend incentive applies to an order.
Reserve for a placed order
Show the offer at quote time with getReferralIncentive. It holds nothing. Call POST /referrals/incentive/reservations when the order is placed. It needs events:write, because a reservation holds a use until an order consumes it.
{
"customerId": "customer-123",
"orderId": "order-123",
"candidates": [
{ "type": "receipt", "receipt": "REPLACE_WITH_CAPTURED_RECEIPT" }
],
"baseCents": 2000
}customerId and orderId are required. Send all checkout candidates together. An empty list or a list with no valid candidate and no retained qualified attribution leaves the referral winner null. Checkout can still return an earned offer. If no earned offer applies, it answers eligible: false with reason: "no_referral" and null programId, partnerId and terms. Omit candidates to check the customer's recorded referral. Top-level programId restricts referral selection, recorded lookup and earned offers to that program. Reservations keep their selection and terms on retries. Completion reuses a saved referral candidate even after receipt expiry, while checking ownership, revoked keys and referral fraud again. Earned redemption itself runs no self-referral or fraud checks. Claiming that saved selection also requires partners:write.
baseCents is optional. It is the amount your checkout discounts. When you send it and the customer qualifies, the response has suggestedDiscountCents, computed with the same integer math as the SDK's computeIncentiveCents.
categories is optional. Send the checkout's order categories when the offer has orderCategories. Without them, or with an empty list, the checkout has no categories, so an offer with include answers category_not_eligible. The reservation keeps the categories it was made with.
{
"data": {
"reservationId": "5b0f2f8e-55a8-4a3c-9a41-0a0f5c1f6d11",
"status": "open",
"orderId": "order-123",
"eligible": true,
"programId": "retail",
"partnerId": "grizzlee-partner-id",
"terms": {
"type": "percent",
"rateBps": 2000,
"maxDiscountCents": 2500,
"orders": 1,
"newCustomersOnly": true,
"expiresAfterDays": null,
"returnOnFullRefund": false,
"currency": "CAD"
},
"programVersion": 3,
"suggestedDiscountCents": 400,
"customerOrderNumber": 1
}
}The reservation copies the terms. Editing or removing the offer afterwards changes nothing for this order. programVersion is the program version the terms come from. When nothing is held it is the program's current version, whose offer terms shows. customerOrderNumber is the number Grizzlee expects for the order. When the customer does not qualify, nothing is held: reservationId, status and orderId are null, eligible is false and reason is one of:
no_referral: no candidate or recorded referral is valid.no_incentive: the program has no friend incentive.program_inactive: the program is paused.partner_inactive: the partner is paused or left the program.self_referral: the customer is the partner, or shares a strong identity signal with the partner in a program that blocks matches. See Self-referral checks.fraud: a fraud rule set toblockfired on a phone, card or device match. See Fraud checks.expired: the offer has expired. Friend expiry starts at referral time. Earned expiry starts when the offer was earned.offer_voided: a replayed open earned hold names a reward that was rejected or whose earning order was fully refunded. The replay releases the hold.not_new_customer: the offer requires a new customer and your server sentfalse, or the fallback purchase number is pastorders.order_limit_reached: the customer already placedordersorders since the referral.uses_exhausted: the customer already used the offer onordersorders.category_not_eligible: the checkout'scategoriesinclude one the offer'sorderCategoriesexclude, or none it includes. Grizzlee checks this after the customer's own limits, so a customer who cannot get the offer at all hears that reason instead.
Each open reservation holds one of the customer's remaining uses. If other orders hold all of them, the response has eligible: false and reason reservation_open. If the use was consumed after the quote, the response has reason uses_exhausted or order_limit_reached. Place the order without the discount or ask the customer to review the price. For a once-only offer, orders: 1, a customer has at most one open reservation per program.
Repeating orderId returns its reservation as it is now. Once it is consumed or released, the replay has eligible: false, its status and no suggestedDiscountCents. An order ID belonging to another customer returns 409 order_conflict. A new hold for an already recorded order returns 409 order_completed.
A replay of an open hold checks the supplied categories, or the saved categories when omitted, against the copied terms. An empty list means none. Failing categories return eligible: false and category_not_eligible, without a suggested discount. The hold stays open. For friend holds, supplied signals run self-referral and fraud checks again. A blocking match releases the hold and returns eligible: false with reason self_referral or fraud. Earned hold replays skip those checks. If their reward is reversed, replay releases the open hold and returns offer_voided.
A receipt is checked but not claimed. /orders/complete claims it when payment is confirmed.
Release an order hold
POST /referrals/incentive/reservations/:id/release frees an open hold. Call it when the order is cancelled. Releasing a released reservation returns it unchanged. Releasing one an order used returns 409 reservation_consumed. Holds have no expiry or maximum duration. Grizzlee never releases them on a timer.
Only the reserved order's completion consumes the hold. Completion can omit reservationId, because Grizzlee finds the open hold by orderId. Another order that supplies its reservation ID gets flag reservation_locked, and the hold stays open for its own order. Completion with no discount releases that order's hold.
Find forgotten holds
GET /referrals/incentive/reservations?olderThanDays=30 needs read and lists up to 100 open holds created more than 30 days ago, newest first. olderThanDays is an optional integer from 0 to 100,000. Zero finds all open holds. Add optional customerId to restrict the list to one customer. Without the age filter, the endpoint lists the latest reservations of all statuses.
Each reservation has incentiveKind, optional rewardId, id, orderId, customerId, programId, partnerId, status, terms, programVersion, currency, incentiveExpiresAt, referredAt, customerOrderNumber, optional newCustomer, baseCents, suggestedDiscountCents and categories, and createdAt and updatedAt. incentiveKind is friend or earned, with rewardId only for earned holds. Historical friend rows can omit incentiveKind. For an earned hold, referredAt is the reward's createdAt. programVersion can be absent on historical holds. Historical quote holds released during migration have no orderId. Status is open, consumed or released. The offer's incentiveExpiresAt limits new reservations, not an existing hold. Grizzlee sends no reservation webhooks.
The Events tab of the dashboard's Developers page has Open offer holds with an Open longer than days filter. The Kind column distinguishes Friend and Earned holds. Check the merchant order before releasing a forgotten hold through the API. Grizzlee never treats age as permission to release it.
If Grizzlee is unreachable
Apply a friend or earned discount only from a reservation Grizzlee returned. If the reservation call fails or times out, do not apply the discount. Retry with the same orderId, which returns the same reservation if the first call succeeded, or tell the customer the referral discount is unavailable right now.
Preview eligibility
POST /referrals/incentive is a read-only preview. It needs read, takes the same referral fields as a reservation, with optional orderId and categories, and holds nothing. categories work as on a reservation. Two checkouts can both see eligible: true. It returns incentiveKind, optional rewardId, programId, partnerId, terms, display, eligible, reason, usesRemaining, expiresAt and customerOrderNumber. incentiveKind is always friend or earned. rewardId appears only for earned offers. display is the program's public name, headline and milestone label, or null when programId is null. terms has the program currency and either the current friend terms or the frozen earned terms. An earned answer has no selfReferral or fraud. customerOrderNumber in the request is ignored. Use it to show a likely offer before checkout. Reserve when the order is placed. A quote never holds a use.
Report the applied discount
Add incentiveAppliedCents to the order.completed event. Optionally send reservationId in the /orders/complete body. Grizzlee can find the hold by order ID. incentiveAppliedCents is an integer of 0 or more. Send it from your server only.
{
"event": {
"type": "order.completed",
"eventId": "payment-123",
"orderId": "order-123",
"customerId": "customer-123",
"currency": "CAD",
"commissionableCents": 2000,
"incentiveAppliedCents": 400,
"occurredAt": "2026-10-08T12:00:00.000Z"
},
"candidates": [
{ "type": "receipt", "receipt": "REPLACE_WITH_CAPTURED_RECEIPT" }
],
"reservationId": "5b0f2f8e-55a8-4a3c-9a41-0a0f5c1f6d11"
}Completion never fails over the incentive. Your checkout already charged the customer, so Grizzlee records the order, earns rewards as usual and stores incentiveAppliedCents as you sent it. An open friend or earned reservation is consumed, and the amount is checked against the terms it copied. An earned hold still completes if its reward was reversed after reservation, because checkout already charged that price. Without reservationId, Grizzlee uses the open hold for the order, if there is one. There is no time limit on an open hold. With incentiveAppliedCents of 0, the reservation is released instead.
When the discount does not match, the order gets incentiveFlag, and the response returns it too:
{
"reason": "exceeds_terms",
"programId": "retail",
"reservationId": "5b0f2f8e-55a8-4a3c-9a41-0a0f5c1f6d11",
"maxCents": 2500,
"reportedCents": 3000
}reason is one of:
exceeds_terms: the amount is above the reservation'smaxDiscountCentsor fixedamountCents.no_reservation: no reservation was supplied or found by order ID, or it belongs to another customer.reservation_releasedorreservation_consumed: the reservation sent was no longer open.reservation_locked: the reservation belongs to another order.no_referral: no reservation, usable referral or eligible earned offer, so no use is counted.category_not_eligible: the order's owncategoriesfail theorderCategoriesof the terms checked. Grizzlee checks the completed order, not what the checkout sent, so send the same categories on the event as on the reservation.
A flag has one reason. A reservation that was not open, or no reservation, comes first, then category_not_eligible, then exceeds_terms. maxCents is the per-order limit of the terms checked, or null when they set none. reportedCents is the amount you sent. programId is absent for no_referral. An earned flag also carries rewardId. Without a reservation, Grizzlee tries the receipt referral, then the latest recorded referral in a program with a friend incentive, then an eligible earned offer, then the latest referral in any program. Earned selection uses the order's occurredAt and categories and ignores open holds on other orders. A fallback earned use gets no_reservation. If checkout selected no referral winner, the fallback skips recorded friend referrals but can still record an earned use. /events takes no reservationId, so a discounted order sent there is flagged no_reservation unless it has an open hold.
Every order with incentiveAppliedCents above 0 and a reservation, referral or eligible earned offer records one use, flagged or not, so the use count stays exact. A flagged use can go past orders. A retry with the same payload, or the same order under another event ID, returns duplicate: true, the original incentiveFlag and records no second use. A retry that changes incentiveAppliedCents fails like any changed payment. Completions and reservations in one company run one at a time, and the database allows each friend use number once per program and customer, and each earned use number once per reward. A use keeps incentiveKind and, for an earned offer, rewardId. One order records at most one use across both kinds.
The amount does not change rewards. Rewards use commissionableCents, so send the base your program pays commission on, for example your fee before the friend's discount.
The order.completed webhook carries incentiveAppliedCents as submitted and incentiveFlag when the order has one. An incentive.flagged webhook follows each flagged order. Reward webhooks for the order's rewards repeat incentiveAppliedCents and incentiveFlag at the top of data. Partner referral reads include only friend uses in incentiveAppliedCents, and partner summaries include each program's incentive. The dashboard shows flagged orders and recent reservations.
The sandbox keeps no use counts. It returns HTTP 503 live_required for /referrals/incentive, reservations, /customers/import, a completion with reservationId and an event with incentiveAppliedCents above 0.
Earned offers
An order program with rewardType: "offer" requires partnerOffer. Its percent or fixed terms use the same fields as friendIncentive, except newCustomersOnly. The program configuration example above shows these fields. Offer programs require duration and reject compensation, performance tiers, limits, custom terms and milestone rewards.
Each referred order allowed by duration earns one offer. partnerOffer.orders sets how many of the partner's own orders that offer covers. Each reward keeps a frozen offer.terms and offer.expiresAt. Expiry starts at the earning order's occurredAt, saved as reward.createdAt, even when approval comes later.
Offers start pending under reviewRewards or a self-referral or fraud review. Checkout finds only approved offers. Rejecting an offer or fully refunding its earning order voids it. Partial refunds and order adjustments leave it alone. A lower cumulative refund total restores its prior approval status unless it was rejected. Discounts already applied stay applied, and uses stay recorded. Voiding stops the remaining uses. A full refund of the order that used the offer gives that use back only when its frozen terms have returnOnFullRefund: true.
Link the partner's merchant customer IDs in customerIds. The existing preview, reservation, release and completion endpoints find approved earned offers for any linked ID. A partner without links still earns offers, but checkout cannot find them. Linking or unlinking takes effect on the next lookup. A paused or erased partner cannot redeem. Pausing the program or removing the partner from it leaves an earned offer usable. Earned redemption runs no self-referral or fraud checks, because those checks ran when the reward was earned.
One discount applies per order. An eligible friend offer wins. Otherwise Grizzlee chooses the eligible earned offer with the soonest expiry, then the oldest earning time, then reward ID. Offers without expiry come last. Categories and remaining uses decide eligibility. A hold for another order reserves one use. The lookup reports an earned category or hold failure only when there is no friend referral and no eligible earned offer.
Preview and reservation responses always include incentiveKind: "friend" | "earned". Earned answers add rewardId, use the reward's programId and partnerId, and return the frozen terms with currency. Friend terms include newCustomersOnly; earned terms omit it. Reservations and uses keep the kind and reward ID. Historical friend rows may omit the kind. See Preview eligibility, Reserve for a placed order and Report the applied discount for checkout behavior and completion fallback order. Partner referral incentiveAppliedCents totals exclude earned uses, since those discount the partner's own orders.
Earned offers apply through API checkout only. Grizzlee does not sync them to Stripe or Shopify promo codes. They are discounts, so they have no wallet balance, ledger entry, delivery or payout. An order that only uses an offer does not count toward software usage unless it also earns a reward.
Self-referral checks
Link a partner to their own merchant customer IDs with customerIds. An order or milestone whose customerId appears in that list is self-referral. externalId identifies the partner and never counts as a customer link. Partners without links still use identity signals:
{
"email": "alex@example.com",
"phone": "+1 514 555 0123",
"paymentFingerprint": "Xt5EWLLDS7FJjR1c",
"deviceId": "install-8f3a",
"ipAddress": "203.0.113.7"
}Send them as signals on the partner (POST /partners, PUT /partners/:id, PUT /partners/by-external-id/:externalId) and on the referred customer (POST /referrals/code, POST /referrals/incentive, POST /referrals/incentive/reservations, POST /orders/complete). Every field is optional and unknown fields fail validation.
| Field | Type | Normalized as |
|---|---|---|
email | Lowercase and trimmed. A +tag is dropped. Gmail dots are dropped and googlemail.com is gmail | |
phone | phone | E.164. Spaces, dots, dashes and brackets are ignored. It needs a + or 00 country code |
phoneHash | phone | Lowercase hex SHA-256 of the E.164 number. Matches a raw phone sent elsewhere |
paymentFingerprint | card | As sent, up to 255 characters. Stripe's card.fingerprint |
deviceId | device | As sent, up to 255 characters |
ipAddress | ip | IPv4 as written. IPv6 by its /64 network. IPv4-mapped IPv6 counts as IPv4 |
An email, phone or IP address that cannot be normalized is ignored, not rejected, so a bad value never fails an order. The order's customerEmail and the partner's email field count as email signals too.
Grizzlee never stores or returns a raw signal. It keeps an HMAC-SHA256 hash keyed per company, with the key derived from the server's encryption key, so the same value hashes differently in each company. Hashes never appear in responses, webhooks or the dashboard. POST /referrals/incentive is read-only: it compares the signals it receives and stores nothing.
The customer's side is every signal sent for their customerId. The partner's side is the partner's own signals, the signals sent for every customer ID in the partner's customerIds, and the partner's email. Email, phone, card and device are strong signals. An IP address is weak. A stored IP address counts only when it was seen in the last 30 days, and a shared IP address alone never blocks a referral.
Set fraudChecks.selfReferral.action on the program:
| Action | A linked customer ID or strong signal match | An IP-only match |
|---|---|---|
block | No partner reward and no friend incentive. The default when omitted | Flagged, reward as usual |
review | Reward held as pending for review | Reward held for review |
flag | Reward follows the program's usual approval and records the match | Flagged, reward as usual |
off | No self-referral check, including customer links | No self-referral check |
selfReferralCheck is no longer accepted. Existing live settings move to fraudChecks.selfReferral.action. Existing partners gain no automatic customer links. Earlier rewards and saved rules stay intact.
Checkout skips a blocked candidate with reason self_referral. If no candidate wins, incentive responses return eligible: false with no_referral and hold nothing. A check of a recorded referral still returns self_referral when blocked. POST /referrals/code returns 400 with code self_referral and details.signals. Retries check again with any new signals: a repeated orderId that now matches under block releases its open hold and returns eligible: false with reason self_referral, and a repeated code attachment returns the 400 and voids the receipt it issued before. The signals a blocked request sent are still stored. POST /referrals/claim skips a voided receipt with self_referral. POST /orders/complete records the order without claiming it. POST /orders/complete and milestone reports skip the reward with a decision reason that starts with Self-referral blocked. Completion does not undo a discount the checkout already applied; check before you price.
These responses carry selfReferral when signals matched: action is blocked, review or flagged, and signals lists the matching types, including customer_id for a customer link, such as ["customer_id", "card"]. Order and milestone decisions carry it too. flagged means the rule uses flag or only an IP address matched under block: the friend stays eligible, the reward follows reviewRewards as usual, and the reward records the match. A reward held for review starts pending with selfReferral and waits even in programs without reviewRewards. Approve it with POST /rewards/:id/approve or reject it with POST /rewards/:id/reject. The dashboard's Commissions page shows held rewards and their matching signal types, and its Developer page shows blocked orders.
Stripe integrations fill in signals from the charge: the card fingerprint, the billing or receipt email and the billing phone. Grizzlee reads them from the stored Stripe event on every attempt, separately from the event mapping, so a mapping corrected in the dashboard keeps them. Only their hashes are stored, and the mapping shown in the dashboard never includes them. The Stripe email is used only as a signal and does not become the order's customerEmail. A paid invoice gives the customer's email and phone from the event and the card fingerprint of the charge that paid it. Grizzlee reads the fingerprint from the charges it already lists for the invoice, or reads the first charge when none has one. A key that may not read charges records the invoice without it. Shopify orders give the order's browser_ip, or client_details.browser_ip, as an IP signal for the order's customer. The user agent is not kept.
Grizzlee also records signals without your server. A link capture records the visitor's IP address and the SDK's device ID, as described under Public app capture. A partner's IP address is recorded when they join from a program page and when they open the partner portal, at most once a day per address. A partner who opens the portal and then buys through their own link from the same network matches on IP address only, so the self-referral rule flags the reward rather than blocking it.
The sandbox has no hashing key and ignores signals.
Fraud checks
The self-referral rule compares the customer with the partner through customer links and identity signals. The other fraud rules look for patterns across customers and compare the hashed signals described in Self-referral checks, never raw values, and runs in every program unless the program turns it off.
| Rule ID | Fires when | Signals |
|---|---|---|
duplicate_accounts | At least accounts customers the partner referred within windowDays share one card or device seen in that time | card, device, ip when enabled |
rapid_repeats | At least referrals customers the partner referred within windowMinutes share one device seen in that time | device, ip when enabled |
repeat_customer | The customer shares a phone, card or device with a different customer who already has a reward in the same program. Rejected rewards do not count | phone, card, device |
disposable_email | The email sent with this request, or the order's customerEmail, uses a disposable domain | none |
"Referred" includes referrals another partner later displaced. Duplicate accounts looks back days for accounts built up over time. Rapid repeats looks at a burst of minutes and is the rule to use for IP addresses. With only devices compared, rapid repeats fires on a subset of what duplicate accounts already catches, so it matters once you enable ipReferrals or relax duplicate accounts.
Programs accept fraudChecks. Omitted rules and settings use these defaults, and programs saved before this setting use them too. Self-referral blocks by default. The other rules only flag until you choose review or block:
{
"selfReferral": { "action": "block" },
"duplicateAccounts": {
"action": "flag",
"accounts": 3,
"windowDays": 30,
"ipAccounts": null
},
"rapidRepeats": {
"action": "flag",
"referrals": 3,
"windowMinutes": 60,
"ipReferrals": null
},
"repeatCustomer": { "action": "flag" },
"disposableEmail": { "action": "flag" }
}In the dashboard, a program's Fraud checks setting offers Standard, Strict, Off and Custom. Custom edits each rule's action and these thresholds.
Each action is one of:
| Action | Effect |
|---|---|
block | No partner reward and no friend incentive. A linked customer ID or strong identity match blocks. A rule that fired on an IP address alone is flagged instead |
review | The reward starts pending and waits for POST /rewards/:id/approve or POST /rewards/:id/reject, even in programs without reviewRewards |
flag | The reward follows reviewRewards as usual, records the match and sends self_referral.detected or fraud.flagged. The default for other rules |
off | The rule does not run |
disposableEmail cannot block: a throwaway address does not show a second person. Thresholds are whole numbers from 2 to 200, the lookup limit below, so every accepted threshold can be reached. windowDays runs from 1 to 365 and windowMinutes from 1 to 10080. PUT /programs/:id keeps the stored fraudChecks when it is omitted and replaces them when sent, filling omitted settings with defaults.
ipAccounts and ipReferrals default to null, which leaves IP addresses out. Everyone on a store's Wi-Fi, an office network or a mobile network's shared address looks like one person, so an in-store QR program would hold nearly every genuine reward. Turn IP counting on only for online referrals, with a high threshold such as 20 customers or 10 referrals an hour.
The customer being checked counts toward a threshold only when the partner referred them inside the rule's window, so a repeat purchase long after the referral does not count the buyer again.
Lookups are bounded. For each signal a rule reads at most the 200 other customers who used it most recently, and for the customer's own stored signals it reads the 5 most recent of each type, plus the ones sent with the request. When more than 200 customers share a signal in the window, the partner's referred customers among older users of the signal can be missed. That only happens for addresses shared at a scale where the rule should be off. The disposable domain check reads only the domain, and Grizzlee stores nothing new about the address.
When fraud rules fire, rewards, order and milestone decisions, and the checkout responses of POST /referrals/incentive, POST /referrals/incentive/reservations carry fraud: action (blocked, review or flagged) is the strongest rule's action, and rules lists each rule with rule, its own action, signals (matching signal types, empty for disposable_email) and count (customers counted for the two velocity rules). A blocked decision is skipped with a reason that starts with Fraud check blocked.
A rule set to block skips a checkout candidate with reason fraud. With no valid candidate left, incentive responses return no_referral. A lookup of a recorded referral returns fraud when blocked. A repeated orderId that now blocks releases its open hold and returns reason fraud. A self-referral block wins over a fraud block and returns self_referral. POST /referrals/code does not run fraud rules: it records the referral, so the partner's referrals keep counting toward the velocity rules. POST /orders/complete and milestone reports check again with the signals stored by then and skip a blocked reward. They do not take back a discount already applied.
The dashboard's Needs review tab under Rewards lists every reward a self-referral check or fraud rule recorded, held ones first, with the reasons and approve and reject buttons. GET /company?rewardStatus=review returns the same list. report.heldRewards counts the pending rewards waiting for a person, and automatic approval skips them.
The sandbox has no hashing key, so fraud rules never fire there. A live POST /simulate runs them with the customer's stored signals.
Disposable email domains
Grizzlee checks email domains against the community-maintained disposable-email-domains blocklist. A domain matches when it or one of its parent domains is on the list. Grizzlee updates the list through releases.
Refunds and errors
Send order.refunded to /events with a stable event ID and the original order ID. If Grizzlee has not recorded that order's completion, it returns 409 order_not_completed. Send the completion first, then retry the unchanged refund with the same event ID. Grizzlee records no refund event ID, ledger entry or webhook on this error. HTTP 409 identifies an ordering conflict that completion can resolve, so the merchant can retry without treating it as a missing resource.
refundedCommissionableCents is the cumulative successful refund of the order's commissionableCents. refundedTotalCents is the cumulative successful refund of its totalCents. After an order adjustment, both measure against the adjusted totals.
The dashboard can record a 50% refund from an offer reward. It disables that action when either cumulative order refund reaches half its current amount or the order is fully refunded. Offers have no money value, so this check uses the order amounts.
Percentage refunds and restorations use cumulative refundedCommissionableCents against the saved reward basis. A missing required field returns HTTP 400 with code missing_refund_commission_base. Send refundedTotalCents for reporting when the order included totalCents. Rewards earned before this rename under a subtotal program retain their historical total basis and use refundedTotalCents. Program edits do not change earned rules. fullRefund: true reverses all associated rewards without requiring cumulative fields. Only this explicit flag marks an order fully refunded. If you also send refundedTotalCents, it must match the current reporting total.
Fixed rewards use their saved fixedRefundMode. "prorate" reverses the reward in proportion to refundedCommissionableCents / commissionableCents, using adjusted values after an order adjustment. "full_refund_only" keeps the reward until an event sends fullRefund: true. Refunding the entire commissionable amount without that flag can prorate a reward to zero, but does not mark the order fully refunded. A later lower total restores rewards with a new ledger entry. A reversal of a delivered reward creates partner debt.
A lower cumulative refund total restores the reward. Send it when a refund fails or you cancel it. Grizzlee recalculates each reward from the rules saved when it was earned and appends a positive restoration ledger entry for the difference. Earlier ledger entries never change. A reward that a full refund reversed returns to pending or approved, whichever it was before the reversal, and the order is no longer marked fully refunded.
A 10% reward on a $100.00 order is worth $10.00. Refunding $50.00 reverses $5.00:
{
"type": "order.refunded",
"eventId": "refund-1",
"orderId": "order-123",
"refundedCommissionableCents": 5000,
"refundedTotalCents": 5000,
"occurredAt": "2026-10-09T12:00:00.000Z"
}If $30.00 of that refund fails, send the new total of $20.00. Grizzlee restores $3.00, the reward is worth $8.00, and the response has "restoredCents": 300:
{
"type": "order.refunded",
"eventId": "refund-1-failed",
"orderId": "order-123",
"refundedCommissionableCents": 2000,
"refundedTotalCents": 2000,
"occurredAt": "2026-10-10T12:00:00.000Z"
}Send every cumulative amount the original order had. An explicit full refund sets the stored refund amounts to the current order amounts. A later correction should send both cumulative fields to correct commission and reporting together.
Grizzlee orders refund events by occurredAt. A refund event older than the latest refund already applied to the order is a stale snapshot. Grizzlee records its event ID, changes nothing, sends no order.refunded webhook and returns "stale": true with no error. A newer event applies even when its total is lower. Two events with the same occurredAt both apply in the order they arrive. A repeated total adds no ledger entries, and a retried event ID returns the original result.
Restoring a reward sends the reward.restored webhook with reward, restoredCents and the ledger fields listed under webhooks. reward.status shows the status it returned to. No separate reward.approved webhook follows. Webhook endpoints receive only the events they list, so add reward.restored to existing endpoints that track reward balances. A restored reward counts toward reward caps and performance tiers again. Once it is approved, its restored amount pays down any debt the reversal left before it adds to available earnings.
Order adjustments
An order's amount can change after it completes: a delivery company adds charges at the door, or you correct a price. Send order.adjusted to /events with a new event ID, the order ID and the order's new totals. The SDK's adjustOrder sends the same event. Re-sending order.completed with different amounts still fails with 409 order_conflict.
{
"type": "order.adjusted",
"eventId": "adjust-1",
"orderId": "order-123",
"commissionableCents": 15000,
"totalCents": 15000,
"reason": "Extra charges at delivery",
"occurredAt": "2026-10-09T12:00:00.000Z"
}commissionableCents and totalCents are the order's new totals, not the change. Send at least one. Each must be a field the order completed with, otherwise the event returns 400 invalid_adjustment. A field you leave out keeps its current value. reason is optional, up to 500 characters. The dashboard shows it, order.adjusted carries it, and erasing the customer replaces it with [erased]. If Grizzlee has not recorded the completion, the event returns 409 order_not_completed. Send the completion, then retry with the same event ID.
Grizzlee recalculates every reward from the order with the rules saved when it was earned. Program edits since then change nothing.
- A percentage reward applies its saved rate, including any schedule phase or tier bonus it earned with, to the commissionable amount. A lower commissionable amount lowers it, down to zero. Duration rules are not checked again, because the order already qualified.
- A fixed reward keeps its amount. With
fixedRefundMode: "prorate", partial refunds are measured against the adjusted commissionable amount, so a newcommissionableCentschanges how much of the reward an earlier partial refund reversed. - A rejected reward stays reversed.
- When a reward's value after refunds would go up, the increase stops at what the customer and partner caps saved with the reward still allow. A percentage reward rises less. A reward that can't rise less, such as a fixed reward whose earlier refund now reverses a smaller share, keeps more of its reversal. A later refund event recalculates that reversal from the refund totals, the same way restorations do.
- An adjustment never creates a reward, including for programs that skipped the order, and never counts as a new order for usage.
- The friend incentive recorded with the order does not change.
Each reward whose value changed gets one adjustment ledger entry with the signed difference. Earlier ledger entries never change. The reward's originalCents keeps what it first earned, and adjustedCents is the signed total of all adjustments. A reward is worth originalCents + (adjustedCents ?? 0) - reversedCents. Older payloads omit adjustedCents, so treat it as zero. A reward adjusted down to zero becomes reversed, and a later higher total returns it to pending or approved. The extra value of an approved reward is available right away. The response has increasedCents, the reward value the adjustment added, and reversalCents, the value it removed.
Refunds after an adjustment apply to the adjusted amounts. refundedCommissionableCents cannot exceed adjusted commissionableCents, and refundedTotalCents cannot exceed adjusted totalCents. An adjustment below either amount already refunded returns 400 invalid_adjustment. Reaching a refunded amount never marks the order fully refunded or returns a friend incentive use. Only fullRefund: true does that. Adjusting a fully refunded order returns 409 order_fully_refunded. A later refund correction can restore it. Send an adjustment before refunds of added charges.
A 10% reward on a $100.00 order is worth $10.00. Adjusting the order to $150.00 adds $5.00, and the reward is worth $15.00. A later refund of $50.00 reverses $5.00. Adjusting the order to $120.00 after that leaves a reward of 10% of $70.00, $7.00.
Grizzlee orders adjustments by occurredAt, the same way it orders refunds. A stale refund is recognised before its total is compared with the adjusted amounts, so it returns "stale": true rather than an error. An adjustment older than the latest one applied to the order records its event ID, changes nothing, sends no webhook and returns "stale": true. Two adjustments with the same occurredAt both apply in the order they arrive. A retried event ID returns the original result.
Each applied adjustment sends order.adjusted, and each reward whose value changed sends reward.adjusted with changeCents. A lower total on a merchant reward you already delivered records partner debt, and a higher one reduces that debt first, then adds to what you owe the partner. See Refunds after delivery. The order in the company response keeps totalCents and commissionableCents as completed and adds adjustedTotalCents, adjustedCommissionableCents, lastAdjustedAt and adjustments, the list of applied adjustments with their previous totals and rewardChangeCents.
Reward delivery
Grizzlee decides what a partner earned and tracks it. Each program chooses who delivers approved rewards, separately for cash and store credit:
grizzlee_walletis the default. Approved rewards add to the partner's Grizzlee wallet. Cash leaves through Grizzlee payouts or Stripe transfers, and credit throughPOST /credits/redeem.merchantmeans your own system pays the partner, for example a Stripe Connect payout run or your own credit ledger. These rewards never reach the Grizzlee wallet, so wallet balances, payouts and credit redemptions cannot spend them. You confirm each delivery through the API.
Each reward saves the mode its program had when it was earned in reward.deliveryMode. Changing a program's mode affects rewards earned after the change. Earlier rewards keep their mode, so one earning can't be paid through both paths. Rewards earned before delivery modes existed use the wallet.
Delivery endpoints need a key with the rewards:deliver scope. The key selects the company. Address rewards by their Grizzlee ID, which every reward webhook and the company response include.
Confirm a delivery
POST /rewards/:id/deliveries records one attempt. Report a delivery with your own reference, such as a Stripe transfer ID or ledger entry ID:
{
"status": "delivered",
"externalReference": "tr_1Q2w3E4r",
"deliveredAt": "2026-10-12T15:00:00.000Z"
}Report a failed attempt with a reason and a reference. This is a breaking change. Failure requests that omit externalReference now return 400. Reuse the reference on retries:
{
"status": "failed",
"reason": "Connected account is restricted",
"externalReference": "attempt-1"
}A new record returns 201 with { "data": RewardDelivery }. The record has id, rewardId, partnerId, status, externalReference, amountCents, currency, rewardType, deliveredAt or failureReason, optional nettedDebtCents and createdAt. amountCents is the gross amount still owed for the reward when you reported the attempt. deliveredAt defaults to the time Grizzlee records the delivery.
Only approved rewards in merchant mode can be delivered. A wallet reward returns 409 with code wallet_delivery. A pending or fully reversed reward returns 409 with code reward_not_approved. A reward in an open merchant payout returns 409 with code reward_in_payout; mark that payout paid or cancel it instead. A failed attempt leaves the reward undelivered, and you can report a delivery afterwards.
Retries are safe. Repeating a delivery with the same externalReference returns the original record with status 200. A new reference delivers the amount still owed, including credit restored after debt recovery or netting. If nothing is owed, a new reference returns 409 with code delivery_conflict. The same reference with a different deliveredAt or nettedDebtCents returns 409 with code idempotency_conflict. A failure with a reference you already reported returns the original record. A failure reported when a delivered reward has nothing left owed returns 409 with code reward_delivered. A failure replay with a changed reason returns idempotency_conflict. A direct delivery can pay the remainder after a paid payout when no open payout holds the reward. One reference can cover several rewards, so a single transfer can pay many rewards.
Each new record sends a reward.delivered or reward.delivery_failed webhook with reward, delivery and the ledger fields listed under webhooks.
GET /rewards/:id/deliveries returns { "rewardId", "deliveryMode", "deliveries", "debts" }. deliveries lists every attempt, oldest first. debts lists the reward's delivery debts with their settlements. Records are never edited or deleted.
Partner-level credit delivery and debt recovery
Use these calls when your billing system holds a partner's credit. Both require rewards:deliver and a live company. They keep the same delivery and settlement records as the per-reward endpoints, which remain available.
POST /partners/by-external-id/:externalId/credit/deliveries confirms credit you added to the partner's balance:
{
"externalReference": "credit-ledger-42",
"amountCents": 600,
"currency": "CAD"
}Grizzlee allocates the amount across approved merchant credit rewards in that currency, oldest approval first. Rewards that started approved use their creation time. Equal approval times sort by reward ID. The last reward can receive a partial delivery. Wallet rewards and cash rewards are excluded. The response is { "data": { "partnerId", "externalReference", "amountCents", "currency", "deliveries" } }. deliveries contains the allocated RewardDelivery records in allocation order. Their amounts sum to the requested amount. Each new delivery sends reward.delivered. An amount above balances.merchant.credit.approvedUndeliveredCents returns 409 invalid_delivery and records nothing.
POST /partners/by-external-id/:externalId/debts/recover confirms debt you collected outside a delivery:
{
"externalReference": "credit-ledger-43",
"amountCents": 300,
"currency": "CAD",
"rewardType": "credit"
}Grizzlee settles outstanding delivery debts in that currency and reward type, oldest creation time first, then debt ID. The last debt can receive a partial settlement. The response is { "data": { "partnerId", "externalReference", "amountCents", "currency", "rewardType", "settlements" } }. settlements contains DeliveryDebtSettlement records in allocation order, each with kind: "recovered" and the same reference. Each new settlement sends reward.debt_settled. An amount above outstanding debt returns 409 invalid_recovery and records nothing. rewardType can also be cash. Cash recovery respects debt reserved for open merchant payouts and returns 409 debt_in_payout when the requested amount exceeds unreserved debt.
Amounts must be positive integer minor units. Zero amounts return 400. The partner comes from the authenticated key's company. A missing partner returns 404.
A new batch returns 201. The same reference returns 200 and the exact original allocation, even after refunds, restorations or later deliveries. Reusing it with a different amount, currency or recovery reward type returns 409 idempotency_conflict. References are scoped to company, partner and operation. Recovery shares its reference scope across cash and credit. Use a new ledger entry ID for each operation. A reference already used by a matching direct reward delivery or debt recovery cannot become a new batch reference.
Read getPartnerSummary({ externalId }) after reward webhooks. Compare balances.merchant.credit.version to the last version you applied, then issue approvedUndeliveredCents and collect up to debtCents when credit is available. Re-read after confirmation. Existing reward webhooks are enough, so there is no credit balance webhook.
Serialize reconciliation for each partner in your billing system. Save your ledger entry and its confirmation request together. If confirmation times out, retry that exact request before creating another ledger entry. The balance version orders reads. It does not reserve credit or prevent rewards changing between your ledger write and confirmation. If a new request returns 409 after your ledger write, reconcile that ledger entry against a fresh summary before issuing more credit.
Refunds after delivery
A refund that reverses part of a delivered reward leaves the partner holding money they no longer earned. Grizzlee records that amount as a delivery debt against the partner, in the reward's currency and reward type. The reward.reversed webhook still fires as usual, and a reward.debt_recorded webhook follows it. Read the debt from GET /rewards/:id/deliveries. Each debt has id, rewardId, deliveryId, eventId, amountCents, outstandingCents and settlements.
You settle a debt in one of two ways:
- Keep part of a later delivery. Send
nettedDebtCentswith that delivery, for example{ "status": "delivered", "externalReference": "tr_9", "nettedDebtCents": 300 }. The amount can't exceed what is still owed for the reward or the partner's outstanding debt in that currency and reward type. Grizzlee applies it to the oldest debts first. - Collect it another way, then call
POST /partners/by-external-id/:externalId/debts/:debtId/recoverwith{ "externalReference": "...", "amountCents": 300 }. OmitamountCentsto recover the whole outstanding amount. The response is the debt with its newoutstandingCents. A repeated reference returns the debt unchanged. A new reference on a settled debt returns 409 with codedebt_settled.
Open merchant payouts hold the cash debt they net until they are paid or cancelled. A recovery, a delivery's nettedDebtCents or a new payout's nettedDebtCents can only use the partner's outstanding cash debt in that currency minus what open payouts hold. More returns 409 debt_in_payout. Pay or cancel the payout first.
A milestone revocation, a reversal of a reward's source report, or a report reversal that leaves a bonus below its count reverses the whole reward and records debt the same way. A reinstatement, or a report that brings the count back, restores it and reduces the debt the same way as a lower refund total. An order adjustment to a lower total records debt the same way, and an adjustment to a higher total reduces it.
Each settlement sends a reward.debt_settled webhook after a recovery, a delivery or paid merchant payout that netted the debt, a reward restoration, or a Stripe transfer reversal that returned overpaid cash.
A restoration, when the cumulative refund total goes down or a milestone is reinstated, reduces that reward's outstanding debt before anything else. If the restoration is larger than the outstanding debt, for example because you already recovered it, the partner is owed the difference. Pay it with a new direct delivery reference or a merchant payout that allocates that amount to the reward.
Payouts
Creating, paying, cancelling and reading a single payout need a key with the payouts:write scope. Listing a partner's payouts also accepts read, as part of the partner read API. Address the partner by your external ID with /partners/by-external-id/:externalId/payouts, or by Grizzlee's ID with /partners/:id/payouts. Every payout has a mode.
Wallet payouts
A wallet payout reserves the partner's available wallet cash, the same as a payout created in the console:
{
"mode": "grizzlee_wallet",
"eventId": "payout-2026-10-12-store",
"amountCents": 2500,
"provider": "manual"
}provider: "stripe" also needs integrationId, and Grizzlee sends the transfer. destination is the connected account and defaults to the partner's stripeAccountId. Without either, the request returns 409 missing_stripe_account. provider: "manual" means you send the money and then mark the payout paid. A payout larger than the available wallet cash returns 409 with code insufficient_balance. Available cash leaves out approved rewards still inside the payout terms' hold period. See Payout schedule. Wallet payouts have no allocations, because wallet balances are amounts rather than specific rewards.
Merchant payouts
A merchant payout pays specific rewards from programs where you deliver rewards. Use it when one transfer from your own system, such as a Stripe Connect payout run, pays many rewards:
{
"mode": "merchant",
"eventId": "connect-run-2026-10-12-store",
"nettedDebtCents": 300,
"allocations": [
{ "rewardId": "reward-1", "amountCents": 1000 },
{ "rewardId": "reward-2", "amountCents": 450 }
]
}amountCents is the gross payout amount, equal to the sum of the allocations. If you also send amountCents, it must match, or the request returns 400 with code invalid_payout_amount. Each allocation must be an approved cash reward in merchant mode that belongs to the partner. Its amount can't exceed what is still owed after other open payouts allocate their amounts. Grizzlee calculates what is owed as the reward's value after reversals minus what the partner still holds from all deliveries. Debt recovery and netting reduce what the partner holds. An allocation can pay part of a reward, or the difference a restoration left after delivery. Grizzlee checks this and returns 409 with one of these codes:
allocation_partner_mismatchfor another partner's rewardcurrency_mismatchfor a reward in another currencywallet_rewardfor a reward the Grizzlee wallet paysallocation_not_cashfor a store credit rewardreward_not_approvedfor a pending or reversed rewardallocation_exceeds_owedwhen the amount is more than the reward still owes
A reward from another company returns 404. Merchant payouts never reserve or release wallet cash.
provider defaults to manual: you send the money and mark the payout paid. With "provider": "stripe" and integrationId, Grizzlee sends netAmountCents from your Stripe balance through that Stripe connection to destination, or to the partner's stripeAccountId when destination is left out, then marks the payout paid. See Stripe transfers. A manual payout with integrationId or destination returns 400 invalid_payout. A Stripe payout whose netting leaves nothing to transfer returns 400 invalid_payout_amount.
Send optional nettedDebtCents to keep part of the gross payout against outstanding delivery debt. It defaults to 0 and cannot exceed the gross amount or the partner's outstanding cash debt in the payout currency. A larger amount returns 409 invalid_netting. The payout exposes nettedDebtCents and netAmountCents. Transfer netAmountCents, which equals amountCents - nettedDebtCents. A fully netted payout has a zero transfer amount. Mark it paid with your settlement reference.
Creating a payout records no debt settlements. Marking it paid rechecks outstanding debt and settles the oldest matching debts first, in the same transaction as deliveries. Recoveries and other netting cannot take debt an open payout holds. A lower refund total can still restore debt the payout nets, and then marking it paid returns 409 invalid_netting. Cancel it and create a payout with the current netting amount. Cancelling records no settlements.
Pay, cancel and read
Create returns 201 with { "data": Payout }, where the payout includes its allocations. Retrying with the same eventId returns the original payout with status 200. The same eventId with a different partner, mode, amount, nettedDebtCents, allocations, provider, integrationId or destination returns 409 with code idempotency_conflict. A retry that leaves destination out matches the account the first request used.
POST /payouts/:id/paid takes { "externalReference": "po_1Q2w3E4r", "paidAt": "2026-10-12T15:00:00.000Z" }. paidAt is optional and defaults to now. Marking a merchant payout paid records a delivery for each allocated reward, with the payout's reference and payoutId, in the same transaction. Each delivery sends reward.delivered. If a refund reduced a reward after the payout was created, the partner now holds more than the reward is worth, and Grizzlee records a delivery debt for the difference with eventId set to payout:<payout id>. Repeating the call with the same reference returns the payout. A different reference on a paid payout returns 409 with code payout_finished. For a Stripe payout, this call only accepts a held transfer in needs_action, and externalReference must be the tr_ transfer ID from Stripe.
POST /payouts/:id/cancel cancels a payout that has not been paid. A wallet payout releases its reservation. A merchant payout frees its rewards for another payout or delivery. Cancelling twice returns the cancelled payout. A Stripe transfer that may have reached Stripe returns 409 with code already_submitted.
GET /payouts/:id returns one payout with its allocations. GET /partners/.../payouts returns { "payouts": [...], "nextCursor": "..." }, newest first, ordered by creation time and then ID. Pass nextCursor as cursor to read the next page. limit accepts 1 to 100 and defaults to 50. nextCursor is null on the last page. A cursor only works for the partner and company that issued it. A key with read but not payouts:write gets the same payouts without destination, the partner's Stripe connected account.
Every payout.created, payout.paid, payout.reversed, payout.failed, payout.needs_action and payout.cancelled webhook carries the payout's fields plus partnerExternalId and allocations. Merchant payouts include gross amountCents, nettedDebtCents and netAmountCents in webhooks and partner payout reads. Older merchant payouts report zero netting and a net amount equal to the gross amount. Wallet payouts send "allocations": []. Allocations have id, payoutId, partnerId, rewardId, orderId, programId, currency, amountCents and createdAt, and are never edited or deleted.
Payout schedule
Instead of deciding when to pay each partner, you can set payout terms once for the company. On each due date, Grizzlee creates one merchant payout per partner and sends payout.created. You send the money, then call POST /payouts/:id/paid, or POST /payouts/:id/cancel if you won't pay it. The schedule only pays approved cash rewards from programs where delivery.cash is merchant. Wallet rewards and store credit are never scheduled. The terms apply to every program in the company.
GET /payout-schedule returns the terms. PUT /payout-schedule replaces them:
{
"enabled": true,
"frequency": "weekly",
"weekday": "monday",
"minimumCents": 2000,
"holdPeriodDays": 14,
"stripeIntegrationId": null,
"missingStripeAccount": "manual"
}frequencyisweeklyormonthly. Weekly terms takeweekday,mondaytosunday. Monthly terms takedayOfMonth, 1 to 28, so every month has that day.minimumCentsis the smallest net transfer. It defaults to 0. A partner whose net amount, after debt netting, is below it gets no payout, and the balance waits for the next due date.holdPeriodDaysdefaults to 30 and accepts up to 365. A run only pays rewards approved at least this many days before the due date, so a refund inside that window reverses the reward before it is paid. The approval time is the reward's first approval, or its creation time when the program doesn't review rewards. This hold is separate from program reward review, which happens first. The same hold applies to wallet cash: an approved wallet cash reward counts toward the available balance only once it was created at least this many days ago, even whileenabledis false. Companies that never saved terms use 30. Terms saved before the default changed keep their value, and aPUTthat leaves the field out saves 30.enabled: falsekeeps the terms but creates no payouts.stripeIntegrationIdis the ID of an active Stripe connection with a secret key, ornull, the default. When set, partners with astripeAccountIdare paid by Stripe transfer. An unknown, disabled or keyless connection returns 409invalid_connection.missingStripeAccountsays what a run does for a partner without astripeAccountIdwhilestripeIntegrationIdis set.manual, the default, creates a manual payout for you to send.waitcreates no payout. See Waiting for a Stripe account.waitwithoutstripeIntegrationIdreturns 400validation_error.
PUT replaces every term. A body without stripeIntegrationId turns Stripe payouts off, and a body without missingStripeAccount goes back to manual. Send both on every save.
The body accepts only these fields. Sending back the full GET response, with activeSince or nextRunOn, returns 400.
Both calls return the terms with activeSince, lastRunOn, nextRunOn and updatedAt. Before you save terms, GET returns disabled monthly terms on day 1 with updatedAt: null. Due dates are UTC days in YYYY-MM-DD form. A run starts at 00:00 UTC on the due date, or as soon as the worker next checks after that. A nextRunOn in the past means the run is still waiting for the worker.
Changed terms take effect the day you save them, and activeSince records that time. The first run is the first due date on or after that day, so saving terms never pays for a due date that already passed. Saving identical terms again changes nothing. If the worker misses several due dates, it runs only the latest one.
On a due date, each partner gets at most one scheduled payout. It allocates what each eligible reward is still owed, oldest approval first, after deliveries and other open payouts, with up to 500 rewards per payout. Rewards past that limit wait for the next due date. Grizzlee nets the partner's outstanding cash delivery debt, after subtracting debt that the partner's other open payouts already net, up to the gross amount. Transfer netAmountCents. A run never creates a payout with a zero transfer amount.
A scheduled payout is a normal merchant payout with mode: "merchant". Its provider is stripe for a partner with a stripeAccountId when the terms name a Stripe connection, and manual otherwise. Its eventId is usually schedule:<due date>:<partner id>, but recognize scheduled payouts by schedule, not by the event ID. schedule has dueOn, the run's due date, and approvedBefore, the latest approval time the run paid. Manual payouts have no schedule. Mark a scheduled payout paid or cancel it like any other merchant payout. Cancelling frees its rewards, which the next due date picks up. The same due date never creates a second payout for that partner.
Waiting for a Stripe account
With "missingStripeAccount": "wait", partners can join and earn before they finish payout setup, and nobody has to pay them by hand. On a due date, a partner without a stripeAccountId gets no payout. The run still applies the hold period and minimum first, so it only holds back a payout it would otherwise have created. The rewards stay approved and payable, and roll over like a balance below the minimum. Once you set the partner's stripeAccountId, the first due date after that pays everything that rolled over by Stripe transfer. Grizzlee does not pay earlier than the next due date. To pay sooner, create a one-off payout.
The first run that holds a partner's payout sends payout.account_required and the partner's payout details needed email, when that email is on. Later runs that hold the same partner send nothing more. The wait ends when a run pays the partner or finds nothing payable for it. A later wait sends the webhook and email again. Saving terms with manual ends every wait in the company.
The partner summary shows the wait in payoutAccountRequired, awaitingPayoutAccount and awaitingPayoutAccountSince, and so does the console partner page.
Scheduled and manual payouts share their allocation rules, so they never pay the same amount twice. A reward in an open scheduled payout can't be allocated to a manual payout or delivered directly. Those calls return 409 allocation_exceeds_owed or reward_in_payout. Later runs skip what open payouts already hold. You can keep creating one-off payouts with POST /partners/.../payouts while a schedule is on.
Stripe transfers
Subscribe to transfer.reversed on the webhook endpoint in the merchant Stripe account that sends transfers. Grizzlee handles this signed event through the connection's existing queue, without a polling window. Select payout.reversed on your outgoing Grizzlee webhook endpoint too.
A full or partial reversal changes the payout to reversed. reversedCents is the cumulative cash returned to the merchant and reversedAt is when Grizzlee last recorded an increase. paidAt and providerReference keep the original payment history. netAmountCents stays the original transfer amount. The unreversed cash still counts as paid.
Grizzlee appends a reversal to the delivery ledger for each affected merchant reward. Each record has status reversed, a positive amountCents, payoutId and reversalOfId, the original delivery ID. It restores cash in delivery creation order, with ties sorted by ID, and never reverses debt netting. Earned rewards, saved rules, original deliveries and allocations stay intact. Approved rewards become payable by the next scheduled run or a new one-off payout. If the reward was refunded, the returned cash reduces outstanding delivery debt first and sends reward.debt_settled with kind restored. Wallet payouts append a positive payout.reversed wallet entry instead.
The connection and its company select the payout. Grizzlee checks the transfer ID, amount, currency and destination. A company transaction applies only increases in Stripe's cumulative amount_reversed, so repeated or older events restore nothing twice. A reversal that arrives before the transfer worker records payment first records the original transfer, then its reversal, and clears the worker's lease. Unrelated transfers are ignored. Detection requires the connection to remain active and Stripe to deliver the event. Grizzlee does not scan past paid transfers for missed events.
Grizzlee can send payouts from your own Stripe balance to partners' Stripe connected accounts. The money moves inside your Stripe account. Grizzlee never holds it. This is the merchant delivery mode with Stripe doing the transfer, so programs keep delivery.cash: "merchant".
Setup:
- Add a Stripe connection with a secret key. A restricted key needs Transfers: Write, which covers creating and reading transfers. Refund checks on the same connection also read charges and refunds. Grizzlee makes no other Stripe calls for payouts.
- Set
stripeAccountIdon each partner. The account must be connected to the Stripe account that owns the key and have thetransferscapability. - Save the payout schedule with
stripeIntegrationId, or create one-off merchant payouts with"provider": "stripe".
Grizzlee creates the payout first, sends payout.created, and commits it. A worker then sends the transfer outside that transaction: netAmountCents in the company currency to destination, with idempotency key grizzlee:<company ID>:<payout ID>, metadata grizzlee_workspace and grizzlee_payout, and transfer_group grizzlee_payout_<payout ID>, also saved on the payout as transferGroup. On success the payout becomes paid with the tr_ transfer ID in providerReference and paidAt set, each allocated reward gets a delivery with that reference, netted debt is settled, and Grizzlee sends payout.paid, reward.delivered and any reward.debt_settled.
Grizzlee records each transfer request before sending it. From then on Stripe may hold a transfer, even when the answer was an error, so Grizzlee never fails or cancels that payout on its own and never frees its rewards or wallet reservation. It retries or holds the payout for you.
Before every retry Grizzlee lists the transfers in the payout's transfer group. Exactly one transfer with the payout's amount, currency and destination marks the payout paid without sending again. Any other transfer in the group holds the payout. If Stripe cannot be asked, or the list is longer than 2,000 transfers, Grizzlee sends nothing and tries later. Payouts first sent before transfer groups existed are searched by destination and grizzlee_payout metadata instead.
A worker holds a five-minute lease on a payout and sends only while it holds the lease with at least a minute left. Each Stripe request times out after 10 seconds. A worker that pauses cannot send after another worker takes over, unless its process freezes for longer than a minute in the instant between that check and the request leaving. If it then wakes more than 24 hours later, after Stripe forgot the idempotency key, Stripe could create a second transfer. Grizzlee does not guard against that case.
| Situation | Status | Webhook | Rewards |
|---|---|---|---|
| Before any request: the Stripe connection is disabled or has no secret key | failed | payout.failed | Payable again |
| Before any request: an allocated reward was reversed or the netted debt was settled another way | cancelled | payout.cancelled | Payable again |
| Network error, timeout, rate limit, Stripe server error, or Stripe could not be searched | queued again, retried after 30 seconds, doubling each time | none | Stay in the payout |
Stripe refuses the transfer: balance_insufficient (also returned when your balance has no funds in the payout currency), an account without the transfers capability or not on your platform, an invalid key, or a key without transfer permission | needs_action, reason in lastError | payout.needs_action | Stay in the payout |
| Stripe holds a transfer for the payout that does not match it, or more than one | needs_action | payout.needs_action | Stay in the payout |
| Still unconfirmed after 6 attempts or 18 hours | needs_action | payout.needs_action | Stay in the payout |
Stripe keeps a refusal under the idempotency key, so Grizzlee does not retry one. A payout that failed or was cancelled frees its rewards. Scheduled ones wait for the next due date, because each due date creates one payout per partner. Create a one-off payout to pay sooner.
What to do with a needs_action payout:
- Read
lastError, then open the Stripe dashboard and look for transfer groupgrizzlee_payout_<payout ID>, or thegrizzlee_payoutmetadata for older payouts. - If Stripe paid it, call
POST /payouts/:id/paidwith thetr_ID. Grizzlee retrieves the transfer and checks amount, currency, destination and metadata before it marks the payout paid. - If Stripe has no transfer, fix the cause, such as funding the balance, then call
POST /payouts/:id/cancelwith{ "confirmNoTransfer": true }. Wait 24 hours after the last transfer request, which is how long Stripe keeps idempotency keys, so no request in flight can still create a transfer. Grizzlee then checks that no worker holds the payout, searches Stripe again, and cancels only when the search finishes and finds nothing. Otherwise it returns 409transfer_recentortransfer_found. Cancelling frees the rewards or reservation for a new payout.
POST /payouts/:id/cancel without confirmNoTransfer cancels a Stripe payout only before any request was sent. After that it returns 409 already_submitted.
A held payout can only be checked and released with the Stripe connection that sent it. If that connection's key stops working, there is no way yet to replace the key on the connection, so contact support.
If a transfer succeeded but its deliveries could not be recorded, for example because a lower refund total restored the debt it nets between the transfer starting and finishing, the payout becomes needs_action with the transfer ID in providerReference and the reason in lastError. Contact support rather than paying those rewards again.
A payout.created handler that sends money must check provider. Send money only for manual. Stripe payouts are informational until payout.paid, payout.failed, payout.cancelled or payout.needs_action arrives.
Partner read API
These endpoints let your own app show one partner their referrals, rewards and payouts. They need the read scope. The key picks the company, so a partner from another company returns 404 with code not_found. Address the partner by your external ID with /partners/by-external-id/:externalId/... or by Grizzlee's ID with /partners/:id/.... A partner whose external ID is summary, referrals or rewards is still read with GET /partners/by-external-id/summary, because that path has no view after the ID.
Responses list each field on purpose. They never include customer emails, partner emails, saved program snapshots, reward calculations or other company data. customerId is your own customer ID.
The partner read API needs a live company. The local sandbox returns 503 with code live_required.
Summary
GET /partners/.../summary returns the partner's profile, programs, click counts and balances:
{
"id": "6f1c...",
"externalId": "merchant/customer-123",
"name": "Alex Store",
"active": true,
"referralCode": "4f1c2d8e9a7b4c3d8e2f1a0b9c8d7e6f",
"clicks": { "total": 120, "last30Days": 35 },
"programs": [
{
"id": "program-1",
"name": "Customer referrals",
"active": true,
"member": true,
"currency": "CAD",
"rewardType": "cash",
"trigger": { "type": "order" },
"deliveryMode": "merchant",
"compensation": { "type": "fixed", "amountCents": 1000 },
"fixedRefundMode": "prorate",
"incentive": {
"type": "percent",
"rateBps": 2000,
"orders": 1,
"newCustomersOnly": true,
"expiresAfterDays": null,
"returnOnFullRefund": false,
"currency": "CAD"
},
"terms": {
"trigger": { "type": "order" },
"rewardType": "cash",
"currency": "CAD",
"compensation": { "type": "fixed", "amountCents": 1000 },
"duration": { "type": "first_order" },
"fixedRefundMode": "prorate",
"referralWindowDays": 30,
"text": {
"reward": {
"en": "$10 cash when your friend completes their first order after your referral",
"fr-CA": "10 $ en argent quand votre ami passe sa première commande après votre parrainage"
},
"incentive": {
"en": "20% off your first order",
"fr-CA": "20 % de rabais sur votre première commande"
},
"headline": {
"en": "Give 20%, get $10",
"fr-CA": "Offrez 20 %, recevez 10 $"
}
}
},
"link": "https://shop.example/join?ref=ALEX4K",
"linkUnavailableReason": null,
"clicks": { "total": 120, "last30Days": 35 },
"earnedCents": 2000,
"reversedCents": 0,
"referrals": { "referred": 4, "qualified": 2 },
"orderCount": 2,
"payouts": { "paidOutCents": 1000 },
"balances": {
"wallet": {
"cash": {
"rewardType": "cash",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedCents": 0
},
"credit": {
"rewardType": "credit",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedCents": 0
}
},
"merchant": {
"cash": {
"version": 7,
"rewardType": "cash",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedUndeliveredCents": 600,
"inOpenPayoutsCents": 400,
"deliveredGrossCents": 1000,
"debtCents": 0
},
"credit": {
"version": 7,
"rewardType": "credit",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedUndeliveredCents": 0,
"inOpenPayoutsCents": 0,
"deliveredGrossCents": 0,
"debtCents": 0
}
}
}
}
],
"payoutAccountRequired": false,
"awaitingPayoutAccount": false,
"awaitingPayoutAccountSince": null,
"balances": {
"wallet": {
"cash": {
"rewardType": "cash",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedCents": 0,
"reservedCents": 0,
"paidOrRedeemedCents": 0,
"availableToSpendCents": 0,
"debtCents": 0
},
"credit": {
"rewardType": "credit",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedCents": 2000,
"reservedCents": 0,
"paidOrRedeemedCents": 500,
"availableToSpendCents": 1500,
"debtCents": 0
}
},
"merchant": {
"cash": {
"version": 7,
"rewardType": "cash",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedUndeliveredCents": 600,
"inOpenPayoutsCents": 400,
"deliveredGrossCents": 1000,
"debtCents": 0
},
"credit": {
"version": 7,
"rewardType": "credit",
"currency": "CAD",
"pendingApprovalCents": 0,
"approvedUndeliveredCents": 0,
"inOpenPayoutsCents": 0,
"deliveredGrossCents": 0,
"debtCents": 0
}
}
}
}clicks has total and last30Days, both integer counts of recorded link and qr activity. Each programs entry has the same clicks fields for that program. Overall counts include all the partner's programs, even former memberships. last30Days covers the rolling 30 days ending at the summary's read time, including both boundaries. Empty counts are zero. Pausing a partner or program does not erase activity.
Code attachments count only when their channel is link or qr. The default code attachment channel, coupon, and the in_app channel do not count. The table does not store receipt sources separately, so the response has no codeAttaches count. Repeat visits count separately, and claiming, expiring or voiding a receipt does not remove its recorded activity. There is no visitor dedupe key, so these counts do not measure unique people or verified QR scans. A QR scan counts when the merchant's tracking integration records it. The embedded dashboard and console partner page show these counts.
Each programs entry also returns its totals:
earnedCents: reward value after reversals, including pending and approved rewards.reversedCents: the currently reversed reward value. Restorations reduce it.referrals.referred: distinct attribution IDs in this partner's referral history, including expired and displaced referrals. Renewals count once. Separate order-scoped referrals count separately.referrals.qualified: those attributions that still belong to this partner and havequalifiedAt. Qualification can come from an order or milestone. Refunds and milestone revocations do not remove a qualification.orderCount: the sum of each referral'sorderCountin this program, computed in the database across all referrals. Each referral counts distinct orders that earned this partner a reward. Renewals do not add orders. Orders that earned nothing and milestone rewards do not count. Refunds, including full reversals, do not lower the count. The total includes former memberships and has no pagination limit.payouts.paidOutCents: gross allocations in paid or reversed merchant payouts, less returned transfers, grouped by the allocated reward's program. A payout spanning programs contributes only each program's allocations. Open, failed and cancelled payouts contribute nothing. Debt netting does not lower this gross total. Direct reward deliveries are not payouts.balances.wallet.cashandbalances.wallet.credit:rewardType,currency,pendingApprovalCentsandapprovedCents, with the same meanings as the partner-wide wallet balances.balances.merchant.cashandbalances.merchant.credit: the complete merchant balance shape below, with the same meanings, scoped to rewards in this program.
Totals use the reward's saved type and delivery mode, so a later program edit cannot move past earnings between balances. Empty totals are zero. Wallet payouts and credit redemptions have no program allocations, so program wallet balances omit reservedCents, paidOrRedeemedCents, availableToSpendCents and debtCents. Read those from the partner-wide balances. payouts.paidOutCents excludes wallet payouts. Program totals cover current memberships, including paused programs, and former programs with rewards, referrals, payout allocations or debt. Leaving a program keeps these totals in the response. The embedded dashboard and console partner page label former programs and show their per-program totals.
programs lists current memberships in the partner's programIds order, then former programs with rewards, referrals, payout allocations or debt, sorted by program ID. member is true for current memberships and false for former ones. active describes the program's status, independently of membership. A former program returns link: null, linkUnavailableReason: "partner_inactive" and incentive: null. Its terms.text.incentive is also null, and its headline omits the friend offer. Click activity alone does not add a former program to this list.
trigger says whether orders or a named milestone earn the reward. Order programs carry compensation; milestone programs carry milestoneRewards. Both use the partner's custom terms when present, otherwise the program's own rules. fixedRefundMode is the program's current refund rule for fixed order rewards. Earned rewards keep their saved rule. incentive is what a customer the partner refers gets in that program, or null. display is the program's public name, headline and milestone label. Its headline falls back to the partner's own terms. link is the referral link to the program's published signup page destination, or to its destinationUrl when no page is published, built the same way as createReferralLink in the SDK. It carries the partner's alias as ?ref=ALEX4K. It adds program=<id> only when another of the partner's linkable programs leads to the same page, so a capture could not tell them apart. QR links add channel=qr. Links made before short codes, with grizzlee_ref, grizzlee_program and grizzlee_channel, keep working. A ref value that names no partner is not a referral, so a campaign tag such as ?ref=producthunt is ignored. When there is no link, link is null and linkUnavailableReason says why:
partner_inactive: the partner is paused or no longer belongs to the programprogram_inactive: the program is pausedno_published_page: the program has nodestinationUrland no published page with a destinationinvalid_destination: the stored destination is not an HTTP or HTTPS URL
terms describes the same deal in a form a screen can show without its own rules, such as "Give 20%, get $10". Read the structured fields first:
trigger:order, ormilestonewith the milestone name.rewardTypeandcurrency.milestoneRewards: each-report and at-count settings for milestone programs. Theircompensationanddurationare null.compensation:fixedwithamountCents, orpercentagewith the baserateBpsbefore tier bonuses,phases, the later rates by months after the customer's first rewarded order. It uses this partner's custom terms when the program has them.duration: which orders of a referred customer earn a reward, such asfirst_order,first_n_orderswithcount,days,monthswithmonths,lifetimeoronly_order. It isnullfor milestone programs, whosemilestoneRewardsdefine each-report rewards and at-count bonuses.fixedRefundMode: how a partial refund changes a fixed order reward. It isnullfor percentage and milestone rewards.referralWindowDays: how long a referral stays open until an order or milestone qualifies it. It isnullwhen the referral never expires.
terms.text holds short sentences built from those fields, in English under en, Canadian French under fr-CA, Armenian under hy and Russian under ru. reward is what the partner earns, for example "5% of each referred order" or "$50 cash when your referral completes 10 lessons". Milestone sentences name the program's milestone label in that language, or "step" and "steps" when it has none, never the milestone key. Referral programs call the referred person "your friend". Affiliate programs and programs that are both say "a customer you refer". incentive is what the friend gets, or null. headline puts both in a few words, or reads "Get $10" without a friend incentive. The text is generic and never names your business. It leaves out performance tier bonuses, caps and incentive expiry, so build your own copy from the structured fields when you need those.
Balances come in all four combinations of delivery mode and reward type, with zeros where nothing applies. A program's delivery setting decides which mode its rewards use. All amounts are integer minor units in the company currency.
Wallet balances count only rewards the Grizzlee wallet pays:
pendingApprovalCents: pending rewards after reversals.approvedCents: every approved reward after reversals, including amounts already reserved, paid or redeemed.reservedCents: cash held by wallet payouts that are queued, processing or need action. Always 0 for credit.paidOrRedeemedCents: cash paid by finished wallet payouts less returned transfers, or credit redeemed less restorations.availableToSpendCents: what a new wallet payout (cash) or credit redemption (credit) can use right now. It is approved value minus reserved, paid and redeemed amounts and reversals that came after money left the wallet. It never goes below 0.debtCents: reversals after money left the wallet that later earnings must cover first.
Merchant balances count only rewards you deliver yourself:
pendingApprovalCents: pending rewards the partner is still owed.version: a partner-wide accounting history count computed in the same database snapshot as the amounts. Cash, credit and program merchant balances share it. It counts merchant reward ledger entries and recorded approvals, successful deliveries, transfer reversals, debts, settlements, payout allocations and terminal merchant payouts. Existing history contributes to the initial version. Paid, failed and cancelled payouts never reopen. Retries, failed delivery attempts, wallet activity and personal-data redaction keep the version unchanged. It can skip numbers and can change when a displayed amount stays the same. Ignore reads below the last applied version.approvedUndeliveredCents: approved and owed, not delivered and not in an open merchant payout.inOpenPayoutsCents: approved and owed, allocated to a merchant payout that is queued, processing or needs action. Each reward counts at most what it still owes. If a refund cut a reward below its open allocation, only the smaller amount counts here, and paying that payout records a delivery debt for the rest.deliveredGrossCents: gross amounts delivered, less returned Stripe transfers, before reward refunds or debt settlements. Transfer reversals can lower it.debtCents: delivered amounts that later refunds reversed and the partner has not repaid.
pendingApprovalCents, approvedUndeliveredCents and inOpenPayoutsCents add up to what the partner is still owed.
Three fields report waiting for a Stripe account:
payoutAccountRequired: the payout schedule is on with a Stripe connection and"missingStripeAccount": "wait", the partner has nostripeAccountId, and the partner earns cash you deliver: it belongs to a program whose cash rewards you deliver, or has such cash pending or approved. Scheduled runs pay this partner nothing until it has an account. Partners who only earn credit are never flagged.awaitingPayoutAccount:payoutAccountRequiredis true and a run already held a payout for the partner.awaitingPayoutAccountSince: the due date,YYYY-MM-DD, of the first run that held it, ornull.
Referrals
GET /partners/.../referrals lists every customer this partner referred, including customers who have not bought anything yet and referrals another partner displaced:
{
"referrals": [
{
"attributionId": "a1b2...",
"customerId": "customer-42",
"programId": "program-1",
"orderId": null,
"channel": "link",
"referredAt": "2026-10-03T14:00:00.000Z",
"status": "claimed",
"qualifiedAt": null,
"firstOrderId": null,
"orderCount": 0,
"milestones": [],
"rewardedCents": 0,
"incentiveAppliedCents": 0
}
],
"nextCursor": null
}Until a purchase qualifies, a later referral by another partner takes the customer over. Grizzlee keeps every accepted referral in an append-only history, so the earlier partner still sees the customer with status displaced, and sends a referral.displaced webhook. Each customer appears once per program for each partner who referred them. Referrals displaced before this history existed are not recoverable.
status is one of:
claimed: this partner holds the referral and no purchase or milestone has qualified yet.qualified: a purchase or milestone qualified while this partner held it.qualifiedAtis that purchase's or milestone's time.displaced: another partner's later referral took it over before any purchase qualified.expired: this partner holds it, nothing qualified and the program'sattributionWindowDayshave passed. Referrals in milestone programs without a window never expire. A late purchase that the merchant reports with an earlieroccurredAtcan still qualify it.
referredAt is this partner's latest referral of the customer in the program. orderId is set only for order-scoped referrals in only_order programs. firstOrderId and orderCount describe orders from this referral that earned this partner a reward. milestones lists the milestones from this referral that earned this partner a reward. rewardedCents totals both after reversals. Orders that earned nothing are not counted. incentiveAppliedCents totals the friend incentive discounts the merchant applied to this customer's orders through this partner's referral. Refunds do not lower it.
Referrals are newest first, ordered by when Grizzlee first recorded this partner's referral and then by ID. The order does not change when the same customer is referred again.
Rewards
GET /partners/.../rewards lists the partner's rewards, newest first, ordered by creation time and then ID. Add status=pending|approved|reversed, programId=... or rewardType=cash|credit|offer to filter. Filters apply before pagination.
{
"rewards": [
{
"id": "reward-1",
"trigger": "order",
"orderId": "order-1",
"milestone": null,
"programId": "program-1",
"programVersion": 2,
"customerId": "customer-42",
"currency": "CAD",
"rewardType": "cash",
"status": "approved",
"amountCents": 1000,
"originalCents": 1000,
"reversedCents": 0,
"deliveryMode": "merchant",
"delivery": {
"status": "in_payout",
"externalReference": null,
"deliveredAt": null,
"payoutId": "payout-1"
},
"createdAt": "2026-10-04T00:00:00.000Z",
"approvedAt": "2026-10-04T00:00:00.000Z",
"availableAt": "2026-10-04T00:00:00.000Z"
}
],
"nextCursor": null
}Offer rewards add offer with terms including currency, status, usesRemaining, usedCount, heldCount and expiresAt. Usage subtracts uses returned after full refunds. usesRemaining excludes recorded uses but includes open holds; heldCount names those holds separately. Status is pending, used, voided, expired or available, checked in that order. A used offer stays used after reversal, and open holds do not change its status. Money amounts stay zero. Ignore delivery fields for offers.
trigger is order or milestone. An order reward has orderId and milestone: null. A milestone reward has the milestone name and orderId: null. programVersion is the program version the reward was earned under, or null for rewards earned before programs had versions. amountCents is the current value, originalCents + (adjustedCents ?? 0) - reversedCents. Older payloads omit adjustedCents, so treat it as zero. approvedAt is the first recorded approval. Rewards in programs without review start approved, so they use createdAt. It is null for a reward that was never approved.
delivery.status is one of:
not_applicable: the Grizzlee wallet pays this reward, or a merchant reward was fully reversed before any delivery.undelivered: you still owe the partner some of it. This includes the top-up a restoration leaves after a delivery.in_payout: an open merchant payout includes it.payoutIdis that payout.delivered: you delivered everything it owes.failed: the latest delivery attempt failed and something is still owed.
externalReference, deliveredAt and payoutId come from the latest delivered record. When the status is failed, externalReference is the failed attempt's reference instead. Each is null when there is nothing to show.
Payouts
GET /partners/.../payouts accepts the read scope as well as payouts:write. See Pay, cancel and read. Read-only keys get payouts without destination.
Pages
Every list returns nextCursor, which is null on the last page. Pass it back as cursor with the same partner to read the next page. limit accepts 1 to 100 and defaults to 50. Rows that share a creation time are ordered by ID, so pages never repeat or skip a row. A cursor only works for the list, partner and company that issued it. Any other cursor returns 400 with code invalid_cursor.
Embed sessions
An embed session shows one partner their Grizzlee dashboard inside an iframe on your site. Your server creates the session with a key that has embed:write, then puts the returned url in the iframe. The browser never sees your server key. Embedding the partner dashboard explains the flow and its threat model.
embed:write is its own scope. Keys created before it existed do not have it, so create a new key for embedding. A key with only embed:write changes nothing in the company. It can still show any partner's dashboard through a session, so keep it on your server like every other key.
Before the first session, an owner or admin lists the parent page origins allowed to frame the dashboard under Allowed embed origins on the API keys tab of Developers. Without at least one origin, POST /embed/sessions returns 409 with code embed_not_configured.
POST /embed/sessions takes:
{
"partner": { "externalId": "merchant/customer-123" },
"locale": "fr-CA",
"programIds": ["customer-referrals"],
"actions": [],
"ttlSeconds": 600,
"embedOrigin": "https://your-business.com"
}partner is { "externalId": "..." } or { "id": "..." }. A partner outside the key's company returns 404 with code not_found. locale is en, fr-CA, hy or ru. It defaults to the partner's locale, and a language the company does not serve becomes the company's default language. The response's locale is the one the session uses. actions lists write actions the frame may perform, from payout_details:write and profile:write. It defaults to [], a read-only session. No embed endpoint performs a write yet, so these values only prepare a session for later releases. ttlSeconds accepts 60 to 600 and defaults to 600.
Optional programIds is a nonempty list of unique program IDs the partner currently belongs to in the key's company. A program outside those memberships returns 400 with code invalid_embed_program. Summary, referrals, rewards, payouts and QR reads stay within this list. Omit it to include all programs. Responses and token exchanges return the list, or null when omitted.
Scoped summaries return balances.wallet: null and merchant balances summed across the selected programs. Program cards retain their own wallet reward totals. Partner-wide payout wait dates are omitted. Scoped payouts omit wallet payouts and return only the selected program allocations' amounts. On a mixed-program payout with a debt deduction, netAmountCents is null. Grizzlee cannot split that deduction between programs. The embed guide describes host sharing opt-in and native share forwarding.
embedOrigin is the origin of the page that will hold the iframe. It must be one of the company's allowed origins, or the request returns 400 with code invalid_embed_origin. When you leave it out and the company allows exactly one origin, the session uses that origin. With several allowed origins, you must send embedOrigin. Leaving it out returns 409 with code embed_origin_required.
The response is 201 with the session and its start URL:
{
"id": "0b8e...",
"partnerId": "6f1c...",
"partnerExternalId": "merchant/customer-123",
"locale": "fr-CA",
"programIds": ["customer-referrals"],
"actions": [],
"embedOrigin": "https://your-business.com",
"expiresAt": "2026-10-12T12:10:00.000Z",
"createdAt": "2026-10-12T12:00:00.000Z",
"consumedAt": null,
"revokedAt": null,
"url": "https://app.grizzlee.example/embed/start?session=0b8e...#token=grizzlee_est_..."
}Load url in the iframe once. The start token after #token= works one time and stops working at expiresAt. Create a new session for every page view. Do not store, log or reuse the URL.
DELETE /embed/sessions/:id ends a session in the key's company and returns it with revokedAt set. Revoking twice keeps the first time. A session from another company returns 404 with code not_found.
When a session expires or is revoked, the frame's next request fails with 401 and code session_expired. Create a new session and load its URL to continue.
Embed sessions need a live company. The local sandbox returns 503 with code live_required.
Data erasure
When a person deletes their account with you, erase what Grizzlee holds about them. Grizzlee keeps what your books need: amounts, dates, statuses, the ledger, internal IDs, your external IDs and your customer IDs. Those IDs stay, so make them opaque, never an email address or a name.
Partner and customer records are separate. Set customerIds to link them for self-referral checks. The linked records can belong to different people, such as a store partner and its owner. Partner erasure removes those links and leaves the customer records intact. Erase customers through customer erasure. If one person has both records, erase each one that applies.
POST /partners/:id/erase and POST /partners/by-external-id/:externalId/erase erase a partner. POST /customers/:customerId/erase erases a referred customer. All three need partners:write, take no body and return 200. The customer ID is URL-encoded like an external ID. A partner that does not exist returns 404 not_found. A customer ID Grizzlee has never seen still succeeds with zero counts, so you can erase every deleted account without checking first.
Erasing a partner:
- sets
nametoErased partner,emailto an empty string andactivetofalse, addserasedAt, and removeslabelsandproperties - deletes the partner's hashed identity signals, embed sessions, partner dashboard login and dashboard invitations. A login no other company uses loses its stored name and email too, right after the erasure commits or on the worker's next pass
- replaces the reason on the partner's rejected rewards and failed deliveries with
[erased] - removes the partner's address from the partner email log and skips their queued emails. The log keeps each email's type and status
- replaces locally stored provider promo code names with
[erased]. Provider IDs, saved offers and statuses stay so you can deactivate the discounts. These changes count inerased.notes. Stripe and Shopify still hold the original names until you remove them there - rewrites the partner's audit entries and webhook payloads, delivered or not, without the name or those reasons. Audit entries are rebuilt from their known formats, so names the partner had before a rename go too. A referral entry does not say which partner it names, so every referral entry of a customer the partner ever referred loses its partner name, even one naming another partner
An erased partner never earns again and its referral code stops working. Registering, upserting or updating it, recording its signals or creating an embed session returns 409 partner_erased, and its open embed frames stop working. Erasure removes customerIds from the partner and saved partner.created payloads. Its external ID stays, so the partner keeps showing up in your rewards, payouts and webhooks under the same IDs. To work with the same person again, register them under a new external ID.
Grizzlee keeps keyed hashes of the partner's dashboard login and email, scoped to your company. The program signup page uses them to refuse a new partner profile for the same person, so they cannot rejoin on their own. Once you register them under a new external ID with the same email, they can link that profile.
Erasing a customer:
- removes
customerEmailfrom the customer's orders and from the saved copies of their order events - removes
customerDisplayNamefrom referrals, orders, cached claims, processed event fingerprints and saved order webhook payloads. The response counts saved display name copies removed from referrals, orders and cached claims inerased.customerDisplayNames - deletes the customer's hashed identity signals
- replaces milestone revocation, reinstatement and report reversal reasons, reward rejection reasons and failed delivery reasons on the customer's rewards, and the reasons on the customer's order adjustments, with
[erased] - deletes the raw Stripe or Shopify webhook bodies about the customer, and any manual mapping of them, keeping the provider event ID and a SHA-256 hash of the body so a redelivery is still recognised. That covers bodies behind the customer's orders and refunds, and ignored, failed or needs-action events whose body or manual mapping names the customer or one of their orders, such as Shopify order edits, or names a Stripe customer ever linked to this customer ID or a charge that paid one of their invoice orders
- rewrites the customer's audit entries and webhook payloads without the email or those reasons
Grizzlee replays a processed event by comparing its saved copy, so emails, display names and reasons are left out of that comparison once erased. Sending the same event again still returns a duplicate. A failed delivery retried with the same externalReference also still matches, with any reason. A webhook already being sent at the moment of erasure may still carry the original data. Every send after the erasure commits uses the rewritten payload. An erased failed or needs-action provider event can no longer be retried or corrected, so resolve those first if the purchase should count. Queued provider events are left to finish, so a purchase in flight still counts. Their raw bodies and manual mappings are removed when processing finishes if they belong to an erased customer. You do not need to erase again after they are processed.
Reports that arrive after customer erasure are accepted, but Grizzlee keeps no identity signals from them. Orders, referrals, rewards, refunds and payouts still follow the program rules. Grizzlee drops customer emails and display names and replaces personal reasons with [erased] before saving orders, milestones, audit entries or webhook payloads. Saved event comparisons omit those personal fields, so retries still deduplicate. Partner erasure suppresses the partner's identity signals from later reports. An erased partner remains inactive and cannot earn new rewards.
A Stripe connection that records invoices keeps the link from the Stripe customer ID to your customer ID, and the history of earlier links. They hold IDs and nothing else, and later renewals need the link to reach the right customer.
Payouts keep their Stripe connected account ID in destination, because transfers and reconciliation reference it. Partners also keep stripeAccountId, including in saved partner.created webhook payloads, so outstanding earnings can still be paid and reconciled. Audit entries keep the last four characters of account IDs when an account changed. These are Stripe's IDs for the partner's accounts. Stripe holds the person's details, so ask Stripe to erase those.
Resend, the email provider, keeps sent partner emails, including the address, for its own retention period. Erasure does not reach it.
Erasure never blocks on money. Pending rewards, approved balances and open payouts stay, and you can still approve, deliver and pay them. The partner response counts what may still be owed.
{
"data": {
"partnerId": "partner-1",
"partnerExternalId": "merchant/partner-123",
"erasedAt": "2026-10-11T10:00:00.000Z",
"alreadyErased": false,
"erased": {
"identitySignals": 3,
"orderEmails": 0,
"customerDisplayNames": 0,
"events": 0,
"notes": 1,
"auditEntries": 12,
"webhookEvents": 4,
"providerEvents": 0,
"embedSessions": 1,
"partnerAccess": 1,
"partnerEmails": 2
},
"outstanding": { "pendingRewards": 2, "openPayouts": 0 }
}
}A customer response has customerId, erasedAt, alreadyErased and erased. Repeating a call is safe. It returns the first erasedAt with alreadyErased: true, erases anything new that arrived since and counts only that. Each first erasure writes a partner.erased or customer.erased audit entry that holds no personal data, and sends the webhook of the same name.
Erasure replaces free text you wrote, such as a rejection or revocation reason, only when it belongs to the erased person's rewards, revocations or deliveries. It does not search program names, delivery and payout references or webhook delivery errors, so keep personal data out of them.
Database backups keep erased data until they expire. Point-in-time backups expire after 14 days. A final snapshot taken when the database is deleted or replaced is kept until an operator deletes it.
Erasure needs a live company. The local sandbox returns 503 with code live_required.
Delete a company
An owner deletes a company from the dashboard, or with DELETE /api/console/companies/:companyId and the body { "name": "<company name>" }. There is no API key route. Admins get 403 forbidden, a name that does not match exactly gets 400 name_mismatch, and a company with a payout that is queued, processing or needs_action, or with scheduled payouts held because a partner has no Stripe account, gets 409 unpaid_payouts. Approved rewards not yet in a payout are never paid after deletion.
Deletion returns { "data": { "id": "...", "deletedAt": "..." } }. In the same transaction it revokes and hides every server API key and tracking key, disables and hides every webhook endpoint, disables Stripe and Shopify connections, ends embed sessions, turns off the payout schedule, unpublishes program signup pages and frees their addresses for other companies, removes the partner email settings, and deletes every membership and invitation. The company disappears for every team member and partner. Requests with its keys return 401, and provider webhooks to its connections return 404.
Orders, rewards, payouts and their history stay, because they are append-only. The worker then erases every partner and every customer the company holds, exactly as the routes above do, one at a time. A failed erasure is retried on the next pass. Customer IDs and external IDs stay, as with any erasure. Discount codes created in Stripe or Shopify keep working until you remove them there.
Partner emails
Grizzlee can email partners for you. Owners and admins set this up in the dashboard under Settings, Notifications. The support email is under Settings, Business details, and the default language under Settings, Languages. There is no API for these settings.
| Sent after | Unsubscribe link | |
|---|---|---|
| Welcome | partner.created | Yes |
| Reward earned | reward.created, except rewards held for review | Yes |
| Reward approved | reward.approved | Yes |
| Reward reversed | reward.reversed, except rejections of rewards held for review | Yes |
| Payout sent | payout.paid | No |
| Payout details needed | payout.needs_action when Stripe refused the transfer because of the partner's own account, or payout.account_required | No |
A reward that starts approved gets one email: reward earned when that switch is on, otherwise reward approved. Payout details needed covers Stripe answers saying the partner's connected account cannot receive transfers yet or does not exist on your Stripe platform, and payouts the schedule holds because the partner has no Stripe account yet. That second case goes out once per wait, with its own opening paragraph saying the money is ready and will be paid on the next payout date after setup. Other held payouts are yours to resolve and send nothing.
Each email has its own switch. Companies created before partner emails existed start with every switch off. New companies start with every switch on. Turning a switch on sends that email only for events created after it was turned on, and no email goes out for an event more than 72 hours old.
Emails come from <company name> <notifications@mail.grizzlee.ca>. Set a support email and partners' replies go there as Reply-To. Without one, emails tell partners to contact you instead of replying. Sending from your own domain is not supported yet.
Emails use the logo URL and primary color from the embedded dashboard settings. They go out in the partner's locale when the company serves it, or in the company's default language: en, fr-CA, hy or ru. For each email and language you can replace the subject and the opening paragraph. {partner}, {company}, {amount} and {program} are filled in. The amount, program and status are still listed below your paragraph. The dashboard previews each email and can send a test, with sample amounts, to your own address.
Welcome and reward emails carry a one-click unsubscribe link and the List-Unsubscribe and List-Unsubscribe-Post headers. An unsubscribe applies to one partner in one company. Payout emails are essential and still go out. Inactive partners get only payout emails, and erased partners get none.
The worker retries a failed send up to 8 times over about an hour, with an idempotency key per email so Resend never delivers a retry twice. Resend keeps that key for 24 hours, so an email that may have reached Resend and was first tried 23 hours ago or more is never sent again. Its status becomes unknown with reason unconfirmed. A retry whose message changed since the first attempt, for example after you edited the subject, is not sent either, because Resend refuses a reused key with a different body. Its status becomes unknown with reason changed_before_retry. Check Resend to see whether these went out. The dashboard lists the latest 100 emails with their status, recipient and reason: disabled, unsubscribed, inactive, no_address, expired or erased for skipped emails, unconfirmed or changed_before_retry for unknown ones, or the provider's error. Grizzlee stores no subject or body.
Webhooks
Grizzlee delivers events at least once. Deduplicate on the outbox UUID in Grizzlee-Event-Id, which also appears as id in the body. An event's ID and payload stay the same across retries. Grizzlee saves the payload in the transaction that changes the reward or payout. Later partner edits, refunds and rule changes cannot change an event already queued. Erasure is the one exception: it rewrites queued and delivered payloads about the erased person. See Data erasure. Events may arrive out of order.
Every body has id, companyId, type, data and createdAt. companyId is the authenticated company. createdAt is the enqueue time. Amounts are integer minor units in currency.
An endpoint receives only the event types in its saved list. There is no option to receive every type. Edit an existing endpoint in the dashboard or through the API to subscribe to types added later. The dashboard lists every catalog event, including partner.erased and customer.erased. It preselects reward.created, reward.approved, reward.reversed, reward.restored, reward.delivered and reward.delivery_failed for a new endpoint. Newer event types stay unchecked, because parseWebhookEvent in an older SDK throws for a type it does not know. Update the SDK before you subscribe to a newer type.
Manage endpoints
Endpoint management requires live mode. GET /webhooks returns the company's endpoints and accepts read or settings:write. Creating, editing and disabling require settings:write. Existing API keys do not have this new scope. Create a key with it in the dashboard. Console routes use the member's settings:write permission at /api/console/companies/:companyId/webhooks and /api/console/companies/:companyId/webhooks/:id with the same methods and bodies, except DELETE, which deletes the endpoint.
POST /webhooks accepts name, url and events and returns 201 with { "data": { "endpoint": ..., "secret": "whsec_..." } }. Names have 2 to 80 characters after trimming. The secret is returned once. GET /webhooks returns { "data": [ ... ] }. Each endpoint has id, companyId, name, url, events, active and createdAt, without a signing secret.
PUT /webhooks/:id accepts a nonempty combination of these fields:
| Field | Meaning |
|---|---|
name | Renames the endpoint. 2 to 80 characters after trimming. |
events | Replaces the full event list. Supply at least one known event, with no duplicates. |
url | Replaces the destination after the same validation as creation. |
enabled | Sets the endpoint's existing active field to true or false. |
Omitted fields keep their values. Unknown fields, including company IDs, are rejected. The endpoint ID, creation time and signing secret stay the same. Updates return { "data": Endpoint }, never a secret. An endpoint outside the authenticated company returns 404 not_found. Every update writes a webhook.updated audit entry in the same transaction. This audit action is not a webhook event.
{
"events": [
"reward.created",
"reward.debt_recorded",
"partner.erased",
"customer.erased"
],
"url": "https://your-business.com/webhooks/grizzlee",
"enabled": true
}Creation and URL changes require HTTPS on port 443, without credentials or fragments. Literal IP addresses and every DNS answer must be public. Each delivery checks DNS again and connects to a validated address. Redirects are not followed.
The worker selects each endpoint's events and enabled state when it distributes an outbox event into delivery rows. Edits apply to outbox events that have not reached that step, even if they were enqueued earlier. Removing an event does not cancel delivery rows already created, and adding an event does not replay already distributed events. Queued deliveries and retries use the endpoint's current URL at the time the worker claims them. A delivery already in progress may still use the previous URL or enabled state.
DELETE /webhooks/:id disables the endpoint and returns it with active: false. PUT with enabled: false does the same. The worker marks pending deliveries failed when it encounters a disabled endpoint. Re-enabling allows future distribution and any still-queued deliveries, but does not replay failed deliveries or events distributed while disabled.
Deleting an endpoint in the dashboard disables it and removes it for good. GET /webhooks no longer lists it, and PUT or DELETE on it returns 404 not_found. Its past deliveries stay in the deliveries list.
The SDK has no endpoint management methods. Call these HTTP routes from your server or use the dashboard. Secret rotation is not available.
Verify deliveries
Verify the untouched request body before parsing. Grizzlee-Signature has the form t=<unix seconds>,v1=<hex HMAC>. The HMAC uses SHA-256 with the endpoint secret and the string <timestamp>.<Grizzlee-Event-Id>.<raw body>. The SDK's verifyWebhook checks it and accepts timestamps within 300 seconds by default. Call parseWebhookEvent(body) after verification, then compare its id to Grizzlee-Event-Id before recording the event. The parser throws for invalid JSON, unknown event types or missing payload fields. It preserves extra fields and original timestamps. It does not verify signatures.
Reward events
reward.created, reward.approved, reward.reversed, reward.restored, reward.adjusted, reward.delivered and reward.delivery_failed all include these fields at the top of data:
| Field | Meaning |
|---|---|
rewardId | Grizzlee reward ID |
partnerId | Grizzlee partner ID |
partnerExternalId | Your partner ID, read in the enqueue transaction |
trigger | order or milestone. Events queued before milestones existed omit it |
orderId | Your order ID. Present on order rewards only |
milestone | The milestone name. Present on milestone rewards only |
programId | Grizzlee program ID |
programVersion | Program version the reward was earned under. Absent for older rewards |
customerId | Your customer ID, without a customer name or email |
currency | Reward currency |
rewardType | cash, credit or offer |
deliveryMode | merchant or grizzlee_wallet, including the wallet default for older rewards |
amountCents | Current net value, originalCents + (adjustedCents ?? 0) - reversedCents |
originalCents | Original earned value |
adjustedCents | Signed total of order adjustments. Events queued before adjustments omit it |
reversedCents | Cumulative amount still reversed |
status | pending, approved or reversed |
reward | Full reward with its original rule snapshot |
Rewards from an order that reported incentiveAppliedCents also carry incentiveAppliedCents, as submitted, and incentiveFlag when the order has one, the same fields order.completed carries. They describe the order, so every reward from that order repeats them. Milestone rewards and orders without an incentive omit both. Events queued before this change omit them too.
Offer rewards send reward.created and, when approved, reward.approved. They carry reward.offer.terms and reward.offer.expiresAt, plus reward.programSnapshot.partnerOffer. Their monetary amounts are zero, with adjustedCents omitted on new rewards. Rejection and a full refund of the earning order send reward.reversed with reversalCents: 0. A lower refund total can send reward.restored with restoredCents: 0. Partial refunds and order adjustments do not change an offer. Ignore deliveryMode when rewardType is offer. Its webhook value defaults to grizzlee_wallet for compatibility, but offers never enter a wallet or a delivery record.
Older SDK webhook parsers reject rewardType: "offer". Use a parser that accepts offer rewards before enabling an offer program. IncentiveIneligibleReason also includes offer_voided, reserved for checkout handling when a held earned offer's reward has been reversed.
A milestone reward carries trigger: "milestone" and milestone, and has no orderId at the top of data or in reward. Check trigger before reading orderId. A missing trigger means an order reward.
reward.reversed adds reversalCents, the amount reversed by this event. Refunds, milestone revocations and reversed milestone reports send it. reward.restored adds restoredCents, the amount returned by this event. Lower refund totals, milestone reinstatements and milestone reports that bring a count back send it. reward.adjusted adds eventId, the order.adjusted event ID, and changeCents, the signed change to the reward's value from that adjustment. An order adjustment sends only reward.adjusted, even when it also changed how much an earlier refund reversed. reward.delivered and reward.delivery_failed add delivery, the recorded attempt with its ID, reward ID, partner ID, status, amount, currency, reward type and creation time. Successful deliveries have externalReference and deliveredAt. New failed attempts have failureReason and externalReference. Older failed records may have no reference. Deliveries may also have nettedDebtCents and payoutId.
amountCents remains the reward's net earned value after delivery. A payout may deliver only part of it, so use delivery.amountCents for the amount delivered. Apply reversal and restoration deltas once per event ID. Keep rewards separate by rewardId when one order earns rewards in several programs.
See the complete reward.reversed payload.
The nested reward and delivery keep their existing shapes. reward.created and reward.approved also keep all their previous raw reward fields, including data.id. Their new data.rewardId matches data.id, and they now include data.reward too.
Debt events
reward.debt_recorded and reward.debt_settled carry the reward fields above for the reward the debt belongs to, including incentiveAppliedCents and incentiveFlag when its order has them. Both add debt, with id, companyId, partnerId, rewardId, deliveryId, eventId, currency, rewardType, amountCents, outstandingCents, settlements and createdAt. debt shows the debt right after this event.
reward.debt_recorded follows a reversal that leaves the partner holding part of a delivered merchant reward. debt.amountCents is what the partner owes back. debt.eventId names the cause: your refund event ID, your order adjustment event ID, your milestone revocation or report reversal event ID, or payout:<payout ID> when a refund landed between creating a merchant payout and marking it paid.
reward.debt_settled follows each settlement, so one call can send several, oldest debt first. It adds settlement, with id, debtId, partnerId, kind, amountCents and createdAt, and settledCents, equal to settlement.amountCents. kind is recovered for POST .../debts/:debtId/recover or POST .../debts/recover, with externalReference. It is netted when a direct delivery or a paid merchant payout kept the amount, with deliveryId. Scheduled payouts net when you mark them paid. It is restored when a lower refund total or a milestone reinstatement reduced the debt, with eventId. A netting settlement belongs to the debt's reward, which is usually not the reward just delivered. A replayed request sends nothing.
See the complete reward.debt_settled payload.
Payout events
payout.created, payout.paid, payout.reversed, payout.failed, payout.needs_action and payout.cancelled keep the payout's fields and allocations. They add partnerExternalId. Required fields are id, companyId, partnerId, partnerExternalId, currency, amountCents, status, provider, eventId, createdAt, updatedAt and allocations. Optional fields are mode, nettedDebtCents, netAmountCents, integrationId, destination, providerReference, transferGroup, paidAt, reversedCents, reversedAt, lastError, lastErrorParts and schedule. Merchant payouts include both netting fields. An absent legacy mode means grizzlee_wallet.
payout.created with a schedule object means Grizzlee created the payout from your payout schedule. With provider: "manual" you need to send netAmountCents to the partner. With provider: "stripe" Grizzlee sends the transfer and follows up with payout.paid, payout.failed, payout.cancelled or payout.needs_action. Do not send money for it. schedule has dueOn, the run's due date as YYYY-MM-DD, and approvedBefore, the latest approval time the run paid. Payouts you create yourself have no schedule. For manual payouts, payout.paid and payout.cancelled follow when you mark the payout paid or cancel it.
Allocations include id, companyId, payoutId, partnerId, rewardId, orderId or milestone, programId, currency, amountCents and createdAt. An allocation for a milestone reward has milestone instead of orderId. Wallet payouts have allocations: [].
See the complete payout.paid payload.
payout.reversed carries those payout fields with status reversed, provider stripe, reversedCents, reversedAt, reversalCents and partial. reversalCents is the amount newly returned by this event. partial is true while some original transfer cash remains with the partner. For a 5,000-cent transfer, a first 2,000-cent reversal has reversalCents: 2000, reversedCents: 2000 and partial: true. A later full reversal has reversalCents: 3000, reversedCents: 5000 and partial: false. Re-read the partner summary after this webhook. See the complete payload.
payout.account_required is not a payout. A scheduled run sends it when it creates no payout for a partner because the terms wait for a Stripe account and the partner has none. It goes out once per wait. Its fields are partnerId, partnerExternalId, currency, amountCents, nettedDebtCents and netAmountCents, which are what the run would have paid, integrationId, the schedule's Stripe connection, and schedule with dueOn and approvedBefore. No money moves. Set the partner's stripeAccountId and the next due date pays it. See the complete payout.account_required payload.
Order events
order.completed, order.refunded and order.adjusted keep the submitted event fields. All three add partners, a list of distinct { partnerId, partnerExternalId } pairs from the order's decisions and rewards. When exactly one partner is involved, data.partnerId and data.partnerExternalId identify that partner too. Multiple partners use the list, and an unattributed order has partners: [].
All three keep type, eventId, orderId and occurredAt. Completion events also have customerId, currency and any submitted totalCents, commissionableCents, incentiveAppliedCents, categories, customerEmail or newCustomer. categories is stored lowercased, sorted and without repeats. customerOrderNumber is Grizzlee's purchase number. reportedCustomerOrderNumber is the number you sent, present only when it disagreed. incentiveFlag is present when the friend discount did not match its reservation. Refund events keep any submitted refundedTotalCents, refundedCommissionableCents or fullRefund. Adjustment events keep any submitted totalCents, commissionableCents or reason, and add customerId, currency, previousTotalCents or previousCommissionableCents for each total the adjustment set, rewardIds with every reward from the order, and rewardChangeCents, the signed change to their combined value. A stale adjustment sends nothing.
See the complete order.completed payload.
Partner events
partner.created keeps the partner record and adds partnerId, which matches id, and partnerExternalId, which matches externalId. The record includes companyId, name, email, referralCode, alias, programIds, active and createdAt, with optional customerIds, labels, properties, stripeAccountId and locale.
See the complete partner.created payload.
Erasure events
partner.erased follows the first erasure of a partner. It has partnerId, partnerExternalId and erasedAt. customer.erased follows the first erasure of a customer. It has customerId and erasedAt. A repeat erasure sends nothing.
See the complete partner.erased payload.
A partner.created for a partner erased before it was delivered arrives with name Erased partner, an empty email and no labels or properties.
Credit events
credit.redeemed and credit.restored keep id, companyId, partnerId, eventId, orderId, currency, amountCents, restoredCents and createdAt. They add partnerExternalId. credit.restored also keeps restoredDeltaCents, the amount returned by this event. Its restoredCents is the cumulative restored total, and eventId identifies the restoration request. amountCents remains the original redeemed amount.
See the complete credit.restored payload.
Incentive events
incentive.flagged follows order.completed once, from the completion that flagged the order. It has orderId, eventId, customerId, currency, incentiveAppliedCents and incentiveFlag. partnerId and partnerExternalId name the referring partner. They are absent when the reason is no_referral.
See the complete incentive.flagged payload.
Self-referral events
self_referral.detected follows a new completed order or milestone report once for each program whose referral matched a linked customer ID or the partner's identity signals. It has action (blocked, review or flagged), signals, eventId, programId, partnerId, partnerExternalId, customerId and trigger. Order events have trigger: "order" and orderId. Milestone events have trigger: "milestone" and milestone. rewardId names the reward: held as pending for review, following the usual approval for flagged. It is absent when the referral was blocked, including by a fraud rule while the self-referral match itself was review or flagged. The payload lists signal types only, never values or hashes. The reward's reward.created webhook carries reward.selfReferral, with status: "pending" when it is held. A rejected reward sends reward.reversed with reward.rejectedAt.
See the complete self_referral.detected payload.
Fraud events
fraud.flagged follows a new completed order or milestone report once for each program where fraud rules fired. It covers the other rules in Fraud checks. Self-referral matches keep their own self_referral.detected event, and a referral that triggers both sends both. It has action, rules, eventId, programId, partnerId, partnerExternalId, customerId and trigger, with orderId or milestone. rewardId names the reward: held as pending for review, following the usual approval for flagged. It is absent when the referral was blocked. The payload lists rule IDs and signal types only, never values or hashes. The reward's reward.created webhook carries reward.fraud.
See the complete fraud.flagged payload.
Referral events
referral.displaced follows a referral by another partner that took a customer's attribution over before a purchase or milestone locked it. Checkout candidate selection follows the same qualification rule. It has attributionId, programId, customerId, channel and occurredAt, the new referral's time. partnerId and partnerExternalId name the new partner. previousPartnerId, previousPartnerExternalId and previousOccurredAt name the partner who lost the customer and when they had referred them. Order-scoped referrals add orderId. A referral by the same partner, or one a qualified attribution ignores, sends nothing. Every path that records a referral sends it, including /attributions, code attachment and receipt claims.
See the complete referral.displaced payload.
Milestone events
milestone.reported follows each new POST /milestones report. milestone.revoked follows a POST /milestones/revoke that revoked the milestone for a person, and milestone.reinstated a POST /milestones/reinstate that reinstated it. milestone.reversed follows a POST /milestones/reverse that took back a report. A milestone can go through several revocations and reinstatements, and each one sends its event. All four have eventId, customerId, milestone, status, programIds, rewardIds, count and partners. count is the person's reports of the milestone that are not reversed, after this event. Events queued before counting existed have no count. partners lists the distinct { partnerId, partnerExternalId } pairs from the decisions and rewards, and when there is exactly one, data.partnerId and data.partnerExternalId name it too.
milestone.reported adds optional valueCents, occurredAt, when the person reached the milestone, decisions, one per evaluated reward rule in each active program that pays for it, and restoredCents, the reward value the report gave back by bringing a count back. Decisions that reached the count check have milestoneCount and milestoneThreshold, so you can show progress such as 7 of 10. status is recorded, or revoked when the milestone was already revoked for this person and the report earned nothing. rewardIds lists the rewards this report created, and programIds the programs that pay for the milestone. A report that rewards nobody, for example one still short of the count, still sends it with empty rewardIds.
milestone.revoked adds reason, reversalCents, the amount this revocation reversed, and revokedAt, when Grizzlee recorded it. status is revoked. rewardIds lists every reward for the person and milestone, and programIds their programs. Each reversed reward also sends reward.reversed, and a delivered one sends reward.debt_recorded.
milestone.reinstated adds restoredCents, the amount this reinstatement gave back, reinstatedAt, when Grizzlee recorded it, and reason when the request had one. status is reinstated. rewardIds and programIds list every reward for the person and milestone and their programs, like milestone.revoked. Each restored reward also sends reward.restored, and a delivered one with outstanding debt sends reward.debt_settled. SDK versions before 0.14.0 reject this event type, so new webhook endpoints leave it unchecked until you subscribe to it.
milestone.reversed adds reportEventId, the report taken back, reversalCents, the amount this reversal reversed, reversedAt, when Grizzlee recorded it, and reason when the request had one. status is reversed. rewardIds and programIds list every reward for the person and milestone and their programs. The reversed report's each-report reward and each bonus that fell below its count also send reward.reversed, and a delivered one sends reward.debt_recorded. SDK versions released before counted milestones reject this event type, so new webhook endpoints leave it unchecked until you subscribe to it. Update the SDK before using milestone reward settings.
See the complete milestone.reported payload.
These fields apply to newly queued events. Events already in the outbox keep their original payloads, so existing deliveries and their signatures remain valid. Drain those older events through your existing handler before requiring the new fields with parseWebhookEvent.
Text in other languages
Grizzlee writes three kinds of text in English: a reward's explanation, a decision's reason and a Stripe payout's lastError. Each comes with the same text as codes, in explanationParts, reasonParts and lastErrorParts, so you can show it in your own language. Each part is one sentence, { code, params }. Amounts in params are minor units of params.currency, rates are basis points and category lists are arrays. The codes are the RewardTextCode, DecisionTextCode and PayoutTextCode types. A rewarded decision uses its reward's codes.
Some codes leave values out. offer names no terms, so read the reward's offer.terms. self_referral_blocked and fraud_blocked read the decision's selfReferral and fraud. payout_changed and delivery_failed carry the error Grizzlee hit as errorCode and detail, its English message.
The English fields keep their wording. Rewards, decisions and payouts saved before the codes existed have no parts, and later releases can add codes, so show the English text when a part is missing or its code is new to you.
Error statuses
| Status | Meaning |
|---|---|
| 400 | Invalid fields, missing commission base, missing refund base, missing order binding, self_referral on code attach |
| 401 | Invalid, expired or revoked private key |
| 403 | Missing scope, invalid origin or invalid captured receipt |
| 404 | Company, program, partner, reward, milestone report, debt or payout unavailable, or install_claim_unavailable for an unknown, expired or used install claim |
| 409 | Embedding without an allowed origin, ambiguous partner join, order not completed, changed payment replay, receipt already claimed, order ownership conflict, a consumed, released or conflicting incentive reservation, or an order already recorded, a delivery or payout that the reward, balance or payout state does not allow, reward_not_pending on reject, or partner_erased |
| 429 | Rate limit. Retry later with the same event ID |
| 503 | Required live provider configuration is absent, or install_claim_failed when no unused install claim code could be drawn |
Signed receipts, wallet operations, reward delivery, payouts and scoped live credentials require live mode. The sandbox supports programs, partners, explicit attributions, payment events, milestones, simulations and approvals with its local sample keys. It has no public receipt capture, code receipt issuance or live wallet services.
The company endpoint accepts page, partner search and rewardStatus query parameters. rewardStatus accepts all, pending, approved, reversed or review. review lists rewards that a self-referral check or fraud rule recorded, held or not. report.heldRewards counts the pending ones that wait for a person. Reward status filtering applies before pagination. The response includes the filtered reward count, separate partner and reward pagination flags, and published program checkout destinations.