Endpoints
Ten endpoints, all GET, all 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.
| Endpoint | Query | Answers |
|---|---|---|
/setup-health | — | Can the gift widget appear on this store? |
/campaigns | status | What offers are running? |
/campaigns/{id} | — | How is one campaign configured? |
/analytics | days includeToday ianaTimezone | Revenue, orders, conversion, AOV |
/products | handles | Can these products be gifted? |
/widgets | — | Which widget types exist here? |
/widgets/{type} | — | One widget's settings |
/translations | locale | Customised storefront wording |
/settings | — | Global app configuration |
/plan | — | Plan and usage |
GET /store-api/v1 itself returns this 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:
| Name | Values | Notes |
|---|---|---|
status | active · scheduled · expired | Omit 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:
| Name | Type | Notes |
|---|---|---|
days | 1–365 | Window length, ending yesterday. Default 30 |
includeToday | boolean | Extend to the store's current day |
ianaTimezone | string | Override 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:
| Name | Required | Notes |
|---|---|---|
handles | Yes | One 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:
| Name | Notes |
|---|---|
locale | One 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
- Errors
- MCP tool guide — what the fields mean, in depth