Composable campaigns
Composable campaigns
Contents
- Overview
- Key concepts
- Rewards handling behaviour
- Survey campaigns
- Configuration flow
- Complete example
- Competition draw example
- Survey campaign example
- Distribution rules
- Value calculation rules
- Endpoints reference
Overview
Composable campaigns represent a modular approach to campaign configuration in Spaaza. Unlike traditional campaign types that bundle all configuration in a single entity, composable campaigns separate targeting, conditions, and rewards into independent, reusable components.
A fully configured composable campaign consists of:
- Campaign - The base campaign entity with
type: "composable" - Assigned Groups - Collections of product barcodes for targeting
- Restrictions - Rules that determine when the campaign applies
- Reward Methods - How rewards are calculated and distributed
This modular architecture provides several benefits: assigned groups can be reused across multiple campaigns, restrictions can be combined to create complex eligibility rules, and reward methods can be configured independently of the campaign's targeting logic.
Key concepts
Campaign context
The context field determines how and when the campaign fires. It is set once at creation time via the add-campaign endpoint and cannot be changed afterwards.
| Context | Description |
|---|---|
basket | (default) Campaign fires on the addBasket event when a basket is submitted. Used for cashback, vouchers, and basket-driven rewards. |
interaction | Campaign fires only when called via the interact-campaign endpoint. Used for scratch-card / competition-draw flows. |
survey | Campaign fires when a shopper completes a mobile-first survey via the survey endpoints. Used for first-party feedback, preferences, and consent flows. |
internal | Placeholder for future campaigns that are not driven by basket or interaction events. Currently accepts no restrictions. |
The context also restricts which restriction types can be set on the campaign and its reward methods. See Allowed restriction types per context.
Assigned groups
Assigned groups define collections of product barcodes that can be used for campaign targeting. They are chain-scoped entities that can be reused across multiple campaigns.
| Field | Type | Description |
|---|---|---|
| name | string | Group name (max 255 characters, required for new groups) |
| type | string | qualify (for use in restrictions) or redeem (for use in reward methods) |
| required_matches | integer | Minimum number of matching items required in the basket (default 0) |
| barcodes | array | Array of product barcode strings (duplicates not allowed) |
| excludes_barcode_matches | boolean | When true, the group matches items that are NOT in the barcode list (default false) |
The type field determines how the assigned group is used:
qualifygroups are used in restrictions to determine which baskets qualify for the campaignredeemgroups are used in reward methods to determine which items receive the reward
Restrictions
Restrictions define conditions under which a campaign applies. Multiple restrictions can be added to a campaign, and all restrictions must be satisfied for the campaign to activate.
Campaign-level restrictions are set inline when creating or updating a campaign via add-campaign or alter-campaign. The available campaign-level restriction types are:
| Type | Description | Configuration Fields |
|---|---|---|
| basket_item | Restrict by items in the basket | assigned_groups (array of IDs), max_basket_items_considered |
| basket_total_value | Restrict by basket total value | minimum_basket_total_value, maximum_basket_total_value |
| business | Restrict by business location | business_ids, business_formats, business_regions |
| currency | Restrict by transaction currency | currencies (array of 3-letter ISO codes) |
| segment | Restrict by user segment membership | user_segment_id, excluded_user_segment_id, visible_to_all_user_segments |
Segment: The segment restriction gates the campaign on user segment membership. At most one segment restriction may be set per campaign.
user_segment_id(string, optional): a single segment id. The campaign reward applies only to users in this segment. Comma-separated values are rejected withparameter_invalid— set a single id.excluded_user_segment_id(string, optional): a single segment id. The campaign reward does not apply to users in this segment. Comma-separated values are rejected withparameter_invalid.visible_to_all_user_segments(boolean, optional, defaulttrue): controls whether the campaign is hidden from non-matching users in the listing endpoints (get-campaignsandget-card). Whentrue(the default) the campaign is shown to everyone and the segment only affects whether the reward applies; set it tofalseto also hide the campaign from users who do not match the segment. For composable campaigns this flag is combined (logical AND) with the campaign-levelvisible_to_all_user_segmentsfield. Afalsevalue is accepted and honoured but is not echoed back in theget-campaignresponse (falsy restriction values are omitted from serialization); atruevalue is returned.
The following restriction types are configured on reward methods (via alter-reward-method), not on campaigns directly:
| Type | Description | Configuration Fields |
|---|---|---|
| budget | Set a budget cap with optional time period | budget (float), budget_period_quantity, budget_period_quantity_unit |
| reward_limit | Limit how many times a user can earn from this reward method | quantity (integer, required), unit, scale |
| usage_cost | Deduct a cost from a wallet when a reward is issued | wallet_id (integer), cost (integer, >= 1) |
| win_chance | Probability that the reward method fires when eligible | method (string, required, must be "basic"), base_chance (float, required, between 0 and 1) |
Reward limit units: year, month, week, day, hour, calendar_day.
Reward limit scale: Optional positive integer multiplier for the unit. For example, unit: "month" with scale: 3 applies the limit over a rolling 3-month period. When omitted, scale defaults to 1.
Usage cost: The usage_cost restriction deducts the configured cost amount from the user's balance in the specified wallet each time a reward is issued. If the user does not have sufficient balance, the reward method will not activate. The wallet_id must reference a valid wallet belonging to the same chain as the campaign.
Win chance: The win_chance restriction makes a reward method fire only some of the time, even when all other restrictions are satisfied. When the reward method is evaluated, a uniform random number between 0 and 1 is drawn; if it is less than or equal to base_chance, the reward method fires, otherwise it is skipped for that evaluation. base_chance: 0.05 means a 5% chance of firing, base_chance: 1 means it always fires (equivalent to no win_chance restriction), and base_chance: 0 means it never fires. The only supported method is currently "basic". For basket-context reward methods the roll is performed when the basket price is calculated, so the outcome quoted to the user carries through to the basket commit.
Budget limitation: Budget enforcement currently counts voucher amounts only. A budget restriction on a wallet_contribution reward method does not block rewards because UserPurchaseProgress contributions are not included in the budget balance. Use budget restrictions on voucher-issuing reward method types (instant_*, deferred_*, honour_voucher) for now.
Which restriction types are allowed depends on the campaign's context. See Allowed restriction types per context.
Restrictions can also be set inline when creating or updating a composable campaign via the add-campaign or alter-campaign endpoints. Pass a restrictions object keyed by restriction type:
{
"campaign_id": 100,
"restrictions": {
"currency": {
"currencies": ["EUR"]
},
"basket_item": {
"assigned_groups": [1]
}
}
}
When restrictions are set inline, they replace all existing restrictions on the campaign.
Allowed restriction types per context
The campaign's context determines which restriction types are valid. Attempting to set a restriction that is not allowed for the campaign's context returns parameter_invalid.
Campaign-level restrictions:
| Restriction Type | basket | interaction | survey | internal |
|---|---|---|---|---|
basket_item | yes | no | no | no |
basket_total_value | yes | no | no | no |
currency | yes | no | no | no |
business | yes | no | no | no |
business_format | yes | no | no | no |
business_region | yes | no | no | no |
segment | yes | yes | yes | no |
Store format/region filtering is expressed through the business type's business_formats / business_regions fields; there are no standalone business_format / business_region restriction types.
Reward-method-level restrictions:
| Restriction Type | basket | interaction | survey | internal |
|---|---|---|---|---|
basket_item | yes | no | no | no |
currency | yes | no | no | no |
business | yes | no | no | no |
business_format | yes | no | no | no |
business_region | yes | no | no | no |
budget | yes | yes | yes | no |
reward_limit | yes | yes | yes | no |
usage_cost | yes | yes | yes | no |
win_chance | yes | yes | yes | no |
Reward-method restrictions shape the awarded voucher, not the qualifying basket. The redeem-shaping types (basket_item, currency, business) are evaluated at redemption against a real basket regardless of how the voucher was earned, so they are allowed in the interaction context too — an interaction campaign (e.g. a scratch card) can award a voucher restricted to specific barcodes/businesses/currency. Only the qualify-side campaign-level restrictions stay context-gated.
Reward methods
Reward methods define how rewards are calculated and distributed to users.
| Field | Type | Description |
|---|---|---|
| type | string | The reward method type (see Reward method types) |
| priority | integer | Processing order (lower numbers are processed first) |
| owner_code | string | Optional short label of your own choosing (max 64 characters) used to identify a reward method when several are listed together. Purely cosmetic: it has no effect on behaviour and does not need to be unique. Surrounding whitespace is removed. Defaults to an empty string |
| usage_limit | integer | Maximum number of times this reward method can issue rewards. null means no limit |
| value | float | The reward value. For percentage types, a decimal between 0 and 1 (e.g., 0.05 for 5%). For the fixed_value* rules (wallet_contribution only) it is a flat amount awarded per award, not a percentage |
| value_calculation_rule | string | items_value (default, matched items only), basket_value (entire basket), fixed_value (flat value per matched item unit), fixed_value_per_basket (flat value once per basket), or fixed_value_per_spend (flat value per spend_step of basket total). The fixed_value* rules are wallet_contribution only. See Value calculation rules |
| spend_step | integer | Required for fixed_value_per_spend (and rejected for other rules): the amount of basket spend that earns one award. A whole number > 0; the awarded count is floor(basket_total / spend_step). Send null to clear it when switching to another rule |
| distribution_rule | string | all_items, cheapest_item, or most_expensive |
| selection | string | Item selection order: high_to_low or low_to_high |
| recipient_wallet_id | integer | The wallet ID to credit (required for wallet_contribution type) |
| honour_code | string | The code shown to the user or scanned by staff (for honour_voucher type) |
| voucher_claimed_by_default | boolean | Whether issued vouchers start as claimed (auto-redeemed) or generated (must be claimed). See Voucher shaping configuration |
| voucher_expiry_seconds | integer | Duration in seconds until issued vouchers expire. See Voucher shaping configuration |
| voucher_expiry_date | string | Fixed expiry date (YYYY-MM-DD) for issued vouchers. See Voucher shaping configuration |
| spend_on_promotional_items | boolean | Whether issued vouchers can redeem against promotional-price items. See Voucher shaping configuration |
| return_reclaims_earned_rewards | boolean | Whether earned wallet progress is clawed back on return. See Return clawback configuration |
| restrictions | object | Optional restrictions on the reward method (see Reward method restrictions) |
| metadata | object | Display information: title, subtitle, description, image_url, notes, log_message |
Reward method types
The type field on a reward method determines how the reward is calculated and delivered:
Instant voucher types - Create a voucher that is applied immediately to the current basket:
| Type | Description |
|---|---|
| instant_fixed_price | Applies a fixed price discount to matched items in the basket |
| instant_fixed_discount | Applies a fixed amount discount to the basket |
| instant_percentage | Applies a percentage discount to the basket |
Deferred voucher types - Create a voucher that can be redeemed in a future transaction:
| Type | Description |
|---|---|
| deferred_fixed_price | Creates a voucher with a fixed price discount for a future purchase |
| deferred_fixed_discount | Creates a voucher with a fixed amount discount for a future purchase |
| deferred_percentage | Creates a voucher with a percentage discount for a future purchase |
Other types:
| Type | Description |
|---|---|
| wallet_contribution | Credits a wallet or points wallet with the calculated reward value |
| honour_voucher | Creates a voucher with an honour code that can be validated externally (e.g., by staff) |
Voucher shaping configuration
Voucher-issuing reward methods (instant_* and deferred_* types) support configuration fields that control how the issued voucher behaves. These fields are set on the reward method via the alter-reward-method endpoint and take effect on every voucher that the reward method issues.
Traditional (non-composable) campaign types configure these behaviours at the campaign level (e.g. created_voucher_claimed_by_default, voucher_expiry_days, spend_on_promotional_items). In composable campaigns the same behaviours are configured per reward method instead, giving independent control when a campaign has multiple reward methods.
| Field | Type | Allowed on | Default | Description |
|---|---|---|---|---|
voucher_claimed_by_default | boolean | deferred_* types only | false | When true, vouchers are created with status claimed and are automatically applied to the next qualifying transaction. When false, vouchers are created with status generated and the shopper must explicitly claim them before they can be redeemed. Not configurable on instant_* types (instant vouchers are always claimed because they apply to the current basket). |
voucher_expiry_seconds | integer | deferred_* types only | — | Duration in seconds from voucher issuance until the voucher expires. Must be a positive integer. Mutually exclusive with voucher_expiry_date. |
voucher_expiry_date | string | deferred_* types only | — | Absolute expiry date in YYYY-MM-DD format. Every voucher issued by this reward method expires at the end of this date, regardless of when it was earned. Must not be in the past. Mutually exclusive with voucher_expiry_seconds. |
spend_on_promotional_items | boolean | instant_* and deferred_* types | true | When false, the issued voucher cannot redeem against the promotional-price portion of a basket — only the non-promotional total is considered when computing the maximum redeemable amount. |
When neither voucher_expiry_seconds nor voucher_expiry_date is set on the reward method, issued vouchers default to expiring 90 days after issuance.
To clear a previously set expiry field, send its value as null. Because voucher_expiry_seconds and voucher_expiry_date are mutually exclusive, you may need to clear one before setting the other.
Example: deferred voucher with claimed-by-default and 30-day expiry
{
"campaign_id": 100,
"type": "deferred_percentage",
"priority": 1,
"configuration": {
"value": 0.10,
"distribution_rule": "all_items",
"voucher_claimed_by_default": true,
"voucher_expiry_seconds": 2592000
},
"metadata": {
"title": "10% Off Next Purchase",
"description": "Automatically applied to your next visit within 30 days"
}
}
Example: deferred voucher with fixed expiry date and no promotional spend
{
"campaign_id": 100,
"type": "deferred_fixed_discount",
"priority": 1,
"configuration": {
"value": 5.00,
"distribution_rule": "all_items",
"voucher_expiry_date": "2026-12-31",
"spend_on_promotional_items": false
},
"metadata": {
"title": "5 EUR Off",
"description": "Valid until end of year, excludes sale items"
}
}
Return clawback configuration
wallet_contribution reward methods support a configuration field that controls whether earned wallet progress (points or balance) is clawed back when the earning purchase is returned.
| Field | Type | Allowed on | Default | Description |
|---|---|---|---|---|
return_reclaims_earned_rewards | boolean | wallet_contribution only | true | When true (or unset), wallet progress earned by this reward method is reclaimed if the earning purchase is later returned. When false, the earned progress is kept even after a return. |
Traditional campaign types configure this at the campaign level via return_reclaims_earned_reward. In composable campaigns the reward-method-level setting takes precedence. If no reward method is recorded on the purchase progress (e.g. for progress earned before this feature), the campaign-level flag is used as a fallback.
Example: wallet contribution that keeps earned points on return
{
"campaign_id": 100,
"type": "wallet_contribution",
"priority": 1,
"configuration": {
"value": 0.05,
"value_calculation_rule": "items_value",
"distribution_rule": "all_items",
"recipient_wallet_id": 42,
"return_reclaims_earned_rewards": false
},
"metadata": {
"title": "5% Loyalty Points",
"description": "Points are yours to keep, even if you return your purchase"
}
}
Reward method restrictions
Reward methods can have their own restrictions. These include spend-context restrictions (evaluated when a deferred voucher is redeemed) as well as budget, reward limit, and usage cost restrictions that control how often and under what conditions the reward method can issue rewards.
The supported restriction types depend on the parent campaign's context (see Allowed restriction types per context). For basket context campaigns, the following are available:
| Type | Description | Configuration Fields |
|---|---|---|
| basket_item | Restrict by items in basket | assigned_groups (array of IDs with type redeem), max_basket_items_considered |
| business | Restrict by store, store format, and/or store region | business_ids, business_formats, business_regions |
| currency | Restrict by currency | currencies (array of 3-letter ISO codes) |
| budget | Set a budget cap with optional time period | budget (float), budget_period_quantity, budget_period_quantity_unit |
| reward_limit | Limit how many times a user can earn from this reward method | quantity (integer, required), unit, scale |
| usage_cost | Deduct a cost from a wallet when a reward is issued | wallet_id (integer), cost (integer, >= 1) |
| win_chance | Probability that the reward method fires when eligible | method (string, required, must be "basic"), base_chance (float, required, between 0 and 1) |
For interaction context campaigns, both the redeem-shaping types (basket_item, currency, business) and budget, reward_limit, usage_cost, and win_chance are available on reward methods. The redeem-shaping types apply at redemption regardless of how the voucher was earned, so an interaction campaign (e.g. a scratch card) can award a voucher restricted to specific barcodes, businesses, or currency.
If a reward-method redeem restriction (basket_item, business, or currency) matches nothing in the basket at redemption, the voucher redeems on nothing rather than falling back to the whole basket — both fixed-amount and percentage vouchers are gated this way.
For interaction and survey context campaigns, only budget, reward_limit, usage_cost, and win_chance are available on reward methods.
max_basket_items_considered: An optional integer on the basket_item restriction that caps how many individual items are taken into account when the reward is applied at redemption. It counts physical items (units), so a single basket line with a quantity of 3 counts as three items — exactly as three separate quantity-1 lines would. For example, setting it to 1 limits a percentage voucher to a single unit even when the matching product arrives as one basket line with a higher quantity. When omitted, all matching items are considered.
Reward method restrictions are set via the restrictions field when creating or updating a reward method:
{
"campaign_id": 100,
"type": "deferred_percentage",
"priority": 1,
"restrictions": {
"basket_item": {
"assigned_groups": [5],
"max_basket_items_considered": 1
},
"currency": {
"currencies": ["EUR"]
},
"business": {
"business_ids": [10, 20]
},
"reward_limit": {
"quantity": 5,
"unit": "month"
},
"usage_cost": {
"wallet_id": 42,
"cost": 100
},
"win_chance": {
"method": "basic",
"base_chance": 0.05
}
},
"configuration": {
"value": 0.10,
"value_calculation_rule": "items_value",
"distribution_rule": "all_items"
}
}
Rewards handling behaviour
The rewards_handling_behaviour field on a composable campaign controls how its reward methods are evaluated. This field is set when creating or updating the campaign.
independent (default)
Each reward method is evaluated independently. All eligible reward methods are processed in priority order, and each one that matches issues its own reward. This is the standard behaviour for campaigns that should issue multiple rewards per transaction (e.g., cashback on different product groups).
random_draw
A single reward method is randomly selected from the eligible candidates. This enables competition-style campaigns where each interaction results in one randomly chosen prize. The flow is:
- Eligible reward methods are determined based on remaining capacity (
usage_limit) - One reward method is randomly selected from the eligible candidates
- The selected reward method issues its reward directly to the user
- If a
usage_costrestriction is configured, the cost is deducted from the user's wallet
Composable campaigns with context: "interaction" can be triggered via the interact-campaign endpoint without requiring a basket. When paired with random_draw, this is useful for scratch-card or prize-draw style interactions where the user does not need to make a purchase. Interaction campaigns can also use independent behaviour when every eligible reward method should be processed for the interaction.
Survey campaigns
Survey campaigns are composable campaigns with context: "survey". They expose a survey definition to a shopper, accept a completed answer set, and then issue direct rewards from the campaign's reward methods.
A survey campaign uses the same reward-method model as other composable campaigns, but the trigger is POST /submit-survey rather than a basket or interaction call. Survey completion is idempotent per campaign and shopper: after a shopper has completed the survey once, later submissions return the existing response with already_completed: true and do not issue another reward.
Survey campaigns support:
- campaign-level
segmentrestrictions, enforced again when the survey is submitted - reward-method-level
budget,reward_limit,usage_cost, andwin_chancerestrictions independentrewards handling to issue every eligible direct rewardrandom_drawrewards handling to issue one eligible reward method
Survey completion issues direct, non-basket rewards. Use deferred voucher types, honour_voucher, or wallet_contribution reward methods. Instant voucher reward methods are basket-oriented and are not issued by survey completion.
Survey definition
Survey questions are configured on the campaign using survey_definition when creating or updating the campaign through add-campaign or alter-campaign.
{
"chain_id": 1,
"type": "composable",
"context": "survey",
"title": "Customer feedback survey",
"active": true,
"survey_definition": {
"schema_version": 1,
"questions": [
{
"key": "favourite_department",
"type": "single_choice",
"prompt": "Which department do you visit most?",
"required": true,
"options": [
{ "key": "fresh", "label": "Fresh" },
{ "key": "bakery", "label": "Bakery" }
]
},
{
"key": "nps",
"type": "nps",
"prompt": "How likely are you to recommend us?",
"required": true
}
]
}
}
When survey_definition is sent on an existing survey campaign, questions and options are reconciled by their persisted integer id. Existing IDs must be included unchanged in later update payloads. An existing question omitted from the array is soft-deleted; an item without an id is created as a new question or option. Completed responses keep a snapshot of the definition and presentation metadata that was active when the shopper submitted the survey.
| Field | Type | Description |
|---|---|---|
schema_version | integer | Survey definition schema version. Must be 1. Defaults to 1 when omitted. |
questions | array | Required array containing at least one active question. Active question keys must be unique within the campaign. Archived questions may retain a key that is reused by an active replacement. |
Question fields:
| Field | Type | Description |
|---|---|---|
id | integer | Persisted immutable question ID returned by the API. Include it when updating an existing question; omit it only when creating a new question. This ID is the reporting identity. |
key | string | Required stable question key, max 128 characters. This is used when submitting answers. |
type | string | Required question type. See Survey question types. |
prompt | string | Required shopper-facing question prompt, max 1024 characters. |
help_text | string | Optional helper text, max 1024 characters. |
required | boolean | Whether the shopper must answer the question before completing the survey. Defaults to false. |
page | integer | Optional page number for multi-page survey UIs. Values below 1 are treated as 1. |
display_order | integer | Optional display ordering. Defaults to the question order in the request. |
image_url | string | Optional image URL for the question. |
validation | object | Optional validation metadata for client applications. The API stores this metadata but only enforces the type-specific validation described below. |
archived | boolean | Administrator-only lifecycle field. true removes the question from shopper survey responses while retaining it in administrator reports and exports. Send false to restore it. |
locked | boolean | Read-only administrator metadata. true after the first committed answer references the question. |
response_count | integer | Read-only administrator metadata containing the number of stored answers that reference this question. |
options | array | Required for single_choice and multiple_choice; ignored by free-text, rating, NPS, and consent questions. Choice questions require at least two options. |
Option fields:
| Field | Type | Description |
|---|---|---|
id | integer | Persisted immutable option ID. Preserve it when updating an existing unanswered question; omit it for a new option. |
key | string | Required stable option key, max 128 characters. Option keys must be unique within the question. |
label | string | Required shopper-facing option label, max 512 characters. |
value | string | Optional stored value, max 512 characters. Defaults to key. |
display_order | integer | Optional option display ordering. Defaults to the option order in the request. |
image_url | string | Optional image URL for the option. |
Question lifecycle and response storage
A question remains editable until its first answer is committed. After that, locked is true and the API rejects changes to the question definition or any of its options. Locked questions can still be archived, restored, or deleted. To ask a revised question, archive or delete the old question and create a replacement without an id. A replacement may reuse the old key, but receives a different question ID so reporting keeps both questions separate.
The three lifecycle states are:
| State | Shopper APIs | Authenticated administrator campaign responses | Reports and exports |
|---|---|---|---|
| Active | Included | Included | Included |
| Archived | Omitted | Included with archived: true | Included |
| Deleted | Omitted | Omitted | Omitted |
Archived questions are included only when the campaign request has a valid administrator session with campaign read access and does not contain a MyPrice app hostname header. GET /survey always returns active questions only.
Deleting a question is a product-level soft delete, not physical erasure. Its answers remain in the database and can remain in existing Elasticsearch _source documents, but internal response serialization, Console reports, and exports omit them. Do not treat survey question deletion as a privacy-erasure operation.
Survey response reporting documents use a bounded nested answers array. Each answer contains question_id, question_key, question_type, option_keys, the applicable typed value (number_value, boolean_value, or text_value), display_value, and presentation snapshots. Analytics and filters must enter the answers nested scope and correlate the typed condition with answers.question_id; no question-keyed dynamic Elasticsearch fields are created.
Survey question types
| Type | Submitted answer value | Validation |
|---|---|---|
single_choice | string option key | Must match one option on the question. |
multiple_choice | array of string option keys | Every selected key must match an option on the question. |
rating | integer | Must be between 1 and 5. |
nps | integer | Must be between 0 and 10. |
short_text | string | Trimmed and limited to 255 characters. |
long_text | string | Trimmed and limited to 5000 characters. |
consent | boolean | Stored as a boolean. Required consent questions must be answered truthy. |
Required questions must be present and non-empty. Optional unanswered questions can be omitted from the answers array.
Submitting survey answers
Client applications can fetch the active survey definition and the shopper's completion state with GET /survey.
GET /survey?campaign_id=300
With user authentication, the shopper is taken from the active session. With admin or privileged authentication, identify the shopper with user_id, member_number, or authentication_point_identifier. chain_id can be passed explicitly, otherwise the campaign's chain is used.
The response contains the active survey definition and completion state:
{
"results": {
"survey_definition": {
"schema_version": 1,
"questions": []
},
"completion": {
"completed": false,
"survey_response_id": null,
"reward_status": null
}
}
}
Submit answers with POST /submit-survey.
POST /submit-survey
{
"campaign_id": 300,
"answers": [
{ "question_key": "favourite_department", "value": "fresh" },
{ "question_key": "nps", "value": 9 }
]
}
question_key may also be sent as key. The submitted value shape depends on the question type.
The response includes the immutable survey response and reward status:
{
"results": {
"success": true,
"already_completed": false,
"survey_response": {
"id": 501,
"campaign_id": 300,
"status": "completed",
"reward_status": "issued",
"reward_summary": {
"reward_status": "issued",
"rewards": []
},
"answers": [
{
"question_id": 41,
"question_key": "favourite_department",
"question_type": "single_choice",
"value": "fresh",
"option_snapshot": [
{
"key": "fresh",
"label": "Fresh",
"value": "fresh"
}
]
}
]
}
}
}
Survey rewards and completion state
Survey response reward_status can be:
| Status | Description |
|---|---|
issued | At least one reward was issued. |
no_reward | No reward was issued, for example because no eligible reward method was available. |
pending | Initial status before reward processing is recorded. |
Survey completion and reward issuance are atomic. If reward processing throws an error, the response and any reward mutations are rolled back and the endpoint returns an error; raw exception messages are not returned to shoppers.
Internal systems can retrieve completed survey responses with GET /internal/get-survey-responses.
GET /internal/get-survey-responses?campaign_id=300&limit=50&offset=0
To fetch one response:
GET /internal/get-survey-responses?campaign_id=300&survey_response_id=501
The list response contains survey_responses, total_count, limit, and offset. limit defaults to 50 and must be between 1 and 100.
Configuration flow
The typical setup order for a composable campaign is:
- Create a wallet (if one does not already exist) - This will be the destination for earned rewards
- Create the base campaign with
type: "composable" - Create assigned groups with the product barcodes that should qualify for or receive rewards
- Add restrictions referencing the assigned groups to define eligibility rules
- Configure the reward method to define how rewards are calculated and distributed
For competition draw campaigns, the setup order is:
- Create a wallet (if one does not already exist) - For points-based entry costs and/or wallet rewards
- Create the base campaign with
type: "composable",context: "interaction", andrewards_handling_behaviour: "random_draw" - Configure reward methods with
usage_limitset to define the prize pool, and optionally add ausage_costrestriction on each reward method to require points per draw
For survey campaigns, the setup order is:
- Create the base campaign with
type: "composable",context: "survey", andsurvey_definition - Configure direct reward methods that should be issued when the survey is completed
- Fetch the survey definition from the client application with
GET /survey - Submit answers with
POST /submit-survey - Review completed responses internally with
GET /internal/get-survey-responses
Complete example
This example creates a "5% cashback on premium products" campaign.
Step 1: Create an assigned group for qualifying products
POST /internal/alter-assigned-group
{
"chain_id": 1,
"name": "Premium Products Qualify",
"type": "qualify",
"required_matches": 1,
"barcodes": ["PREMIUM_001", "PREMIUM_002", "PREMIUM_003"]
}
Response includes assigned_group_id (e.g., 1).
Step 2: Create the composable campaign with inline restrictions
POST /internal/add-campaign
{
"chain_id": 1,
"type": "composable",
"title": "Premium Products 5% Cashback",
"description": "Earn 5% cashback on all premium product purchases",
"active": true,
"is_contributor": true,
"restrictions": {
"basket_item": {
"assigned_groups": [1]
},
"currency": {
"currencies": ["EUR"]
}
}
}
Response includes campaign_id (e.g., 100).
Step 3: Configure the reward method with reward-method-level restrictions
POST /internal/alter-reward-method
{
"campaign_id": 100,
"type": "wallet_contribution",
"priority": 1,
"configuration": {
"value": 0.05,
"value_calculation_rule": "items_value",
"distribution_rule": "all_items",
"recipient_wallet_id": 42
},
"restrictions": {
"reward_limit": {
"quantity": 10,
"unit": "month"
}
},
"metadata": {
"title": "5% Cashback",
"description": "Earn 5% back on premium products"
}
}
Competition draw example
This example creates a competition draw campaign where users spend 100 points per draw and can win one of several prizes.
Step 1: Create the composable campaign with interaction context and random draw behaviour
POST /internal/add-campaign
{
"chain_id": 1,
"type": "composable",
"title": "Summer Prize Draw",
"description": "Spend 100 points for a chance to win prizes",
"active": true,
"context": "interaction",
"rewards_handling_behaviour": "random_draw"
}
Response includes campaign_id (e.g., 200).
Step 2: Add reward methods as prizes with usage limits and usage cost
POST /internal/alter-reward-method
{
"campaign_id": 200,
"type": "honour_voucher",
"priority": 1,
"owner_code": "GRAND-PRIZE",
"usage_limit": 5,
"configuration": {
"honour_code": "GRAND_PRIZE",
"value": 0
},
"restrictions": {
"usage_cost": {
"wallet_id": 50,
"cost": 100
}
},
"metadata": {
"title": "Grand Prize",
"description": "You won the grand prize!"
}
}
POST /internal/alter-reward-method
{
"campaign_id": 200,
"type": "wallet_contribution",
"priority": 2,
"usage_limit": 100,
"configuration": {
"value": 50,
"recipient_wallet_id": 50,
"value_calculation_rule": "basket_value",
"distribution_rule": "all_items"
},
"restrictions": {
"usage_cost": {
"wallet_id": 50,
"cost": 100
}
},
"metadata": {
"title": "50 Bonus Points",
"description": "You won 50 bonus points!"
}
}
Step 3: Trigger the draw for a user
GET /interact-campaign?campaign_id=200&user_id=12345
See the interact-campaign endpoint documentation for full details.
Survey campaign example
This example creates a survey campaign that issues 10 wallet points when a shopper completes the survey.
Step 1: Create the survey campaign
POST /internal/add-campaign
{
"chain_id": 1,
"type": "composable",
"context": "survey",
"title": "Customer feedback survey",
"active": true,
"survey_definition": {
"schema_version": 1,
"questions": [
{
"key": "favourite_department",
"type": "single_choice",
"prompt": "Which department do you visit most?",
"required": true,
"options": [
{ "key": "fresh", "label": "Fresh" },
{ "key": "bakery", "label": "Bakery" }
]
},
{
"key": "visit_rating",
"type": "rating",
"prompt": "Rate your last visit",
"required": true
},
{
"key": "contact_consent",
"type": "consent",
"prompt": "May we use your answers to improve your experience?",
"required": true
}
]
}
}
Response includes campaign_id (e.g., 300).
Step 2: Add the survey completion reward
POST /internal/alter-reward-method
{
"campaign_id": 300,
"type": "wallet_contribution",
"priority": 1,
"configuration": {
"value": 10,
"value_calculation_rule": "fixed_value",
"distribution_rule": "all_items",
"recipient_wallet_id": 42
},
"metadata": {
"title": "Survey completion reward",
"description": "10 points for completing the survey"
}
}
Step 3: Fetch the survey for the shopper
GET /survey?campaign_id=300
Step 4: Submit the shopper's answers
POST /submit-survey
{
"campaign_id": 300,
"answers": [
{ "question_key": "favourite_department", "value": "fresh" },
{ "question_key": "visit_rating", "value": 5 },
{ "question_key": "contact_consent", "value": true }
]
}
The first successful submission creates a completed survey response and attempts to issue the configured reward. Repeating the same request for the same shopper returns the existing response with already_completed: true.
Distribution rules
The distribution_rule in a reward method determines how the reward is allocated across basket items:
all_items
Distributes the reward proportionally across all matched items based on their value.
Example: A 20 EUR reward on items worth 50, 30, and 20 EUR results in a distribution of 10, 6, and 4 EUR respectively.
cheapest_item
Applies the entire reward to the cheapest matched item per threshold.
Example: A 20 EUR reward is applied entirely to the 20 EUR item.
most_expensive
Applies the entire reward to the most expensive matched item per threshold.
Example: A 20 EUR reward is applied entirely to the 50 EUR item.
Value calculation rules
The value_calculation_rule determines how the reward amount is computed. There are two families: the percentage rules take a fraction of a value, and the fixed-value family (wallet_contribution only) award a flat value a number of times — reward = value × count.
items_value (default)
Percentage of the value of matched items. value is a decimal between 0 and 1.
Example: A basket has 100 EUR total, but only 60 EUR of matched items. A 5% reward equals 3 EUR (5% of 60).
basket_value
Percentage of the entire basket value. value is a decimal between 0 and 1.
Example: A basket has 100 EUR total, with 60 EUR of matched items. A 5% reward equals 5 EUR (5% of 100).
fixed_value
Awards a flat value per matched item unit (stamp-card style rewards). Only supported for wallet_contribution reward methods; using it with other reward types is rejected at configuration time.
This multiplies by item quantity. A basket line with item_quantity: 21 awards value × 21 — e.g. value: 1 yields 21, not 1. The unit count is also reduced by any other campaign that distributes those items first, so identical baskets can earn different amounts. For "one reward per basket" use fixed_value_per_basket; for "one per amount spent" use fixed_value_per_spend.
Example: A reward method with value: 5 and the fixed_value rule, where the basket has 2 matched item units. Reward = 10 (5 × 2), regardless of item prices.
fixed_value_per_basket
Awards a flat value once per qualifying basket, independent of unit count and of other campaigns' item distribution. Only supported for wallet_contribution reward methods.
Example: A reward method with value: 1. Any qualifying basket earns a reward of 1.
fixed_value_per_spend
Awards a flat value per whole spend_step of the basket total: reward = value × floor(basket_total / spend_step). It uses the same basket total that the basket_total_value restriction checks, so it is independent of unit count and item distribution. Requires the spend_step field. Only supported for wallet_contribution reward methods.
Example: A reward method with value: 1 and spend_step: 20. A basket total of 41.79 earns a reward of 2 (floor(41.79 / 20)).
Choosing a rule by intent (the fixed-value family is wallet_contribution only):
| You want… | value_calculation_rule | value | Also set |
|---|---|---|---|
| 5% of matched items | items_value | 0.05 | — |
| 5% of the whole basket | basket_value | 0.05 | — |
| 1 stamp per item unit bought | fixed_value | 1 | scales with quantity |
| 1 reward per qualifying basket ("Spend 20, get a card") | fixed_value_per_basket | 1 | basket_total_value min 20 |
| 1 reward per 20 spent ("a card for every 20") | fixed_value_per_spend | 1 | spend_step: 20 |
Endpoints reference
Campaign endpoints
| Endpoint | Method | Description |
|---|---|---|
| /internal/add-campaign | POST | Create a new campaign (supports inline restrictions) |
| /internal/alter-campaign | POST | Update an existing campaign (supports inline restrictions) |
Assigned group endpoints
| Endpoint | Method | Description |
|---|---|---|
| /internal/alter-assigned-group | POST | Create or update assigned group |
| /internal/get-assigned-group | GET | Get single assigned group |
| /internal/get-assigned-groups | GET | List assigned groups (paginated) |
| /internal/delete-assigned-group | DELETE | Delete assigned group |
Restriction endpoints
| Endpoint | Method | Description |
|---|---|---|
| /internal/alter-restriction | POST | Create or update restriction |
| /internal/get-restriction | GET | Get single restriction |
| /internal/get-restrictions | GET | List restrictions (paginated) |
| /internal/delete-restriction | DELETE | Delete restriction |
Reward method endpoints
| Endpoint | Method | Description |
|---|---|---|
| /internal/alter-reward-method | POST | Create or update reward method |
| /internal/get-reward-method | GET | Get single reward method |
| /internal/get-reward-methods | GET | List reward methods (paginated) |
| /internal/delete-reward-method | DELETE | Delete reward method |
Interaction endpoints
| Endpoint | Method | Description |
|---|---|---|
| /interact-campaign | GET | Trigger a campaign interaction for interaction context composable campaigns |
Survey endpoints
| Endpoint | Method | Description |
|---|---|---|
| /survey | GET | Get the active survey definition and shopper completion state |
| /submit-survey | POST | Complete a survey for a shopper and issue configured direct rewards once |
| /internal/get-survey-responses | GET | List completed survey responses for a survey campaign or fetch one response |
All internal endpoints require admin authentication with write access to the chain.