🔌 Store API
Endpoints

Endpoints

Twenty-six endpoints, relative to https://upsell.avada.io/store-api/v1.

Each one runs the same handler as the MCP tool beside it, so the two surfaces cannot drift apart. For what the returned fields mean, the MCP tool guide is the longer explanation; this page is the HTTP detail.

Which of them your key may call depends on its access level. A read key calling a write endpoint gets INSUFFICIENT_SCOPE, not a 404.

Reading

Every GET, available at every access level.

EndpointQueryAnswers
/setup-health—Can the gift widget appear on this store?
/campaignsstatusWhat offers are running?
/campaigns/{id}—How is one campaign configured?
/analyticsdays includeToday ianaTimezoneRevenue, orders, conversion, AOV
/productshandlesCan these products be gifted?
/widgets—Which widget types exist here?
/widgets/{type}—One widget's settings
/translationslocaleCustomised storefront wording
/settings—Global app configuration
/plan—Plan and usage

Resolving Shopify resources

Eight more GET endpoints turn a name into the ids the write endpoints need. They exist because a campaign is built from Shopify ids, and an id that was guessed points at nothing — or at the wrong product.

EndpointQueryFinds
/gift-productsqueryProducts that could be given as a gift
/trigger-productsqueryProducts that could trigger an offer
/collectionsqueryShopify collections
/customer-segmentsqueryShopify customer segments
/customer-locationsqueryMarkets and countries
/shipping-ratesqueryShipping rates, by zone
/markets—Every Shopify Market
/pos-locationsqueryLocations usable as POS channels

A search returns at most ten candidates. /gift-products and /trigger-products also report blockers — out of stock, unpublished, draft, subscription-only — and a stockStatus per product.

⚠️

Judge stock from stockStatus, never from totalInventory. A product with stockStatus: "not_tracked" has inventory tracking switched off and is always available, which is why its totalInventory comes back empty.

Writing

These need a key with write access, and the two deletion endpoints need delete. They take a JSON object as the request body; anything else is INVALID_INPUT.

MethodEndpointAccessDoes
POST/campaigns/previewWriteValidate and preview a campaign before creating it
POST/campaignsWriteCreate the exact campaign returned by the preview
POST/campaigns/{id}/edit-previewWritePreview a guarded campaign edit before applying it
PATCH/campaigns/{id}WriteEdit one supported campaign
PUT/campaigns/{id}/statusWriteTurn one campaign on or off
PATCH/widgets/{type}WriteEdit allowlisted settings for one widget type
POST/campaigns/{id}/deletion-previewDeletePreview permanent deletion of one campaign
POST/campaigns/{id}/deleteDeletePermanently delete one previously confirmed campaign

Prepare, then commit

Creating, editing and deleting are each two calls. The first validates everything, re-reads the Shopify resources involved, and returns a complete preview plus a short-lived confirmationToken. It writes nothing. The second sends that token back unchanged, and is the only one that writes.

StepCreateEditDelete
PreviewPOST /campaigns/previewPOST /campaigns/{id}/edit-previewPOST /campaigns/{id}/deletion-preview
CommitPOST /campaignsPATCH /campaigns/{id}POST /campaigns/{id}/delete

Repeating the same confirmed commit is idempotent — it returns the original result rather than creating a second campaign. Changing any commercial field invalidates the token, so a preview your integration did not re-run cannot be committed under new terms.

⚠️

POST /campaigns/{id}/delete cannot be undone, and its token is bound to the campaign's configuration at preview time: if the campaign changed in between, the deletion is refused rather than applied to something you did not look at. To stop an offer without losing it, use PUT /campaigns/{id}/status.

Write rate limit

Write calls consume a second, smaller budget on top of the shared 60 per minute:

All requests60 per minute, per key
Non-GET requests20 per minute, per key

Exceeding the write budget returns WRITE_RATE_LIMIT (429) while GET requests keep working.

The bodies these endpoints accept mirror the matching MCP tool exactly — see the MCP tool guide for the field-by-field contract, and /mcp/tools for the tool each endpoint maps to.


GET /store-api/v1 itself returns the full endpoint list, generated from the same table that builds the routes.


/setup-health

Whether the free gift widget can actually appear on the storefront.

Query: none

Returns: the live theme and whether it supports app blocks, whether the app embed is on, campaign counts by status, the active campaigns, and issues — the conclusion.

{
  "success": true,
  "data": {
    "domain": "example.myshopify.com",
    "theme": {
      "liveThemeId": 139204001866,
      "liveThemeName": "Horizon",
      "isSupportThemeOS": true,
      "embedEnabled": false,
      "themes": []
    },
    "campaigns": {
      "total": 37,
      "active": 1,
      "scheduled": 0,
      "expired": 36,
      "activeCampaigns": []
    },
    "issues": [
      {
        "code": "APP_EMBED_DISABLED",
        "message": "App embed is disabled on the live theme, so the widget cannot render."
      }
    ],
    "errors": []
  },
  "error": null
}

Issue codes: APP_EMBED_DISABLED, THEME_NOT_OS2, NO_CAMPAIGN, NO_ACTIVE_CAMPAIGN.

Sections fail independently. If Shopify is unreachable, campaign counts still come back and the failure is recorded in errors — so errors being non-empty does not mean the rest of the payload is untrustworthy, only that part of it is missing.


/campaigns

Query:

NameValuesNotes
statusactive · scheduled · expiredOmit for everything

Returns: total, and a summary of each campaign — id, name, status, type, targetPage, startDate, endDate, code.

Omit status and you get the campaigns the merchant can see — deleted ones are filtered out in the query, so a capped list of 100 means 100 visible campaigns rather than 100 rows with some missing.

⚠️

Pass only active, scheduled or expired. Any other value is currently forwarded to the query as-is: an unrecognised status returns an empty list rather than an error, and ?status=deleted returns soft-deleted campaigns the app treats as gone. Do not build on either behaviour.


/campaigns/{id}

Path parameter: campaignId, from /campaigns.

Returns: found, and the campaign's complete configuration — trigger conditions, gift products, discount settings, schedule.

An id that does not exist on this store returns HTTP 200 with found: false, not a 404. An id belonging to a different store returns the same, so this endpoint cannot be used to probe whether a campaign exists elsewhere.


/analytics

Query:

NameTypeNotes
days1–365Window length, ending yesterday. Default 30
includeTodaybooleanExtend to the store's current day
ianaTimezonestringOverride the timezone. Omit it

includeToday accepts true, 1, false, 0 — and bare ?includeToday with no value, which counts as yes.

Returns:

{
  "success": true,
  "data": {
    "domain": "example.myshopify.com",
    "period": {
      "start": "August 19, 2026",
      "end": "August 25, 2026",
      "days": 7,
      "timezone": "America/New_York",
      "includesToday": false
    },
    "currency": "USD",
    "cached": false,
    "metrics": {
      "orders": {"value": 0, "previous": 0, "changePercent": null},
      "revenue": {"value": 0, "previous": 0, "changePercent": null},
      "conversionRate": {"value": 0, "previous": 0, "unit": "percent", "changePercent": null},
      "aovByFG": {"value": 0, "previous": 0, "changePercent": null},
      "aovStore": {"value": 0, "previous": 0, "changePercent": null}
    }
  },
  "error": null
}

Three things worth reading rather than assuming:

  • period.timezone — windows are built in the store's own timezone, which may not be yours.
  • currency — so you never have to guess the symbol.
  • conversionRate.unit — it is a percentage, not a fraction.

changePercent is null when the previous window was zero; there is no meaningful change from nothing. includeToday makes the result uncached and partial — the store's day is still running.


/products

Query:

NameRequiredNotes
handlesYesOne handle, or several comma-separated. Max 50

A handle is the part of the product URL after /products/, not the title.

Returns: requested, found, notFound, and products — each with status, totalInventory, tracksInventory, publishedOnOnlineStore, variants, variantCount, giftableVariantCount, canBeGifted and issues. Each variant carries stockStatus and giftable.

⚠️

Use giftable, stockStatus and issues for the verdict. Do not compute it from inventoryQuantity and inventoryPolicy: a variant with stockStatus: "not_tracked" has inventory tracking off and is always available, and precisely because nothing is tracked, those two fields come back null. Treating null as zero produces a confident wrong answer.


/widgets

Query: none

Returns: total, and for each type its internal type name, display title, isPremium and isLocked.

{
  "success": true,
  "data": {
    "total": 11,
    "types": [
      {"type": "progress-bar", "title": "Milestone bar", "isPremium": false, "isLocked": false},
      {"type": "deal-of-the-day", "title": "Deal of the day", "isPremium": false, "isLocked": true}
    ]
  },
  "error": null
}

Call this before /widgets/{type} to get a valid name.


/widgets/{type}

Path parameter: type, for example progress-bar.

Returns: found and the saved settings. A type this store never configured returns 200 with found: false and a pointer back to /widgets.


/translations

Query:

NameNotes
localeOne language code, e.g. vi. Omit for all

Returns: activeLanguages, totalStrings, and two lists — widgets and campaigns. Each entry carries two layers:

  • text — wording typed on the Design screen. What shoppers see, and the fallback for any language without its own translation.
  • translations — per-language overrides.

Only customised strings appear. A string that is absent is using the app's built-in wording, which is not the same as being empty. A store on a plain install returns found: false with an explanation — the defaults are in use, nothing is broken.

Pass locale on multi-language stores; the unfiltered response is the one most likely to hit the size limit.


/settings

Query: none

Returns: found and the store-wide configuration — the switches that apply across every campaign. Per-campaign rules live in /campaigns/{id}.


/plan

Query: none

Returns: the plan and trial state, campaign allowance against campaigns live, revenue used against the cap and when it resets, and issues for limits already exceeded.

{
  "success": true,
  "data": {
    "domain": "example.myshopify.com",
    "plan": {"plan": "basic_26", "shopifyPlan": "basic", "isPaid": true, "trialEndsAt": null},
    "campaigns": {"activeOrScheduled": 1, "limit": null, "unlimited": true, "exceeded": false},
    "revenue": {"used": 0, "limit": null, "unlimited": true, "resetAt": "2026-09-25T05:30:14.769Z"},
    "issues": []
  },
  "error": null
}

limit: null with unlimited: true means no cap on this plan — not a missing value.

This endpoint reports your own store's situation. It does not list plans or quote prices.


Related

Product
Install AppWebsiteAvada Apps
Resources
DocumentationFAQPrivacy Policy
Company
Avada GroupContact
© 2026 Avada Group. All rights reserved.