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.
| 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 |
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.
| Endpoint | Query | Finds |
|---|---|---|
/gift-products | query | Products that could be given as a gift |
/trigger-products | query | Products that could trigger an offer |
/collections | query | Shopify collections |
/customer-segments | query | Shopify customer segments |
/customer-locations | query | Markets and countries |
/shipping-rates | query | Shipping rates, by zone |
/markets | — | Every Shopify Market |
/pos-locations | query | Locations 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.
| Method | Endpoint | Access | Does |
|---|---|---|---|
POST | /campaigns/preview | Write | Validate and preview a campaign before creating it |
POST | /campaigns | Write | Create the exact campaign returned by the preview |
POST | /campaigns/{id}/edit-preview | Write | Preview a guarded campaign edit before applying it |
PATCH | /campaigns/{id} | Write | Edit one supported campaign |
PUT | /campaigns/{id}/status | Write | Turn one campaign on or off |
PATCH | /widgets/{type} | Write | Edit allowlisted settings for one widget type |
POST | /campaigns/{id}/deletion-preview | Delete | Preview permanent deletion of one campaign |
POST | /campaigns/{id}/delete | Delete | Permanently 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.
| Step | Create | Edit | Delete |
|---|---|---|---|
| Preview | POST /campaigns/preview | POST /campaigns/{id}/edit-preview | POST /campaigns/{id}/deletion-preview |
| Commit | POST /campaigns | PATCH /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 requests | 60 per minute, per key |
Non-GET requests | 20 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:
| 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