Integration Guide

AironFlow plugs into your store in two complementary ways. The Content Widget is a drop-in script that renders AI-optimized descriptions, SEO, and GEO content directly on your product, category, and brand pages, no backend work required. The Sync API gives you a pull-based contract: we mark items as pending_sync whenever new content is approved, you fetch only those items, save them in your own database, and confirm them as synced.

Base URL

https://staging.aironflow.io

Response format

application/json

Sync flow

  1. 1AironFlow generates and approves AI content for a product, category, or brand. The entity's sync status becomes pending_sync.
  2. 2(Optional) AironFlow fires a data_ready webhook to a URL you configure, so you know immediately.
  3. 3Your backend calls GET /api/v1/sync/{entity}/pending (products, categories, brands) to pull only the items that need saving.
  4. 4You save or update those items in your own database, matching by externalId.
  5. 5You POST the saved IDs to /api/v1/sync/{entity}/confirm. The status flips to synced and syncedAt is recorded.
  6. 6When anything changes (re-generation, manual edit, approval of a new batch), the entity is flipped back to pending_sync and the cycle repeats.
Sync statusMeaning
pending_syncAI content approved (or modified after a previous sync), waiting for your store to fetch and save.
syncedYour store confirmed the latest version. Nothing to do until it changes again.

Authentication

All API requests must include an API key in the Authorization header using the Bearer scheme. Keys are prefixed ac_ and are scoped to a single eshop, cross-tenant access is not possible.

Authorization: Bearer ac_your_api_key_here

How to get an API key

Log in to your AironFlow workspace → Settings → Integrations → generate a key. The full key is only shown once, store it in your environment variables. A disabled key returns 401.

Error responses

Errors are returned as JSON with an error field.

StatusMeaning
401Missing or invalid API key
400Bad request, malformed body or invalid query parameters
404Entity not found or does not belong to your eshop
500Internal server error
{ "error": "Unauthorized" }

Content Widget

The widget is the fastest way to get AI content live: one script tag in your page template plus one placeholder per entity. It fetches approved content over the network and renders it in-place, no deploys needed when content changes.

Where to get the widget key

In your workspace → Settings → Integrations, generate a widget key. Widget keys are prefixed af_pk_ and are separate from your API key. You can restrict them to specific domains there as well.

Embed snippet

Add the script tag once, ideally at the end of <head> or just before </body>:

<script src="https://staging.aironflow.io/api/widget/YOUR_WIDGET_KEY/v1.js" defer></script>

Entity targets

Drop a placeholder wherever you want AI content rendered. The widget finds every element with a data-af-widget attribute and fills it in. The data-af-entity-id value is your feed ID, the same one you submit to us.

<!-- Product page -->
<div data-af-widget data-af-entity-type="product" data-af-entity-id="YOUR_PRODUCT_ID"></div>

<!-- Category page -->
<div data-af-widget data-af-entity-type="category" data-af-entity-id="YOUR_CATEGORY_ID"></div>

<!-- Brand page -->
<div data-af-widget data-af-entity-type="brand" data-af-entity-id="YOUR_BRAND_ID"></div>

Heads up, row-based enhanced descriptions need a full-width target

If your AI content uses rows (block sections with coloured backgrounds, dividers, alternating layouts), the data-af-widget target should be a full-width container, a top-level <div style="width:100%"> placed outside any narrow product-page column. Each row spans its container edge-to-edge, so backgrounds get clipped if the placeholder sits inside a constrained column.

Only approved content is served. Unapproved drafts and rejected variants never reach the widget.

Custom rendering

By default you drop descriptionFullHtml into the page as one blob, or let the widget render it. If you want to build the description yourself — most often to swap our image gallery for your own carousel, or to slot your own sections between ours — request the descriptionBlocks field: the same content, as an ordered list of per-block HTML fragments.

Contact us to enable custom rendering. It is switched on per e-shop from our side, so the field will not appear in your API responses until we turn it on for your account. Email [email protected] and we will enable it — it takes effect immediately, with no re-sync and no regeneration on your side.

Response — descriptionBlocks

Two additional fields on every product. The first entry of the array is always the stylesheet.

"descriptionBlocks": {
  "rootClass": "af-ec-root",

  "stylesheet": {
    "href": "https://app.aironflow.io/api/widget/af_pk_xxx/v1.css",
    "html": "<link rel=\"stylesheet\" href=\"...\">"
  },

  "rows": [
    "<section class=\"af-ec-row af-ec-row--default\" data-has-title ...>...</section>",
    "<section class=\"af-ec-row af-ec-row--alt\" ...>...</section>",
    "<section class=\"af-ec-row af-ec-row--themed\" ...>...</section>"
  ]
},
"descriptionJsonLd": "<script type=\"application/ld+json\">...</script>"
  • rows — the description, one complete <section> per row, in document order. Every entry is a whole element — nothing is a fragment, so you can print, parse or validate any of them.
  • rootClass — the class for your inner wrapper. Usually af-ec-root, but compact descriptions need an extra modifier that carries their width and padding. Read it rather than hardcoding it.
  • stylesheet — one per response, not per row. Link it once; see Styling below.
  • descriptionJsonLd is document-level FAQ schema — put it in the page head, or omit it.
  • The array is null when a product has no AironFlow content, and for a small number of older products — fall back to descriptionFullHtml.

How to render it

Two wrapper divs, then print each row's html. Put the product id on the outer wrapper so analytics and recommendations still know what page this is. There is no widget mount to add and nothing to switch off.

<div class="af-ec-standalone"
     data-af-entity-type="product"
     data-af-entity-id="YOUR_PRODUCT_ID">
  <div class="{rootClass}">

    <!-- stylesheet.html - or link it once in <head> instead -->

    <!-- then, for every row, in order: -->
    {row.html}

  </div>
</div>

Adding your own sections

Emit your own markup between any two rows. Our rows are opaque strings you never modify, so nothing about our markup can drift.

<div class="af-ec-standalone"
     data-af-entity-type="product"
     data-af-entity-id="YOUR_PRODUCT_ID">
  <div class="{rootClass}">

    {rows[0]}
    {myCustomSection1}
    {rows[1]}
    {rows[2]}
    ...
    {rows[n]}
    {myCustomSection2}

  </div>
</div>

Note the split in the Razor sample: the product id goes through normal interpolation so it is HTML-encoded, while the rows go through Html.Raw because they are already-rendered markup. Encoding them would print tags on the page.

One wrapper for the whole description — do not repeat it per row.

Both wrapper divs are emitted once, around all the rows and anything you interleave. Opening a fresh rootClass div per row or per group restarts the section numbering: that 01 / 02 / counter is reset on the wrapper, so a second wrapper starts again at 01.

Your markup inherits our CSS reset. Everything inside the wrapper gets margin: 0, padding: 0 and box-sizing: border-box, and images become display: block. In practice a bare paragraph loses its margins and a list loses its bullet indent — so give your own sections explicit spacing. If a section does not need to sit between rows, put it outside .af-ec-standalone entirely and it is untouched.

Want your own component instead of one of our blocks? Don't rebuild our row around it — ask us to turn that block off for your store, and place your component between rows instead. We exclude it at generation time, so after your next content refresh our rows simply won't contain it. Your markup and ours stay completely separate.

Styling

stylesheet links a CSS file specific to your e-shop. It is identical for every product and is CDN-cached, so link it once in the page head rather than emitting it per product. When you change your theme or palette in AironFlow, the new styling reaches your storefront on the next cache expiry — no product re-sync needed.

Add ?blocksCss=inline to a request to get an inline style tag instead. Useful for one-off single-product fetches; on a list endpoint it repeats roughly 50KB per product.

Sync API

The Sync API is three endpoints per entity: check status, fetch pending items, confirm saved items. Rate-limited to 100 requests per minute per API key.

Sync status overview

GET/api/v1/sync/status

Aggregate counts per entity type. Use as a cheap health check before deciding whether to fetch.

Response

{
  "products":   { "pending_sync": 42, "synced": 380, "total": 422 },
  "categories": { "pending_sync": 3,  "synced": 25,  "total": 28  },
  "brands":     { "pending_sync": 1,  "synced": 12,  "total": 13  }
}

Products — pending

GET/api/v1/sync/products/pending

Returns only products whose sync status is pending_sync. Full product payload with AI-optimized content, SEO, GEO, images, categories, specifications, and variations.

Query parameters

ParameterTypeRequiredDescription
pageintegerOptionalPage number(default: 1)
limitintegerOptionalItems per page, max 200(default: 50)
externalIdstringOptionalExact match on your feed entity ID
sinceISO-8601 timestampOptionalOnly return items updated after this time (products only)

Response

{
  "data": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "externalId": "prod-001",
      "syncStatus": "pending_sync",
      "name": "Wireless Bluetooth Headphones",
      "brand": "AudioPro",
      "shortDescription": "Noise-cancelling over-ear headphones.",
      "description": "<p>High-quality wireless headphones...</p>",
      "descriptionFullHtml": "<style>...</style><div class="af-ec-root">...enhanced description + FAQ + highlights + use cases + tags, fully styled...</div>",
      "sku": "SKU-HEAD-001",
      "barcode": "5901234123457",
      "price": "89.99",
      "oldPrice": "119.99",
      "stock": 50,
      "published": true,
      "seo": {
        "slug": "wireless-bluetooth-headphones",
        "metaTitle": "Best Wireless Headphones 2024 | AudioPro",
        "metaDescription": "Buy AudioPro wireless headphones...",
        "metaKeywords": "wireless headphones, noise cancelling"
      },
      "geo": { "faq": [...], "prosAndCons": {...}, "useCases": [...] },
      "images": [ { "url": "...", "isPrimary": true, "order": 0 } ],
      "categories": [ { "externalId": 162, "name": "Audio", "path": "Electronics > Audio" } ],
      "specifications": [ { "name": "Color", "values": ["Black","White"] } ],
      "variations": [ { "sku": "...", "color": "Black", "price": "89.99", "stock": 30 } ],
      "translations": { "en": { "name": "...", "descriptionFullHtml": "...", "seo": {...}, "geo": {...} } }
    }
  ],
  "pagination": { "page": 1, "limit": 50, "total": 42, "totalPages": 1 }
}

About descriptionFullHtml

Single fully-styled HTML blob that combines the AironFlow enhanced description with every adjacent section, FAQ, highlights, use cases, tags. All CSS is inlined and scoped under .af-ec-root and [data-af-widget], so it won't leak into surrounding storefront styles.

Wrap it in <div data-af-widget data-af-enhance-only data-af-entity-type="product" data-af-entity-id="…"> and link the widget stylesheet /api/widget/<widgetKey>/v1.css once in <head>: the HTML carries no CSS of its own, and data-af-enhance-only keeps the widget script from re-fetching and re-rendering what you stored (it only wires the FAQ accordion, galleries and the pageview beacon). Load the script deferred. Running OpenCart 3? The shipped extension does all of this — see Settings → Integrations.

Recommended integration: store this value in a dedicated table keyed by external product ID (and locale, from translations). When rendering a product page, if a row exists output this HTML directly; otherwise fall back to your platform's stock description. The field is null for products that have no AironFlow content yet.

description, geo.*, and descriptionFullHtml intentionally overlap. Pick one strategy: either reassemble UI from the structured fields, or render the full HTML blob. Mixing both will duplicate content.

About translations

Every product, category, and brand carries a translations object, a map keyed by ISO-639-1 locale (en, de, …) of the entity's approved translations (same fields per locale, products also include descriptionFullHtml + geo). Locales generated but not yet approved are omitted. It is always present ({} when none) and purely additive, ignore it if you don't serve a multilingual storefront.

Products — confirm

POST/api/v1/sync/products/confirm

Marks products as synced once saved. Accepts up to 1 000 AironFlow UUIDs per call. Status flips to synced and syncedAt is stamped.

Request body

{
  "ids": [
    "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "7b3c1a22-9e4f-4c3b-8a11-1d2e3f4a5b6c"
  ]
}

Response

{ "confirmed": 2, "ids": ["...", "..."] }

Categories — pending

GET/api/v1/sync/categories/pending

Same shape as products. Includes parentExternalId so you can rebuild the tree locally.

ParameterTypeRequiredDescription
pageintegerOptionalPage number(default: 1)
limitintegerOptionalItems per page, max 200(default: 50)
externalIdstringOptionalExact match on your feed entity ID

Categories — confirm

POST/api/v1/sync/categories/confirm
{ "ids": ["uuid1", "uuid2"] }

Brands — pending

GET/api/v1/sync/brands/pending

Same shape as products, brand-specific fields only.

ParameterTypeRequiredDescription
pageintegerOptionalPage number(default: 1)
limitintegerOptionalItems per page, max 200(default: 50)
externalIdstringOptionalExact match on your feed entity ID

Brands — confirm

POST/api/v1/sync/brands/confirm
{ "ids": ["uuid1"] }

Webhook — data_ready

Configure a webhook URL in Settings → Integrations and enable Data Ready for Sync. Each approval batch fires a POST to your URL so you can start fetching without polling.

POST https://your-eshop.com/your-webhook-endpoint
Content-Type: application/json

{
  "event": "data_ready",
  "eshopId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "timestamp": "2026-02-20T10:30:00Z",
  "summary": { "products": 3, "categories": 0, "brands": 0 },
  "entities": {
    "products": [
      { "id": "uuid1", "externalId": "prod-001" },
      { "id": "uuid2", "externalId": "prod-002" }
    ]
  }
}

Return any 2xx within 10 seconds. Delivery is fire-and-forget, fall back to GET /api/v1/sync/status polling if the webhook is missed.

Full sync cycle (curl)

BASE_URL="https://staging.aironflow.io"
API_KEY="ac_your_api_key_here"

# 1. Check what's pending
curl -s -H "Authorization: Bearer $API_KEY" "$BASE_URL/api/v1/sync/status"

# 2. Fetch pending products
curl -s -H "Authorization: Bearer $API_KEY" \
  "$BASE_URL/api/v1/sync/products/pending?limit=100&page=1"

# 3. Save products in your store (your logic)

# 4. Confirm saved IDs
curl -s -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids":["uuid1","uuid2","uuid3"]}' \
  "$BASE_URL/api/v1/sync/products/confirm"

# 5. Repeat for categories and brands.