Recommendations

By developer@lovi… , 25 April 2026

The Recommendations feature provides a structured way to surface AI-generated or manually created suggestions for improving the performance of a shop. Recommendations can target any entity — menu items, menu sections, campaigns, journeys, actions, targeting segments, and more. Each suggestion is stored as a pending recommendation that the shop owner can review and approve before any change is applied to the loyalty programme.


Contents


Core Concepts

What Is a Recommendation?

A recommendation is a structured suggestion to create, change, or remove a specific entity. It always has:

  • A typecreate, update, or delete
  • A category — the entity type the recommendation targets (e.g. item, campaign, journey)
  • A subcategory — optional further classification within the category (e.g. win-back within journey)
  • A rationale — a plain-language explanation of why the change is being suggested
  • A status — tracks where the recommendation is in its lifecycle
  • A data payload — the proposed entity fields. For update and delete recommendations this must include the target entity id.

Recommendation Lifecycle

A recommendation can only move forward through its lifecycle — it cannot be re-opened once accepted or rejected.

Status Meaning
pending Newly created; awaiting review
accepted Approved via the /accept endpoint; the proposed change has been applied
rejected Dismissed via the /reject endpoint; no change was applied

Recommendation Fields

Field Type Required Description
id integer Unique identifier, assigned on creation
placeId integer The place this recommendation belongs to, set automatically from the URL
type string Yes create, update, or delete
category string Yes The entity type: item, section, journey, campaign, communication_template, targeting_segment, targeting_rule, targeting_assignment, or collection
subcategory string No Optional further classification (e.g. win-back within journey)
rationale string Yes Human-readable explanation of why this change is recommended
data object No The proposed entity data. Shape depends on category — see examples below. For update and delete must include the entity id. Not used for collection.
parent_id integer No ID of a parent recommendation. Links this recommendation into a tree that is accepted or rejected as a unit. Set only on creation; cannot be changed afterwards.
status string pending, accepted, or rejected. Defaults to pending; managed by the accept/reject endpoints.
created integer Unix timestamp (seconds) when the recommendation was created
updated integer Unix timestamp (seconds) of the last update

The data Object

The shape of data is flexible and depends on what is being recommended. At minimum it identifies the target entity (for update and delete). For update recommendations it also contains the proposed field changes — only fields that differ from the current values need to be included.

Example — an update recommendation targeting a menu item:

{
  "id": 1042,
  "title": "Crispy Chicken Burger",
  "description": "Juicy fried chicken with pickles and sriracha mayo on a toasted brioche bun."
}

Example — a delete recommendation:

{
  "id": 2071
}

Authentication

All endpoints require an Api-Key header. The authenticated user must have manager access to the place.

Base URL

/places/{placeId}/recommendations

Replace {placeId} with the numeric ID of the place you are managing.


Endpoint Reference

Method Path Description
GET /places/{placeId}/recommendations List all recommendations for a place (paginated)
GET /places/{placeId}/recommendations/{id} Get a single recommendation by ID
POST /places/{placeId}/recommendations Create a new recommendation
PUT /places/{placeId}/recommendations/{id} Update a recommendation
DELETE /places/{placeId}/recommendations/{id} Delete a recommendation
POST /places/{placeId}/recommendations/{id}/accept Accept a recommendation and apply the change
POST /places/{placeId}/recommendations/{id}/reject Reject a recommendation — no change applied

Common Workflows

Reviewing recommendations

  1. Fetch all pending suggestions: GET /places/{placeId}/recommendations?where={"status":"pending"}
  2. Read each recommendation's rationale and data to understand what is proposed and why
  3. Use POST /{id}/accept to apply the change
  4. Use POST /{id}/reject to dismiss it without making any change

Filtering by category

Use the where parameter to scope the list to a specific category:

GET /places/123/recommendations?where={"category":"campaign","status":"pending"}

Pagination and sorting

Supports page, limit, where (URL-encoded JSON), and order query parameters. Fetch the 10 most recently created pending recommendations:

GET /places/123/recommendations?where={"status":"pending"}&limit=10&order=-created

Menu Items

Create a menu item

Propose adding a new item to a section. The data object mirrors the fields required to create an item directly.

POST /places/123/recommendations
{
  "type": "create",
  "category": "item",
  "rationale": "Adding a new seasonal product to the lunch menu",
  "data": {
    "vat": 25,
    "price": 15,
    "title": "Grilled Salmon",
    "number": 105,
    "status": "Active",
    "section_id": 66691,
    "description": "Fresh Atlantic salmon with seasonal vegetables",
    "add_on_category_ids": [68432]
  }
}

Update a menu item

Only include the fields you want to change. data.id identifies the target item.

POST /places/123/recommendations
{
  "type": "update",
  "category": "item",
  "rationale": "The item title is generic and does not describe the dish. A more descriptive title will improve discoverability.",
  "data": {
    "id": 1042,
    "title": "Crispy Chicken Burger",
    "description": "Juicy fried chicken with pickles and sriracha mayo on a toasted brioche bun."
  }
}

Delete a menu item

If accepted, the item will be permanently deleted.

POST /places/123/recommendations
{
  "type": "delete",
  "category": "item",
  "rationale": "Item has been discontinued by the supplier",
  "data": {
    "id": 12345
  }
}

Menu Sections

Create a menu section

POST /places/123/recommendations
{
  "type": "create",
  "category": "section",
  "rationale": "Adding a dedicated desserts section for the summer menu",
  "data": {
    "title": "Desserts",
    "description": "Seasonal sweet treats"
  }
}

Update a menu section

POST /places/123/recommendations
{
  "type": "update",
  "category": "section",
  "rationale": "Renaming the section to better reflect its contents",
  "data": {
    "id": 12345,
    "title": "Summer Desserts"
  }
}

Delete a menu section

Propose removing an entire section. If accepted, the section will be permanently deleted.

POST /places/123/recommendations
{
  "type": "delete",
  "category": "section",
  "rationale": "This section contains only one item and has not received any orders in the past 90 days. Removing it will reduce menu clutter.",
  "data": {
    "id": 88
  }
}

Campaigns

Create a campaign

Propose creating a new discount or promotional campaign. code and redemptions are required.

POST /places/123/recommendations
{
  "type": "create",
  "category": "campaign",
  "rationale": "Summer promotion offering 20% off all drinks",
  "data": {
    "code": "SUMMER20",
    "discount": 20,
    "discountType": "percentage",
    "redemptions": 500,
    "redemptionsPerCustomer": 1,
    "status": "Active",
    "startTime": 1748736000,
    "endTime": 1756684800,
    "visibility": "public"
  }
}

Update a campaign

POST /places/123/recommendations
{
  "type": "update",
  "category": "campaign",
  "rationale": "The campaign end date has passed but the campaign is still active. It should be deactivated.",
  "data": {
    "id": 55,
    "status": "Inactive"
  }
}

Delete a campaign

POST /places/123/recommendations
{
  "type": "delete",
  "category": "campaign",
  "rationale": "Campaign was created in error",
  "data": {
    "id": 9900
  }
}

Journeys

A journey (category: "journey") defines an automated customer workflow triggered by an event. The journey's steps are defined in a machineConfig field — include it in the recommendation's data when creating the journey recommendation, just like any other field. For the full machineConfig reference see the Journeys developer guide.

Referencing communication templates in machineConfig

Journey steps that send messages need a templateId pointing to a Communication Template. When the template is being created as part of the same recommendation tree, you do not need to know its ID in advance. Instead, assign it a caller-defined string reference using the localRef field in the template recommendation's data, then reference that string in the journey's machineConfig using templateRef.

When the tree is accepted, the system creates all entities, collects the mapping of localRef → created template ID, and then replaces every templateRef in the journey's machineConfig with the actual template entity ID. This happens in a final pass after the entire tree is accepted, so the template and journey recommendations can be in any position in the tree — siblings, cousins, any arrangement.

// Template recommendation — set localRef to a string of your choosing
{
  "type": "create",
  "category": "communication_template",
  "rationale": "Win-back email",
  "data": {
    "localRef": "winback_email",
    "name": "Win-back email",
    "type": "email",
    "recipient": "{{user.email}}",
    "subject": "We miss you",
    "body": "..."
  }
}

// Journey recommendation — reference the template by its localRef
{
  "type": "create",
  "category": "journey",
  "rationale": "Win-back journey",
  "data": {
    "name": "Win-back",
    "triggerEvent": "account.classification.became_slipping_away",
    "machineConfig": {
      "initial": "send_email",
      "states": {
        "send_email": {
          "meta": { "type": "send_email", "templateRef": "winback_email" },
          "on": { "DONE": "done" }
        },
        "done": { "type": "final" }
      }
    }
  }
}

The localRef value is stripped from the template's data before the entity is created — it is never written to the database. Use any string that is unique within the tree.

Create a journey

Supported triggerEvent values: manual, order.created, order.updated, order.closed, campaign.public.started, campaign.private.started, account.created, account.updated, account.tier.upgraded, account.tier.downgraded, account.tier.extended, account.classification.became_vip, account.classification.became_new, account.classification.became_active, account.classification.became_inactive, account.classification.became_slipping_away, account.classification.became_churned.

Include machineConfig directly in data to define the journey's workflow steps at creation time. Use a known templateId when the template already exists, or use templateRef with a localRef string when the template is being created as part of the same recommendation tree (see above).

POST /places/123/recommendations
{
  "type": "create",
  "category": "journey",
  "subcategory": "win-back",
  "rationale": "Re-engage customers who have not ordered in 60 days",
  "data": {
    "name": "60-Day Win-Back",
    "triggerEvent": "order.closed",
    "machineConfig": {
      "initial": "wait_2_days",
      "states": {
        "wait_2_days": {
          "meta": { "type": "wait_for_duration", "days": 2 },
          "on": { "ELAPSED": "send_email" }
        },
        "send_email": {
          "meta": { "type": "send_email", "templateId": 42 },
          "on": { "DONE": "done" }
        },
        "done": { "type": "final" }
      }
    }
  }
}

Update a journey

POST /places/123/recommendations
{
  "type": "update",
  "category": "journey",
  "rationale": "Activating the updated win-back journey",
  "data": {
    "id": 12,
    "active": true
  }
}

Delete a journey

POST /places/123/recommendations
{
  "type": "delete",
  "category": "journey",
  "rationale": "Replacing this journey with a revised version",
  "data": {
    "id": 77
  }
}

Targeting Segments, Rules, and Assignments

Targeting controls which customers are included in or excluded from a campaign or journey. A segment defines an audience via one or more rules, and an assignment links a segment to a campaign or journey. For full details on segments, rules, operators, and assignments see the Targeting developer guide.

Predefined segments (one per account classification: vip, new, active, inactive, slipping_away, churned) are created automatically for each place and can be looked up by their stable code field.

Create a new segment with rules

A segment without rules matches nobody. When recommending a new segment, add its rules as children of the segment recommendation — use segmentRef in the rule's data to reference the segment by its localRef.

// Step 1 — create the segment recommendation
POST /places/123/recommendations
{
  "type": "create",
  "category": "targeting_segment",
  "rationale": "Segment for high-spend customers",
  "data": { "localRef": "high_spenders", "name": "High Spenders" }
}
// → id: 600

// Step 2 — add rules as children, referencing the segment by its localRef
POST /places/123/recommendations
{
  "type": "create",
  "category": "targeting_rule",
  "parent_id": 600,
  "rationale": "Customers who have spent more than 500 in total",
  "data": {
    "segmentRef": "high_spenders",
    "attribute": "account.totalSpend",
    "operator": "gte",
    "value": 500,
    "ruleGroup": 0,
    "negate": false,
    "sortOrder": 0
  }
}

// Step 3 — accept the segment recommendation
POST /places/123/recommendations/600/accept

Add a rule to an existing segment

Supply segmentId directly when the segment already exists and its ID is known.

POST /places/123/recommendations
{
  "type": "create",
  "category": "targeting_rule",
  "rationale": "Target VIP-classified customers only",
  "data": {
    "segmentId": 55,
    "attribute": "account.classification",
    "operator": "eq",
    "value": "vip",
    "ruleGroup": 0,
    "negate": false,
    "sortOrder": 0
  }
}

Create a targeting assignment

Links a segment to a campaign or journey. Use targetRef to reference a journey or campaign entity created in the same recommendation tree; use targetId when the entity already exists. Use segmentRef or segmentId the same way.

POST /places/123/recommendations
{
  "type": "create",
  "category": "targeting_assignment",
  "rationale": "Restrict win-back journey to slipping-away customers only",
  "parent_id": 601,
  "data": {
    "segmentId": 12,
    "targetRef": "winback_journey",
    "targetType": "journey"
  }
}

Collections

A collection recommendation groups multiple entity changes that should be reviewed and applied together. The changes are defined as an elements array inside the collection's data. When the collection is accepted all elements are applied atomically — if any element fails, nothing is persisted.

Each element has the same fields as a standalone recommendation (category, type, rationale, data) plus an optional localRef — a caller-defined string identifier used to reference the entity created by that element from other elements in the same collection.

Cross-references between elements

Elements are processed in order. Any element can reference a localRef defined by an earlier element using the following fields:

  • segmentRef: "my_segment" in targeting_rule or targeting_assignment data — resolves to the segment entity ID
  • targetRef: "my_journey" in targeting_assignment data — resolves to the target entity ID
  • templateRef: "my_template" inside a journey's machineConfig states — resolves to the template entity ID

localRef and all *Ref fields are stripped before entity creation. If an element references a localRef that is not yet defined by a preceding element, the API returns INCORRECT_ORDER_OF_ELEMENTS at creation time — before the recommendation is stored.


Example: New journey

A welcome journey that sends an email to every new loyalty account. Two elements: the template followed by the journey. The template must appear first so its localRef is defined when the journey's machineConfig is validated.

POST /places/123/recommendations
{
  "type": "create",
  "category": "collection",
  "rationale": "Welcome email for new loyalty members",
  "data": {
    "elements": [
      {
        "localRef": "welcome_email",
        "category": "communication_template",
        "type": "create",
        "rationale": "Welcome email for new members",
        "data": {
          "name": "Welcome email",
          "type": "email",
          "recipient": "{{user.email}}",
          "subject": "Welcome to {{place.title}}",
          "body": "Hi {{user.name}}, welcome to our loyalty programme! Your points start from your very next order."
        }
      },
      {
        "category": "journey",
        "type": "create",
        "rationale": "Send a welcome email when a new loyalty account is created",
        "data": {
          "name": "Welcome new members",
          "triggerEvent": "account.created",
          "machineConfig": {
            "initial": "send_welcome",
            "states": {
              "send_welcome": {
                "meta": { "type": "send_email", "templateRef": "welcome_email" },
                "on": { "DONE": "done" }
              },
              "done": { "type": "final" }
            }
          }
        }
      }
    ]
  }
}
// → id: 500
POST /places/123/recommendations/500/accept

Both entities are created atomically. On failure everything is rolled back.


Example: Campaign with a journey and multiple templates

A complete campaign setup with three messaging steps. Templates must appear before the journey so their localRef values are defined when the journey's machineConfig is validated.

POST /places/123/recommendations
{
  "type": "create",
  "category": "collection",
  "rationale": "Summer loyalty campaign with nurture journey",
  "data": {
    "elements": [
      {
        "localRef": "summer_push",
        "category": "communication_template",
        "type": "create",
        "rationale": "Push notification for campaign launch",
        "data": {
          "name": "Summer campaign — launch push",
          "type": "push_notification",
          "recipient": "{{user.token}}",
          "subject": "A special offer just for you",
          "body": "Hi {{user.name}}, enjoy 25% off your next order with code SUMMER25."
        }
      },
      {
        "localRef": "summer_email",
        "category": "communication_template",
        "type": "create",
        "rationale": "Follow-up email after 48 hours",
        "data": {
          "name": "Summer campaign — follow-up email",
          "type": "email",
          "recipient": "{{user.email}}",
          "subject": "Don't forget your 25% off — only a few days left",
          "body": "Hi {{user.name}}, your 25% discount is still waiting. Use code SUMMER25 before it expires."
        }
      },
      {
        "localRef": "summer_sms",
        "category": "communication_template",
        "type": "create",
        "rationale": "Final SMS reminder before campaign ends",
        "data": {
          "name": "Summer campaign — SMS reminder",
          "type": "sms",
          "recipient": "{{user.mobilePhone}}",
          "subject": "",
          "body": "Last chance! Use SUMMER25 for 25% off at {{place.title}}. Expires soon."
        }
      },
      {
        "localRef": "loyalty_segment",
        "category": "targeting_segment",
        "type": "create",
        "rationale": "Segment for loyalty programme members",
        "data": {
          "name": "Loyalty members"
        }
      },
      {
        "localRef": "summer_journey",
        "category": "journey",
        "type": "create",
        "rationale": "Nurture sequence triggered when the campaign starts",
        "data": {
          "name": "Summer campaign nurture",
          "triggerEvent": "campaign.public.started",
          "machineConfig": {
            "initial": "send_push",
            "states": {
              "send_push": {
                "meta": { "type": "send_push_notification", "templateRef": "summer_push" },
                "on": { "DONE": "wait_48h" }
              },
              "wait_48h": {
                "meta": { "type": "wait_for_duration", "hours": 48 },
                "on": { "ELAPSED": "send_email" }
              },
              "send_email": {
                "meta": { "type": "send_email", "templateRef": "summer_email" },
                "on": { "DONE": "wait_24h" }
              },
              "wait_24h": {
                "meta": { "type": "wait_for_duration", "hours": 24 },
                "on": { "ELAPSED": "send_sms" }
              },
              "send_sms": {
                "meta": { "type": "send_sms", "templateRef": "summer_sms" },
                "on": { "DONE": "done" }
              },
              "done": { "type": "final" }
            }
          }
        }
      },
      {
        "category": "targeting_assignment",
        "type": "create",
        "rationale": "Restrict journey to loyalty members only",
        "data": {
          "segmentRef": "loyalty_segment",
          "targetRef": "summer_journey",
          "targetType": "journey"
        }
      },
      {
        "localRef": "summer_campaign",
        "category": "campaign",
        "type": "create",
        "rationale": "Launch summer loyalty campaign",
        "data": {
          "code": "SUMMER25",
          "type": "total_bill_discount",
          "discount": 25,
          "discountType": "percentage",
          "redemptions": 500,
          "redemptionsPerCustomer": 1,
          "startTime": 1751328000,
          "endTime": 1753920000,
          "visibility": "public"
        }
      }
    ]
  }
}
// → id: 500
POST /places/123/recommendations/500/accept

All entities are created atomically. Journeys are activated before campaigns, so the journey is live when the campaign fires its campaign.public.started trigger event.


Example: Win-back slipping-away customers

The journey fires automatically for each customer the moment they are classified as slipping away — no targeting assignment or campaign needed. The trigger event is account.classification.became_slipping_away.

POST /places/123/recommendations
{
  "type": "create",
  "category": "collection",
  "rationale": "Send a win-back email when a customer starts slipping away",
  "data": {
    "elements": [
      {
        "localRef": "winback_email",
        "category": "communication_template",
        "type": "create",
        "rationale": "Win-back email offering 20% off",
        "data": {
          "name": "Win-back email",
          "type": "email",
          "recipient": "{{user.email}}",
          "subject": "We miss you — here's 20% off your next order",
          "body": "Hi {{user.name}},\n\nIt's been a while! Use code WINBACK20 for 20% off at {{place.title}}."
        }
      },
      {
        "category": "journey",
        "type": "create",
        "rationale": "Send win-back email when a customer is classified as slipping away",
        "data": {
          "name": "Win-back",
          "triggerEvent": "account.classification.became_slipping_away",
          "machineConfig": {
            "initial": "send_email",
            "states": {
              "send_email": {
                "meta": { "type": "send_email", "templateRef": "winback_email" },
                "on": { "DONE": "done" }
              },
              "done": { "type": "final" }
            }
          }
        }
      }
    ]
  }
}
// → id: 600
POST /places/123/recommendations/600/accept

Both entities are created atomically. The journey starts firing immediately for any customer classified as slipping away.


Response Format

Recommendations are returned wrapped in a node.recommendation object keyed by ID:

{
  "node.recommendation": {
    "500": {
      "id": 500,
      "placeId": 123,
      "type": "create",
      "category": "collection",
      "rationale": "Win-back campaign",
      "data": { "elements": [ ... ] },
      "parentId": null,
      "status": "pending",
      "createdAt": 1714060800,
      "updatedAt": 1714060800
    }
  }
}

List responses also include a pager object:

{
  "node.recommendation": { ... },
  "pager": { "total": 45, "limit": 10, "page": 1, "pages": 5 }
}

Error Responses

Status Code Description
400 MISSING_REQUIRED_FIELDS One or more of type, category, or rationale is missing. For collection elements, the same applies to each element in the array.
400 INCORRECT_ORDER_OF_ELEMENTS A collection element references a localRef (via segmentRef, targetRef, or templateRef) that is not defined by any preceding element. Returned at creation time.
400 Request body contains non-editable fields (id, placeId, status, etc.)
400 INVALID_RECOMMENDATION_STATUS Attempted to accept a recommendation that is not pending
400 ACCEPT_ACTION_FAILED An element failed during accept. The response includes failedId. All changes are rolled back.
400 MISSING_SEGMENT_ID A targeting_rule or targeting_assignment element has no segmentId or segmentRef
400 MISSING_TARGET_ID A targeting_assignment element has no targetId or targetRef
401 API_KEY_REQUIRED No API key was provided
403 INVALID_API_KEY API key is invalid or user is not a manager of this place
404 Recommendation not found

Comments