Offer generator (Market integration)

Обновлено: May 6, 2026Раздел: General

Offer generator (Market integration)

Full API reference: https://api.mailoo.app/docs/v1

The Offer generator is a Market-only feature: wizards are scoped to a MARKET integration so they can resolve published products and price lists from the same catalog. Offer storage, SMTP, and outbound delivery are self-contained (separate tables and mail settings from other integration types).

For catalog read endpoints (types, products, price lists), continue to use ``market.external-read`` as documented in market-catalog-external-api{.interpreted-text role="doc"}.

Overview

  • Dashboard: Project → Market integration → tab Offer generator --- list, create, edit, delete generators; configure wizard JSON, optional AI, and SMTP; external URL patterns for pre-form.
  • Owner API (JWT / session Bearer): Create and update generators and mail settings under /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators….
  • External API (``X-API-Key``): Same path prefix as Market external routes --- /api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/… --- with separate scopes from market.external-read.
  • Scopes:
  • ``offer-generator.external-read`` --- GET …/offer-generators/{generatorId} (wizard JSON with catalog blocks resolved for published data only).
  • ``offer-generator.submit`` --- POST …/offer-generators/{generatorId}/submissions (validate answers, optional AI, persist offer, send mail).
  • Persistence: OfferGenerator, OfferGeneratorMailSettings, GeneratedOffer, OfferDelivery (Prisma). Nothing is written to messages or integration outboundMail.

Wizard configuration (version 1)

Stored as JSON on ``OfferGenerator.wizardConfig``. The last step must be a ``final`` block.

Step shape

  • id --- stable string id (referenced by ai blocks includeStepIds).
  • visibleWhen (optional) --- either a single atomic condition or an OR of AND groups:
  • Atomic --- object { "var": "<name>", "equals": "<value>" } or { "var": "<name>", "oneOf": ["…"] } (exactly one of equals or oneOf).
  • Compound --- { "or": [ { "and": [ <atom>, … ] }, … ] }. The step is visible when any and array matches; within a group every atom must match (same atom rules as above). Use this when several independent combinations should show the same step.
  • block --- discriminated by type:
  • ``text`` --- Optional bodyMarkdown; fields[] with name, optional label, kind (text | textarea | email | phone | boolean | pinGroup), required. For pinGroup, options[] (at least two value / label pairs) is required; one value is stored in the field name variable. Each field may set useMarkdownBody (boolean, default false): when true, markdownBody (markdown) is the field prompt instead of label; prepared wizards include markdownBodyHtml per field when markdownBody is set. Fields may also attach catalog product lists (see offer-generator-field-product-lists{.interpreted-text role="ref"}).
  • ``choice`` --- variable; options[] with value, label, optional setVariables map (client may merge into submitted variables).
  • ``product`` --- productIds[]; showFields; optional showPrice (default true). External GET embeds resolvedProducts. Each row includes ``images``, ``previewMediaId``, and ``previewImageUrl`` when the catalog product defines them (same merged-locale contract as the Market external product API: no locales, optional locale query on GET …/offer-generators/{id}). For each key in showFields that is a catalog ``MD_TEXT`` attribute on the resolved product, the prepared wizard also adds "<key>Html" (HTML from markdown, with Mailoo image URLs normalized) alongside the raw markdown/string value.
  • ``priceList`` --- priceListId; optional lineSkuFilter; showFields; optional showPrice. External GET embeds resolvedLines.
  • ``ai`` --- promptTemplate (supports {{varName}} placeholders); optional outputVariable (default aiText); optional includeStepIds for extra JSON context. Requires AI settings on the generator (see below).
  • ``final`` --- customerEmailVariable (must match a submitted variable name); optional submitLabel, bodyMarkdown and fields[]. Final fields use the same schema and validation rules as text.fields[] and are submitted in the same variables object.

AI settings (optional)

Stored on ``OfferGenerator.aiSettings`` as openai-compatible JSON: baseUrl, model, and encrypted apiKeyEncrypted (same encryption mechanism as SMTP secrets). The API never returns the key; owner GET responses expose hasApiKey only.

Mail settings (required for successful email delivery)

PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- host, port, secure, user, from, optional replyTo, optional notificationEmail (BCC-style internal alert), optional smtpPassword (write-only on PUT).

If mail settings are missing or SMTP send fails, the submission still creates a ``GeneratedOffer`` with status ``FAILED`` and diagnostics; ``OfferDelivery`` rows record per-channel attempts (``CUSTOMER`` / ``NOTIFICATION``).

Owner API (Bearer)

  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators
  • POST /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators
  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • DELETE /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}
  • GET /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings --- returns { data: null } when no row exists yet; otherwise same non-secret fields as after PUT.
  • PUT /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/mail-settings

POST create body includes key (slug per integration), optional name, status, wizardConfig, optional aiSettings (apiKey is write-only when present).

External API (X-API-Key)

Replace placeholders with your project UID, MARKET integration id, and generator id (CUID).

  • GET {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}

    Returns { data: { id, key, name, status, wizard } } where wizard.steps[].block may include bodyHtml, field markdownBodyHtml, resolvedProducts and resolvedLines for prepared rendering. Optional query ``locale`` selects the catalog language for embedded products (same rules as Market external GET …/products; default from integration defaultCatalogLocale, else en).

  • POST {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/submissions

    Body: { "variables": { … } } --- all answers keyed by field / choice variable names. Response: { data: { id, status, customerEmail } } with status COMPLETED or FAILED.

BFF / pre-form pattern

Keep two restricted keys on the server (never in the browser):

  1. Read key --- offer-generator.external-read --- server loads the wizard once (or caches) and renders UI.
  2. Submit key --- offer-generator.submit --- server posts the final variables JSON after validating on your side.

Catalog lookups from the client still go through your BFF using ``market.external-read`` if you need live product data outside the embedded resolvedProducts snapshot.

Field product lists {#offer-generator-field-product-lists}

Text-like fields (text.fields[] and final.fields[]) can show product recommendations next to the input. This lets an integrator build a rich pre-form without adding custom catalog calls for every field.

For non-boolean fields, attach products:

{
  "name": "useCase",
  "label": "What do you need?",
  "kind": "textarea",
  "required": true,
  "products": {
    "productIds": ["product_cuid_1", "product_cuid_2"],
    "showFields": ["name", "sku"],
    "showPrice": true,
    "priceKindId": "price_kind_cuid"
  }
}

For boolean fields, use separate lists for each state:

{
  "name": "installation",
  "label": "Include installation?",
  "kind": "boolean",
  "required": false,
  "productsWhenTrue": {
    "productIds": ["installation_service_id"],
    "showFields": ["name", "sku"],
    "showPrice": true,
    "priceKindId": "price_kind_cuid"
  }
}

On external GET, Mailoo prepares these lists with resolvedProducts for published products only. Render from resolvedProducts when present, and fall back gracefully when it is empty (unpublished, deleted, filtered, or no current price).

Integration UI patterns

Mailoo's dashboard preview is intentionally close to what an external site can implement. Recommended client structure:

  • Load the prepared wizard from your server/BFF with offer-generator.external-read and pass only the wizard JSON to the browser.
  • Keep a single variables object in browser state. Every field, choice, pin group, and AI preview output writes to that object by variable name.
  • Recompute visible steps from visibleWhen whenever variables changes.
  • Render prepared bodyHtml / markdownBodyHtml when present; otherwise render the plain markdown text as fallback.
  • Render resolvedProducts / resolvedLines from the prepared wizard, not by exposing API keys in the browser.
  • On submit, validate visible steps locally for UX, then post { "variables": variables } from your server/BFF using the offer-generator.submit key.

Minimal visibility helper:

type Vars = Record<string, string | number | boolean | null>

function asString(value: unknown): string | undefined {
  if (value === null || value === undefined) return undefined
  if (typeof value === 'string') return value
  if (typeof value === 'number' || typeof value === 'boolean') {
    return String(value)
  }
  return undefined
}

function atomMatches(
  atom: { var: string; equals?: string; oneOf?: string[] },
  vars: Vars
): boolean {
  const current = asString(vars[atom.var])
  if (atom.equals !== undefined) {
    return current === asString(atom.equals)
  }
  if (Array.isArray(atom.oneOf)) {
    return current !== undefined && atom.oneOf.includes(current)
  }
  return false
}

function isVisible(step: { visibleWhen?: any }, vars: Vars): boolean {
  const when = step.visibleWhen
  if (!when) return true
  if (Array.isArray(when.or) && when.or.length > 0) {
    return when.or.some(
      (group: { and?: unknown }) =>
        Array.isArray(group.and) &&
        group.and.length > 0 &&
        group.and.every((atom: any) => atomMatches(atom, vars))
    )
  }
  return atomMatches(when, vars)
}

const visibleSteps = wizard.steps.filter((step) => isVisible(step, vars))

Choice blocks should update both their main variable and optional setVariables in one state change:

function chooseOption(block: any, option: any) {
  setVars((prev) => ({
    ...prev,
    [block.variable]: option.value,
    ...(option.setVariables ?? {}),
  }))
}

Text and final fields share rendering rules:

function renderField(field: any) {
  const name = field.name
  const value = String(vars[name] ?? '')

  if (field.kind === 'boolean') {
    return (
      <input
        type="checkbox"
        checked={value.toLowerCase() === 'true'}
        onChange={(e) => setVar(name, e.target.checked ? 'true' : 'false')}
      />
    )
  }

  if (field.kind === 'pinGroup') {
    return field.options.map((option: any) => (
      <button type="button" onClick={() => setVar(name, option.value)}>
        {option.label}
      </button>
    ))
  }

  return (
    <input
      type={field.kind === 'email' ? 'email' : field.kind === 'phone' ? 'tel' : 'text'}
      value={value}
      onChange={(e) => setVar(name, e.target.value)}
    />
  )
}

For product recommendations attached to fields, use the prepared list:

function fieldProductsFor(field: any) {
  if (field.kind === 'boolean') {
    const checked = String(vars[field.name] ?? '').toLowerCase() === 'true'
    return checked
      ? field.productsWhenTrue?.resolvedProducts ?? []
      : field.productsWhenFalse?.resolvedProducts ?? []
  }
  return field.products?.resolvedProducts ?? []
}

Submit pattern:

async function submitOffer() {
  // Browser posts to your own server route. The server route adds X-API-Key.
  const res = await fetch('/api/offer-generator/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ variables: vars }),
  })
  if (!res.ok) throw new Error('Offer submission failed')
  return res.json()
}

Security and rendering notes

  • Never expose offer-generator.external-read or offer-generator.submit keys to the browser.
  • Treat prepared bodyHtml and markdownBodyHtml as HTML generated by Mailoo's server pipeline for the wizard you own. Do not pass arbitrary visitor input through dangerouslySetInnerHTML.
  • Keep local browser validation as a convenience only. The API validates submitted variables again, including visible text and final fields, choice values, phone/email formats, and customerEmailVariable.
  • If the UI lets visitors go backward and change earlier answers, remove or recompute variables from later hidden steps before submit, or rely on the server-side visible-step validation to reject inconsistent payloads.
  • market-catalog-external-api{.interpreted-text role="doc"} --- product and price list reads.
  • market-catalog-csv{.interpreted-text role="doc"} --- bulk catalog edits in the dashboard.
  • outbound-smtp{.interpreted-text role="doc"} --- not used for offer generator mail (offer generator uses its own SMTP table).
📚