Offer generator (Market integration)
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 frommarket.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 tomessagesor integrationoutboundMail.
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 byaiblocksincludeStepIds).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 ofequalsoroneOf). - Compound ---
{ "or": [ { "and": [ <atom>, … ] }, … ] }. The step is visible when anyandarray 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 bytype:- ``text`` --- Optional
bodyMarkdown;fields[]withname, optionallabel,kind(text|textarea|email|phone|boolean|pinGroup),required. ForpinGroup,options[](at least twovalue/labelpairs) is required; one value is stored in the fieldnamevariable. Each field may setuseMarkdownBody(boolean, default false): when true,markdownBody(markdown) is the field prompt instead oflabel; prepared wizards includemarkdownBodyHtmlper field whenmarkdownBodyis set. Fields may also attach catalog product lists (seeoffer-generator-field-product-lists{.interpreted-text role="ref"}). - ``choice`` ---
variable;options[]withvalue,label, optionalsetVariablesmap (client may merge into submitted variables). - ``product`` ---
productIds[];showFields; optionalshowPrice(default true). External GET embedsresolvedProducts. Each row includes ``images``, ``previewMediaId``, and ``previewImageUrl`` when the catalog product defines them (same merged-locale contract as the Market external product API: nolocales, optionallocalequery onGET …/offer-generators/{id}). For each key inshowFieldsthat 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; optionallineSkuFilter;showFields; optionalshowPrice. External GET embedsresolvedLines. - ``ai`` ---
promptTemplate(supports{{varName}}placeholders); optionaloutputVariable(defaultaiText); optionalincludeStepIdsfor extra JSON context. Requires AI settings on the generator (see below). - ``final`` ---
customerEmailVariable(must match a submitted variable name); optionalsubmitLabel,bodyMarkdownandfields[]. Final fields use the same schema and validation rules astext.fields[]and are submitted in the samevariablesobject.
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-generatorsPOST /api/v1/projects/{projectUid}/integrations/{integrationId}/offer-generatorsGET /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 afterPUT.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 } }wherewizard.steps[].blockmay includebodyHtml, fieldmarkdownBodyHtml,resolvedProductsandresolvedLinesfor prepared rendering. Optional query ``locale`` selects the catalog language for embedded products (same rules as Market externalGET …/products; default from integrationdefaultCatalogLocale, elseen). -
POST {baseUrl}/api/v1/market/{projectUid}/integrations/{integrationId}/offer-generators/{generatorId}/submissionsBody:
{ "variables": { … } }--- all answers keyed by field / choice variable names. Response:{ data: { id, status, customerEmail } }withstatusCOMPLETEDorFAILED.
BFF / pre-form pattern
Keep two restricted keys on the server (never in the browser):
- Read key ---
offer-generator.external-read--- server loads the wizard once (or caches) and renders UI. - Submit key ---
offer-generator.submit--- server posts the finalvariablesJSON 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-readand pass only the wizard JSON to the browser. - Keep a single
variablesobject in browser state. Every field, choice, pin group, and AI preview output writes to that object by variable name. - Recompute visible steps from
visibleWhenwhenevervariableschanges. - Render prepared
bodyHtml/markdownBodyHtmlwhen present; otherwise render the plain markdown text as fallback. - Render
resolvedProducts/resolvedLinesfrom 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 theoffer-generator.submitkey.
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-readoroffer-generator.submitkeys to the browser. - Treat prepared
bodyHtmlandmarkdownBodyHtmlas HTML generated by Mailoo's server pipeline for the wizard you own. Do not pass arbitrary visitor input throughdangerouslySetInnerHTML. - Keep local browser validation as a convenience only. The API validates submitted variables again, including visible
textandfinalfields, choice values, phone/email formats, andcustomerEmailVariable. - 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.
Related
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).