Public REST API reference
The Scale plan unlocks a public REST API at https://vltrx-rewards-api.valotrix.com/api/v1/. This reference walks every endpoint, every parameter, and every response shape - what to send, what you'll get back, what every error code means.
Use cases
- Build a custom analytics dashboard on top of Valotrix Cart Rewards data.
- Programmatically import campaign recipes (e.g. seed a fresh dev store with a tested set).
- Pipe gift events from a headless storefront into Valotrix Cart Rewards's analytics.
- Pull per-customer redemption counts to enforce limits in a custom checkout flow.
- Pull aggregated daily stats into your data warehouse.
- Trigger campaign enable/disable from external schedulers.
API access requires the Scale plan. Free, Growth, and Pro plans see the API & Webhooks section in Settings but can't generate working keys - attempts return 403.
Authentication
Every request needs an Authorization header with a Bearer token:
Authorization: Bearer vltrx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Generate keys in the admin under Settings → API & Webhooks → API Keys. Each key is shown once at creation - copy it immediately. We can't recover a lost key (keys are SHA-256 hashed at rest); revoke and create a new one.
A missing or invalid token returns 401:
{ "error": "Unauthorized" }
A valid token whose plan doesn't include API access returns 403:
{ "error": "API access requires the Scale plan" }
Response headers
Every response includes:
X-Request-Id: <uuid>- unique per request. Include this when emailing support.Content-Type: application/json(except204 No Content).Cache-Control: private, max-age=Non a few read endpoints (/rules,/redemptions).
Rate limits
Rate-limit budgets per API key (sliding 60-second window):
| Endpoint group | Limit | Notes |
|---|---|---|
| Most read/write endpoints | 120 / min | /campaigns, /campaigns/:id, /rules, /widgets, /redemptions, enable/disable/schedule |
/analytics | 30 / min | Tighter - analytics queries can scan up to 90 days of stats |
/events (POST ingest) | 60 / min | |
/campaigns/import | 10 / min | Strictest - each request can create up to 50 campaigns |
Exceeding a limit returns 429 with a Retry-After header (seconds):
HTTP/1.1 429 Too Many Requests
Retry-After: 47
{ "error": "Rate limit exceeded" }
Pagination
List endpoints (/campaigns) use page-based pagination:
| Query param | Default | Max | Notes |
|---|---|---|---|
page | 1 | - | 1-indexed |
perPage | 50 | 100 | Clamped if you exceed |
The response includes a meta block:
{
"campaigns": [ /* … */ ],
"meta": { "total": 213, "page": 2, "perPage": 50 }
}
To page through everything, increment page until your accumulated count reaches meta.total (or you get fewer rows than perPage).
Idempotency
Mutation endpoints (POST, PUT, DELETE) don't currently use an Idempotency-Key header - replays may be applied twice. Use the X-Request-Id returned in the response to track and reconcile from your side. Idempotency-Key support is on the roadmap.
Errors
All errors return a JSON object with at minimum an error field:
{ "error": "Campaign not found" }
Some endpoints add structured fields like issue and path for validation errors:
{
"error": "Invalid widget settings body",
"issue": "Required",
"path": ["progressBar", "thresholds", 0, "amountCents"]
}
Status-code-to-meaning map:
| Status | Meaning |
|---|---|
400 | Invalid request body, missing query param, malformed payload |
401 | Missing or invalid Authorization header |
403 | Authenticated but plan doesn't grant access (Scale required) |
404 | Resource not found for this shop |
405 | HTTP method not allowed (response includes Allow: GET, PUT, DELETE) |
409 | Plan-cap exceeded on import / create (e.g. trying to add 51st campaign on a plan that allows 50) |
413 | Request body too large |
429 | Rate limit exceeded - see Retry-After |
500 | Internal server error - email support with X-Request-Id |
Endpoints
Campaigns: list
GET /api/v1/campaigns
Query params:
| Name | Type | Notes |
|---|---|---|
page | int | 1-indexed; default 1 |
perPage | int | default 50, max 100 |
enabled | "true" | "false" | filter by enabled state |
curl -H "Authorization: Bearer $VLTRX_KEY" \
"https://vltrx-rewards-api.valotrix.com/api/v1/campaigns?enabled=true&perPage=20"
const res = await fetch(
"https://vltrx-rewards-api.valotrix.com/api/v1/campaigns?enabled=true",
{ headers: { Authorization: `Bearer ${process.env.VLTRX_KEY}` } }
);
const { campaigns, meta } = await res.json();
Response 200:
{
"campaigns": [
{
"id": "clxabc123",
"name": "Spend $50, get a free tote bag",
"enabled": true,
"isDraft": false,
"rulesCount": 1,
"startAt": null,
"endAt": null,
"createdAt": "2026-04-12T10:30:00.000Z",
"updatedAt": "2026-05-08T14:22:11.000Z"
}
],
"meta": { "total": 1, "page": 1, "perPage": 50 }
}
Campaigns: bulk import
POST /api/v1/campaigns/import
Bulk-creates campaigns. Does not update existing campaigns - every entry creates a new campaign. Up to 50 campaigns per request.
Plan-cap behavior: the import is rejected as a whole batch (403) if it would push you above your plan's campaign or rule cap. Plan-gated features (e.g. customer-tag conditions on a Free shop) are silently stripped per enforcePlanFeatures.
Request body:
{
"campaigns": [
{
"name": "Imported recipe",
"enabled": true,
"isDraft": false,
"startAt": null,
"endAt": null,
"rules": [
{
"name": "Tier 1",
"enabled": true,
"conditionTree": {
"type": "cart.subtotal_gte",
"value": 5000,
"currencyOverrides": { "EUR": 4500 }
},
"giftConfig": {
"type": "fixed",
"gifts": [
{ "variantId": "gid://shopify/ProductVariant/12345", "quantity": 1 }
],
"fallbackOnOOS": false
},
"options": {
"actionType": "auto_add",
"discountMethod": "automatic",
"discountType": "percentage",
"discountValue": 100,
"discountTitle": "FREE GIFT"
}
}
]
}
]
}
Response 200:
{
"imported": 1,
"campaignIds": ["clxnewid001"]
}
Errors:
400- missing or emptycampaignsarray, or > 50 campaigns403- plan-cap exceeded (rejects the whole batch - fix the input or upgrade)400withissuefield - schema validation failure on a specific entry
Campaign: get one
GET /api/v1/campaigns/:id
Returns the campaign with all its rules inline (rules' conditionTree, giftConfig, and options are returned as parsed JSON objects, not strings).
curl -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/campaigns/clxabc123
Response 200:
{
"campaign": {
"id": "clxabc123",
"name": "Spend $50, get a free tote bag",
"enabled": true,
"isDraft": false,
"startAt": null,
"endAt": null,
"createdAt": "2026-04-12T10:30:00.000Z",
"updatedAt": "2026-05-08T14:22:11.000Z",
"rules": [
{
"id": "ruleabc",
"name": "Tier 1",
"enabled": true,
"conditionTree": { "type": "cart.subtotal_gte", "value": 5000 },
"giftConfig": { "type": "fixed", "gifts": [/* … */], "fallbackOnOOS": false },
"options": {
"actionType": "auto_add",
"discountMethod": "automatic",
"discountType": "percentage",
"discountValue": 100,
"discountTitle": "FREE GIFT"
},
"createdAt": "2026-04-12T10:30:00.000Z",
"updatedAt": "2026-04-12T10:30:00.000Z"
}
],
"experiment": null
}
}
If an A/B experiment is active on this campaign, the experiment field is populated with { id, status, primaryMetric, startedAt, stoppedAt, concludedAt, winnerVariantId, variants: [...] }.
Errors:
404- campaign not found (or belongs to a different shop)
Campaign: full replace
PUT /api/v1/campaigns/:id
Replaces the campaign in full (REST semantics - all fields are required). Body shape mirrors the GET response (single campaign with inline rules).
Plan-gated features get stripped via enforcePlanFeatures if your plan doesn't include them. Plan-cap counts exclude the campaign you're updating, so PUT-ing changes to an existing campaign won't trip the cap.
Webhooks fire only on enabled-state transitions (campaign.activated / campaign.deactivated).
Request body: same shape as GET /api/v1/campaigns/:id returns under campaign.
Response 200: same shape as the GET (returns the updated campaign).
Errors:
400- body failscampaignSchemavalidation (response hasissue+path)403- plan-cap exceeded (e.g. the new rule count would exceed cap)404- campaign not found405- wrong method (response includesAllow: GET, PUT, DELETE)
Campaign: delete
DELETE /api/v1/campaigns/:id
Deletes the campaign and all its events. If the campaign was live (enabled && !isDraft), a campaign.deactivated webhook fires with trigger: "api" (there's no separate campaign.deleted event - deletion is a stronger form of deactivation).
curl -X DELETE -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/campaigns/clxabc123
Response 200:
{ "ok": true }
Errors:
404- campaign not found
Campaign: enable
POST /api/v1/campaigns/:id/enable
Enables a campaign. Flips isDraft → false, enforces the plan cap on active campaigns, runs a health-check gate on gift inventory + trigger products, invalidates the rules-payload cache, syncs the automatic discount, and dispatches a campaign.activated webhook.
Query params:
| Name | Notes |
|---|---|
force=1 | Bypass the health-check gate (use sparingly - broken-gift campaigns will fire on the storefront anyway) |
curl -X POST -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/campaigns/clxabc123/enable
Response 200:
{
"ok": true,
"campaign": {
"id": "clxabc123",
"enabled": true,
"isDraft": false
}
}
If the health check fails and force isn't set:
HTTP/1.1 422 Unprocessable Entity
{
"error": "Campaign has health issues",
"warnings": [
{ "type": "gift_oos", "ruleId": "ruleabc", "variantId": "gid://shopify/ProductVariant/12345" }
]
}
Pass ?force=1 to skip the gate.
Errors:
403- campaign-cap exceeded (e.g. activating this would put you above the plan's max active campaigns)404- campaign not found
Campaign: disable
POST /api/v1/campaigns/:id/disable
Disables a campaign. Invalidates the rules-payload cache, syncs the automatic discount, and dispatches a campaign.deactivated webhook with trigger: "api".
curl -X POST -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/campaigns/clxabc123/disable
Response 200:
{
"ok": true,
"campaign": {
"id": "clxabc123",
"enabled": false
}
}
Campaign: schedule
POST /api/v1/campaigns/:id/schedule
Sets startAt and/or endAt. Both are optional; passing null clears the existing value. If both are provided, endAt must be strictly after startAt.
Request body:
{
"startAt": "2026-11-29T00:00:00.000Z",
"endAt": "2026-12-02T23:59:59.000Z"
}
Or to clear the schedule:
{ "startAt": null, "endAt": null }
Timestamps must be ISO 8601 strings. (For YYYY-MM-DD-only ranges, append T00:00:00.000Z.)
Response 200:
{
"ok": true,
"campaign": {
"id": "clxabc123",
"startAt": "2026-11-29T00:00:00.000Z",
"endAt": "2026-12-02T23:59:59.000Z"
}
}
Errors:
400- body missing bothstartAtandendAt, invalid ISO 8601, orendAt <= startAt
Rules (active payload)
GET /api/v1/rules
Returns the active rules payload - the same shape served to the storefront engine, but accessed via API key instead of the storefront proxy. Useful for replicating rule logic in a headless storefront or for debugging.
Supports HTTP caching via ETag and If-None-Match:
# First request
curl -i -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/rules
# Response includes: ETag: "abc123", Cache-Control: private, max-age=60
# Subsequent request - use the etag
curl -i -H "Authorization: Bearer $VLTRX_KEY" \
-H 'If-None-Match: "abc123"' \
https://vltrx-rewards-api.valotrix.com/api/v1/rules
# Response: 304 Not Modified, no body
Response body shape (200):
{
"rules": [ /* active rules with parsed conditionTree, giftConfig, options */ ],
"experiments": [ /* active experiment configs, projected to engine shape */ ],
"debugAllowed": true,
"shopSettings": {
"engine": { "runOnExpressCheckout": true, "runOnCartSubmission": false, "useDiscountedPrices": false, "evaluationDelay": 300 },
"checkout": { "blockZeroValueCart": true, "blockGiftOnlyCarts": true, "stripZeroPriceItems": true },
"data": { "collectCartTokens": true }
},
"widgets": { /* widget settings (toast, popup, progressBar, etc.) */ }
}
experiments is omitted (not just empty) when the shop has no active experiments.
Widgets: get
GET /api/v1/widgets
Returns the shop's widget configuration.
curl -H "Authorization: Bearer $VLTRX_KEY" \
https://vltrx-rewards-api.valotrix.com/api/v1/widgets
Response 200:
{
"widgets": {
"_version": 14,
"toast": { "enabled": true, /* ... */ },
"promoPopup": { /* ... */ },
"progressBar": { /* ... */ },
"giftChoice": { /* ... */ },
"reminder": { /* ... */ }
}
}
Widgets: full replace
PUT /api/v1/widgets
Full replace of widget settings. Partial updates are not supported - GET, modify, PUT. The server stamps the canonical _version on every save (any client-supplied _version is ignored to prevent migration-skip drift).
Request body: same shape as the GET response under widgets.
Response 200:
{ "ok": true, "widgets": { /* the persisted version */ } }
Custom-code fields (HTML/CSS/JS in customCode) are persisted as-is. The storefront proxy strips them on the way out for non-Scale shops, so plan gating is enforced at delivery, not write.
Errors:
400- body failswidgetSettingsSchemavalidation (response hasissue+path)405- wrong method (response includesAllow: GET, PUT)
Redemptions
GET /api/v1/redemptions?customerId=<numeric-id>
Returns per-customer redemption counts. Used to enforce per-customer limits in custom checkout flows. Cached per request for 30 seconds (Cache-Control: private, max-age=30).
Required query params:
| Name | Type | Notes |
|---|---|---|
customerId | numeric string | Shopify customer ID - must be all digits |
curl -H "Authorization: Bearer $VLTRX_KEY" \
"https://vltrx-rewards-api.valotrix.com/api/v1/redemptions?customerId=987654321"
Response 200:
{
"redemptions": [
{ "ruleId": "ruleabc", "campaignId": "clxabc123", "count": 1, "lastRedeemedAt": "2026-05-08T14:22:11.000Z" }
]
}
Errors:
400- missingcustomerIdor non-numeric value
Events ingest (headless storefronts)
POST /api/v1/events
For headless storefronts that don't run the standard Valotrix Cart Rewards storefront script. Ingests gift-added / gift-removed / choice-shown / choice-selected events directly from your custom storefront, mirroring what the standard storefront proxy ingests.
Standard Shopify storefronts don't need this - they go through the storefront proxy automatically. Use this only when you've built your own storefront on top of Shopify's Storefront API.
Request body: validated against proxyEventSchema - the same payload the proxy event endpoint accepts. Has events: [...] plus storefront context.
Response 200:
{ "ok": true, "ingested": 3 }
Errors:
400- body fails schema validation429- rate-limited (60/min)
Analytics
GET /api/v1/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD
Returns daily totals + per-campaign stats over a date range.
Required query params:
| Name | Format | Notes |
|---|---|---|
from | YYYY-MM-DD | inclusive |
to | YYYY-MM-DD | inclusive |
Optional:
| Name | Notes |
|---|---|
campaignId | filter campaignStats to one campaign |
Maximum range: 90 days. Larger ranges return 400.
curl -H "Authorization: Bearer $VLTRX_KEY" \
"https://vltrx-rewards-api.valotrix.com/api/v1/analytics?from=2026-04-01&to=2026-04-30"
Response 200:
{
"dailyTotals": [
{ "date": "2026-04-01", "totalOrders": 142, "totalRevenue": 8420.50, "ordersWithGifts": 38, "revenueWithGifts": 2890.00 }
],
"campaignStats": [
{ "date": "2026-04-01", "campaignId": "clxabc123", "ordersWithGifts": 38, "giftsGiven": 38, "revenueWithGifts": 2890.00 }
],
"summary": {
"totalOrders": 1180,
"totalGifts": 1180,
"totalRevenue": 86420.30
}
}
Notes:
- Currency is your shop's base currency.
- Revenue values are dollars-with-decimals (not cents).
- An order's contribution lands on the day the order completed, in the shop's timezone.
Errors:
400- missingfrom/to, invalid date format, range > 90 days, orfrom > to
What's not in v1
Documented for transparency:
- GraphQL - REST only in v1.
- Scoped keys - every key currently has full access. Per-resource scopes are on the roadmap.
Idempotency-Key- replays may apply twice on POST/PUT/DELETE.- Cursor pagination - page-based only. Stable enough for most workflows.
- Webhook subscription management via API - webhook endpoints are configured in Settings only.
Next: Outbound webhooks →