Domain GuidesIncentives & Offers

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).

offersarray

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).

valueobject

The offer's worth, in money classes: amount_minor and currency for fixed value, percent_bps for a percentage (2000 = 20%).

reasoningobject

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.

redemption_methodsarray

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>"

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}/reasoning returns the per-offer reasoning card on its own (source, confidence, merchant_attribution, rationale).
  • GET /offers/{offerId}/audit-trail returns the full proposal audit log: reasoning, consent version, and approver.
  • GET /proposals lists incentive proposals by status (the approval queue). POST /proposals/{id}/approve fires the offer; POST /proposals/{id}/reject means it does not fire.
  • GET /branches/{branchId}/offers is 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.

body
versioninteger
Required

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-envelopes creates 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/apply applies a promo code, tier benefit, or reload bonus to a customer.
  • GET /customers/{customerId}/points reads the loyalty points balance, broken out per campaign. POST /customers/{customerId}/points/redeem redeems points for a discount or item (FIFO, atomic).
  • GET /offers/eligible pre-renders eligible offer templates with no customer attached. Public and cacheable.
  • POST /incentives/evaluate is 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 a decision_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.

grantsarray

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>"

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.

body
reasonstring
Required

Why the grant is being clawed back (for example SOURCE_ORDER_VOIDED).

clawed_backobject

The amount reversed (amount_minor, currency).

spent_loss_loggedobject

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"
  }'

Operations and availability

OperationMethod + pathAvailability
resolveSessionOffersGET /checkout/sessions/{id}/offersPlanned
listCustomerOffersGET /customers/{customerId}/offersPlanned
applyCustomerOfferPOST /customers/{customerId}/offers/{offerId}/applyPlanned
redeemCustomerOfferPOST /customers/{customerId}/offers/{offerId}/redeemPlanned
dismissCustomerOfferPOST /customers/{customerId}/offers/{offerId}/dismissPlanned
releaseCustomerOfferPOST /customers/{customerId}/offers/{offerId}/releasePlanned
getOfferReasoningGET /offers/{offerId}/reasoningPlanned
getOfferAuditTrailGET /offers/{offerId}/audit-trailPlanned
listProposalsGET /proposalsPlanned
approveProposalPOST /proposals/{id}/approvePlanned
rejectProposalPOST /proposals/{id}/rejectPlanned
listBranchOffersGET /branches/{branchId}/offersPlanned
createBudgetEnvelopePOST /budget-envelopesPlanned
getBudgetEnvelopeGET /budget-envelopes/{id}Planned
updateBudgetEnvelopePATCH /budget-envelopes/{id}Planned
applyCustomerPromotionPOST /customers/{customerId}/promotions/applyPlanned
getCustomerPointsGET /customers/{customerId}/pointsPlanned
redeemCustomerPointsPOST /customers/{customerId}/points/redeemPlanned
listEligibleOfferTemplatesGET /offers/eligiblePlanned
evaluateIncentivesPOST /incentives/evaluatePlanned
listCustomerGrantsGET /customers/{customerId}/grantsBeta
clawbackGrantPOST /grants/{id}/clawbackBeta (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