Incentives & Offers
Resolve basket-and-identity-aware offers, apply and redeem them, capture non-selection as counterfactuals, and read the PromoGrant ledger. Most of this domain is Planned; grants and clawback are Beta.
Overview
The incentives domain turns a checkout_session into decisions: which offers a customer qualifies for, why, and what they are worth. You resolve a feed, present it, then record what the customer did with each offer, including the offers they did not take. Those non-selections are counterfactuals, and they are how the Brain learns.
One rule frames the whole domain: the richest offer feed is the session-anchored one, because it resolves against both the basket and the identity on the session. The customer-only feed resolves on identity alone and scores lower.
Almost every operation on this page is Planned: shape-frozen in the contract, not yet mounted. Only GET /customers/{customerId}/grants and POST /grants/{id}/clawback are Beta (callable today). The 95 Planned endpoints across the API are provisional: designed, not implementation-verified. Confirm the live mounted set at runtime with GET /v1/partner/capabilities and GET /v1/partner/openapi, never this page alone. See Availability & Maturity.
The session-anchored offer feed (Planned)
GET /v1/partner/checkout/sessions/{id}/offers is the primary offer surface. It resolves offers eligible for the basket lines and the identity attached to the session, so its feed is materially richer than the identity-only feed. Call it after identity attaches and before tender, so the cashier can present offers and the exposure is captured.
The feed returns an OfferFeed. Each Offer carries a shape, a value, the redemption methods it supports, an expiry, and a reasoning card. The response meta.decision_trace_id is the attribution carrier: one opaque, replay-stable string per intelligence-bearing response. Persist it. It is the thread that ties an exposure to a later redemption to an attribution report.
An empty offers array is a success, not a 404. It means nothing is eligible right now. A 404 means the session was not found, or it belongs to another integration (cross-tenant collapses to NOT_FOUND to avoid leaking existence).
The eligible offers. Each carries offer_id, campaign_id, offer_shape, value, min_basket_minor, redemption_methods, target_branches, expires_at, a reasoning card, and a reserved per-offer trace_id (nullable in v1).
The offer's worth, in money classes: amount_minor and currency for fixed value, percent_bps for a percentage (2000 = 20%).
Why this offer fired: source (rule in v1, brain in v1.5), confidence, merchant_attribution (the campaign name the merchant sees in reporting), rationale, and expected_value_minor.
The credential types this offer can be redeemed with: qr, otp, short_code.
curl https://api.feddi.io/v1/partner/checkout/sessions/9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f/offers \
-H "Authorization: Bearer <terminal-jwt>"
{
"ok": true,
"data": {
"session_id": "9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"offers": [
{
"offer_id": "off_3a9c1e44",
"campaign_id": "camp_77b21a09",
"title": "20% off your next coffee",
"offer_shape": "PERCENT_DISCOUNT",
"value": { "amount_minor": 0, "currency": "QAR", "percent_bps": 2000 },
"min_basket_minor": 2000,
"redemption_methods": ["qr", "otp", "short_code"],
"target_branches": ["br_doha_01"],
"expires_at": "2026-06-30T20:59:59Z",
"reasoning": {
"source": "rule",
"confidence": 0.82,
"merchant_attribution": "Summer espresso push",
"rationale": "Basket contains an eligible espresso SKU and customer is a returning weekday visitor.",
"expected_value_minor": null
},
"trace_id": null
}
],
"next_cursor": null,
"has_more": false
},
"error": null,
"meta": {
"request_id": "req_0b9d3f21",
"idempotency_replayed": false,
"api_version": "2026-06-01",
"data_completeness_score": 88,
"decision_trace_id": "dt_4f2a8c10"
}
}
Anonymous sessions get a thinner, identity-independent feed (templates only), scored down via data_completeness_score. Offers are branch-locked by default (target_branches). The decisioning contract is stable across versions: in v1 the offers come from the rule engine, in v1.5 the Brain swaps in behind the identical contract and only the reasoning content changes. See The Decisioning Model.
Capture both selection and non-selection (Planned)
After you resolve the feed, you record what happens to each offer against the customer. These four operations are the counterfactual surface. Selection is one data point. Non-selection is the more valuable one, because most systems never record it.
Apply an offer
POST /customers/{customerId}/offers/{offerId}/apply locks the offer for redemption, creating an AppliedIncentive and reserving budget (CAS on the envelope version). Apply records the selection, a counterfactual even if the offer is never redeemed. PENDING_PROOF customers are blocked here: an unverified identity cannot arm money side-effects.
Redeem a locked offer
POST /customers/{customerId}/offers/{offerId}/redeem consumes the lock atomically, credential-gated. This is where the offer's value actually applies.
Dismiss a shown offer
POST /customers/{customerId}/offers/{offerId}/dismiss records that an offer was shown but not chosen, with a reason (read the supported set from /capabilities). It moves no money. It feeds the shown-not-chosen counterfactual.
Release an abandoned lock
POST /customers/{customerId}/offers/{offerId}/release un-applies a locked offer the customer walked away from and restores the reserved budget.
Every mutating call here carries an Idempotency-Key header and is replay-safe: replaying the same key returns the original response byte-identical with meta.idempotency_replayed: true. Dismissing an already-dismissed offer, or clawing back an already-clawed grant, is a no-op replay. See Idempotency & Errors.
The three non-selection states the Brain learns from are eligible_not_shown, shown_not_chosen, and chosen_not_redeemed. Resolving the session feed attaches the exposure automatically when the session closes; apply, redeem, dismiss, and release make the per-offer outcome explicit. Read Data Maximization for the full reciprocity contract.
Reasoning, audit, and proposals (Planned)
Every offer is explainable, and offers that need approval go through a queue.
GET /offers/{offerId}/reasoningreturns the per-offer reasoning card on its own (source,confidence,merchant_attribution,rationale).GET /offers/{offerId}/audit-trailreturns the full proposal audit log: reasoning, consent version, and approver.GET /proposalslists incentive proposals by status (the approval queue).POST /proposals/{id}/approvefires the offer;POST /proposals/{id}/rejectmeans it does not fire.GET /branches/{branchId}/offersis the merchant dashboard list of active offers at a branch.
Budget envelopes: the merchant's incentive control (Planned)
A budget envelope is how a merchant caps incentive spend. It tracks total_budget_minor, spent_minor, reserved_minor, and remaining_minor, plus the channels, offer shapes, and segments it allows.
Updates are compare-and-set guarded. You supply the version you last read, and the update applies only if the envelope is still at that version.
The version you last read. The update applies a compare-and-set: a stale write is rejected so two admins cannot clobber each other and budget-cap races are prevented.
A stale version on PATCH /budget-envelopes/{id} returns VALIDATION_ERROR (HTTP 400), with the current version in error.details. Re-read the envelope and retry with the fresh version. (This is distinct from the generic CONFLICT (409) returned for other concurrent state collisions.) Increasing the budget does not retroactively re-fire queued proposals; expiry edits gate new applies only.
POST /budget-envelopescreates an envelope (the merchant's primary incentive control).GET /budget-envelopes/{id}reads remaining, version, and expiry.PATCH /budget-envelopes/{id}updates mutable fields under the CAS guard above.
Promotions, points, templates, and what-if (Planned)
POST /customers/{customerId}/promotions/applyapplies a promo code, tier benefit, or reload bonus to a customer.GET /customers/{customerId}/pointsreads the loyalty points balance, broken out per campaign.POST /customers/{customerId}/points/redeemredeems points for a discount or item (FIFO, atomic).GET /offers/eligiblepre-renders eligible offer templates with no customer attached. Public and cacheable.POST /incentives/evaluateis a read-only basket what-if. It evaluates a transient basket against the rule engine without moving money or persisting anything, and returns the offers that would be eligible plus near-eligible offers with the precise gap ("add 5 QAR more for a free pastry") so the cashier can suggestive-sell. It uses POST because the basket is a body, but it is side-effect-free, and it carries adecision_trace_id.
The PromoGrant ledger (Beta)
GET /v1/partner/customers/{customerId}/grants is callable today. It returns the customer's promotional-money grants and the state-machine state of each. Promotional money is a separate class from actual cash: it expires, it is never cashable, and it is spent first. See Money: Actual vs Promotional.
Each grant carries grant_id, source (CASHBACK / RELOAD_BONUS / SKU_TOPUP_BONUS / GATEWAY_BONUS), state (LOCKED / RELEASED / CLAWED_BACK / EXPIRED), amount, remaining, accrued_at, expires_at, and a source_event_ref.
Filter by state with the state query parameter. The feed is read-only, tenant-scoped, and cursor-paginated.
curl "https://api.feddi.io/v1/partner/customers/wu_4421/grants?state=LOCKED" \
-H "Authorization: Bearer <terminal-jwt>"
{
"ok": true,
"data": {
"customer_id": "wu_4421",
"grants": [
{
"grant_id": "pg_7781",
"source": "CASHBACK",
"state": "LOCKED",
"amount": { "amount_minor": 1000, "currency": "QAR" },
"remaining": { "amount_minor": 1000, "currency": "QAR" },
"accrued_at": "2026-06-01T12:00:00Z",
"expires_at": "2026-09-01T12:00:00Z",
"source_event_ref": "odoo:pos.order:5512"
}
],
"next_cursor": null,
"has_more": false
},
"error": null,
"meta": {
"request_id": "req_bb11cc22",
"idempotency_replayed": false,
"api_version": "2026-06-01",
"data_completeness_score": 80,
"decision_trace_id": null
}
}
Clawback a grant (Beta, operator-gated)
POST /v1/partner/grants/{id}/clawback reverses a PromoGrant when the source event that funded it is voided or refunded, or for fraud. It is RBAC-gated to ops or admin keys (ApiKeyAuth, not a terminal JWT), and idempotent on the Idempotency-Key header.
A LOCKED grant claws back its full amount. A RELEASED grant claws back the unspent remainder. Any already-spent portion is logged as a merchant marketing loss; it is never pulled back from the customer's actual cash balance, because promotional money never converts to cash.
Why the grant is being clawed back (for example SOURCE_ORDER_VOIDED).
The amount reversed (amount_minor, currency).
The already-spent portion booked as a merchant loss, not recovered from the customer.
curl -X POST https://api.feddi.io/v1/partner/grants/pg_7781/clawback \
-H "x-api-key: <ops-api-key>" \
-H "Idempotency-Key: 2b3c4d5e-6f70-8192-0314-253647586a7b" \
-H "Content-Type: application/json" \
-d '{
"meta": { "partner_request_id": "2b3c4d5e-6f70-8192-0314-253647586a7b", "occurred_at": "2026-06-05T11:10:00Z", "sent_at": "2026-06-05T11:10:01Z", "api_version": "2026-06-01" },
"reason": "SOURCE_ORDER_VOIDED",
"note": "Odoo pos.order 5512 refunded"
}'
{
"ok": true,
"data": {
"grant_id": "pg_7781",
"state": "CLAWED_BACK",
"clawed_back": { "amount_minor": 600, "currency": "QAR" },
"spent_loss_logged": { "amount_minor": 400, "currency": "QAR" },
"clawed_at": "2026-06-05T11:10:02Z"
},
"error": null,
"meta": { "request_id": "req_cc22dd33", "idempotency_replayed": false, "api_version": "2026-06-01" }
}
Operations and availability
| Operation | Method + path | Availability |
|---|---|---|
resolveSessionOffers | GET /checkout/sessions/{id}/offers | Planned |
listCustomerOffers | GET /customers/{customerId}/offers | Planned |
applyCustomerOffer | POST /customers/{customerId}/offers/{offerId}/apply | Planned |
redeemCustomerOffer | POST /customers/{customerId}/offers/{offerId}/redeem | Planned |
dismissCustomerOffer | POST /customers/{customerId}/offers/{offerId}/dismiss | Planned |
releaseCustomerOffer | POST /customers/{customerId}/offers/{offerId}/release | Planned |
getOfferReasoning | GET /offers/{offerId}/reasoning | Planned |
getOfferAuditTrail | GET /offers/{offerId}/audit-trail | Planned |
listProposals | GET /proposals | Planned |
approveProposal | POST /proposals/{id}/approve | Planned |
rejectProposal | POST /proposals/{id}/reject | Planned |
listBranchOffers | GET /branches/{branchId}/offers | Planned |
createBudgetEnvelope | POST /budget-envelopes | Planned |
getBudgetEnvelope | GET /budget-envelopes/{id} | Planned |
updateBudgetEnvelope | PATCH /budget-envelopes/{id} | Planned |
applyCustomerPromotion | POST /customers/{customerId}/promotions/apply | Planned |
getCustomerPoints | GET /customers/{customerId}/points | Planned |
redeemCustomerPoints | POST /customers/{customerId}/points/redeem | Planned |
listEligibleOfferTemplates | GET /offers/eligible | Planned |
evaluateIncentives | POST /incentives/evaluate | Planned |
listCustomerGrants | GET /customers/{customerId}/grants | Beta |
clawbackGrant | POST /grants/{id}/clawback | Beta (operator-gated) |
This table reflects the contract at publish time. The live, mounted set is always GET /v1/partner/capabilities and GET /v1/partner/openapi. Where this page and the spec disagree, the spec wins.
Where to go next
Money: Actual vs Promotional
The two-class balance model. Offers and grants produce promotional credit; this is its lifecycle.
Data Maximization
Why apply, redeem, dismiss, and release matter: counterfactuals are how the Brain learns.
The Decisioning Model
Where the offer feed and decision_trace_id sit in the session spine.
Transactions & Settlement
Where redeemed offers and clawbacks land in the transaction record.