Zestly API v1
The REST API of a shop's Zestly loyalty program: read members, balances and activity, adjust points, complete custom actions, redeem and cancel rewards, read referrals, lock VIP tiers, grant memberships, read the analytics and the change log. Every endpoint goes through the same code as the Zestly admin: the same rules, limits and history.
- OpenAPI 3.1:
GET /api/v1/openapi.json(public), also in this folder:openapi.json. - In one file, for a language model:
llms.txt. - Italian introduction for merchants:
guida-introduttiva.md. - Webhooks: below, and the signature's check in Node.js:
examples/verify-webhook.mjs.openapi.jsondescribes them too (OpenAPI 3.1webhooks: every event, its headers and the body's schema).
Keys
The merchant creates keys in Zestly › Sviluppatori › Chiavi API › Crea una chiave: a name, the scopes (permissions) and, optionally, an expiry. The key (zst_live_ + 43 characters) is shown once: Zestly keeps only its SHA-256 hash. A shop has at most 20 keys in force (a rotation's 24-hour overlap may add one more).
- Revoke stops a key at once.
- Rotate makes a new key with the same name and scopes; the old one keeps working for 24 hours (so you can deploy the new one). Rotate and revoke now («Ruota e revoca subito») stops the old one at once and starts a new line of Idempotency-Keys (see Idempotency): the choice for a leaked key.
- Pause the API («Metti in pausa l'API») refuses every call of the shop (403
api_paused) until it is resumed. - A key belongs to the shop, not to the staff member who created it: it keeps working after they leave, until it is revoked («Creata da» shows who made it).
- Uninstalling Zestly revokes every key; after a reinstall, create new ones.
Never put a key in a browser, a theme or a mobile app. Call the API from your server. The zst_live_ prefix lets secret scanners spot a leaked key: replace it with «Ruota e revoca subito» (or revoke it and create a new one). Either way the new key does not share the leaked one's Idempotency-Keys, so whatever its holder sent cannot block or replay your integration's calls.
Authentication and scopes
Authorization: Bearer zst_live_…
| Scope | Allows |
|---|---|
members:read | GET /members, GET /members/{customerId}, …/view, …/activity, …/redemptions |
members:write | POST /members/{customerId} (enrol), …/exclude, …/reinstate |
points:write | POST /members/{customerId}/adjustments, POST /members/{customerId}/actions/{ruleId} |
redemptions:write | POST /members/{customerId}/redemptions, POST /redemptions/{redemptionId}/cancel |
program:read | GET /program, /rules, /rewards, /tiers, /membership-plans |
referrals:read | GET /referrals, GET /members/{customerId}/referrals |
vip:write | PUT /members/{customerId}/tier |
memberships:write | POST /members/{customerId}/memberships, DELETE /members/{customerId}/memberships/{planId} |
analytics:read | GET /analytics/summary |
changes:read | GET /changes (the change log: it names the customers each change is about) |
changes:undo | POST /changes/{changeId}/undo, for a member's changes (the program's are undone in Zestly) |
program:write | POST /proposals, GET /proposals/{proposalId}: propose changes to the program, which the merchant applies in Zestly |
A missing scope is 403 missing_scope. Give each integration the fewest scopes it needs (the creation form proposes the read-only ones).
Conventions
- Base URL:
https://<the Zestly app's host>/api/v1(shown on the «Sviluppatori» page). - JSON in UTF-8. A body is sent with
Content-Type: application/jsonand is at most 16 KB. - Customers are Shopify customer ids: numeric (
7700112233) or a GID (gid://shopify/Customer/7700112233, URL-encoded in the path). Never an email: look the customer up on Shopify first. - Points are integers. Money is in minor units (
amountMinor,valueMinor) with itscurrency. - Instants are ISO 8601 in UTC (
2026-10-16T09:00:00.000Z); a day of the shop's calendar isYYYY-MM-DD. - Texts (explanations, summaries, the change log) follow
?locale=it|en, else the shop's language. - No personal data: answers hold ids and numbers, never a name, an email or a phone number.
- Lists are paged:
?limit=(see each endpoint) and?cursor=with the previous page'snext;next: nullon the last page. The order is stable. A cursor that cannot be read is400 invalid_request, never the first page again. - Paths are canonical: no double or trailing slash, no percent-encoded letter or digit, no backslash,
/api/v1in lower case. A GID's/is encoded (%2F); its:may be encoded or not. Anything else is404 not_found. - Every answer carries
X-Request-Id: the call's row in «Sviluppatori › Registro chiamate» (kept 7 days).
Idempotency
Every POST, PUT and DELETE requires an Idempotency-Key header: 1–64 characters among A–Z a–z 0–9 _ -, one per operation: a UUID, or <integration>-<operation>-<id> (crm-review-48213, pos-adjust-1001) — never a bare order id, which an adjustment and a custom action for the same order would share.
- Keys belong to the API key that sends them (and to the keys it is rotated into with «Ruota»): two integrations of the shop never meet each other's, even with the same order ids. «Ruota e revoca subito» and a new key start afresh: a call that timed out with the old key is not recognised by the new one, so check «Registro chiamate» before sending it again.
- The same key with the same request (path, parameters, body — key order does not matter) answers the stored reply, with
Idempotent-Replayed: true. Retry a timed-out call with the same key: it never moves points twice. - While the first request with a key is still running, the same call is
409 idempotency_in_progresswithRetry-After: it never runs twice. Retry after those seconds to get the first one's answer. - The same key with another request is
409 idempotency_conflict. - Replies are kept 7 days. After that, the same key still never moves points, rewards or memberships twice (the key becomes the program's own ledger key), but the reply is computed again (e.g.
applied: false). - A gift card's code is in the first reply only; a replay says
giftCardCode: null.
Errors
{ "error": { "code": "insufficient_balance", "message": "The request was refused: insufficient_balance.", "requestId": "…" } }
Read code, never message.
| Status | Code |
|---|---|
| 400 | invalid_request: a malformed query, body or header (an unknown field too), an unreadable cursor |
| 401 | unauthorized: missing, unknown, expired or revoked key |
| 403 | missing_scope, api_paused, app_uninstalled, undo_not_allowed (a change to the program) |
| 404 | not_found: not a member of this shop, an unknown reward, rule, plan, redemption or change, a path that is not canonical |
| 405 | method_not_allowed |
| 409 | idempotency_conflict, idempotency_in_progress (with Retry-After) |
| 413 | payload_too_large |
| 422 | the program's own refusal, e.g. insufficient_balance, invalid_points, member_inactive, unavailable:ended, not_cancellable, already_used, vip_off, nothing_live, already_undone, would_go_negative |
| 429 | rate_limited, with Retry-After (seconds) |
| 500 | server_error: retry with the same Idempotency-Key |
Rate limits
Per shop and per minute; each key gets half of the shop's so one integration never takes all of it.
| Kind | Per key | Per shop |
|---|---|---|
Reads (GET) | 300 | 600 |
| Writes | 150 | 300 |
| Writes that call Shopify (enrolments, redemptions, cancellations, memberships) | 30 | 60 |
Reads that may call Shopify (GET /rules, GET /rewards: product and collection names, cached 5 minutes) | 30 | 60 |
Over a limit: 429 rate_limited with Retry-After. Refused calls are not each in the call log: a key's first refusal of the minute is.
Endpoints
| Method and path | What it does |
|---|---|
GET /members | members by join date, filtered by status, tier, minBalance, updatedSince |
GET /members/{customerId} | balance, pending points and the next release, lifetime points, VIP tier, referral code, memberships |
GET /members/{customerId}/view | the widget's view of the customer, for a headless storefront |
GET /members/{customerId}/activity | the points movements and cashback events, explained |
GET /members/{customerId}/redemptions | the member's rewards |
GET /members/{customerId}/referrals | the member's referral code and referrals |
POST /members/{customerId} | enrol an existing Shopify customer (201) |
POST /members/{customerId}/exclude · /reinstate | exclude from the program, or re-enrol |
POST /members/{customerId}/adjustments | add or remove points: { points, reason, note?, countsForTier?, notify? } |
POST /members/{customerId}/actions/{ruleId} | complete a custom action rule |
POST /members/{customerId}/redemptions | redeem a reward: { rewardId, points? } |
POST /redemptions/{redemptionId}/cancel | cancel an unused reward, points back |
PUT /members/{customerId}/tier | lock the VIP tier ({ mode: "lock", tierId, until?, gift? }) or unlock it ({ mode: "auto" }) |
POST /members/{customerId}/memberships | grant or extend a membership: { planId, months | until, note } |
DELETE /members/{customerId}/memberships/{planId} | revoke a membership |
GET /referrals | the shop's referrals |
GET /program · /rules · /rewards · /tiers · /membership-plans | the program, with the admin's sentences |
GET /analytics/summary | the period's totals of «Analisi» (from, to) |
GET /changes | the change log, with the author of each change |
POST /changes/{changeId}/undo | undo a member's change, as «Annulla» |
POST /proposals · GET /proposals/{proposalId} | propose a change to the program (checked now, applied by the merchant in Zestly) |
The schemas of every request and answer are in openapi.json.
Changes made with a key appear in Zestly's «Registro modifiche» as API · {the key's name}, and can be undone there like the merchant's own.
Examples
curl https://<host>/api/v1/members/7700112233 \
-H "Authorization: Bearer $ZESTLY_KEY"
curl -X POST https://<host>/api/v1/members/7700112233/adjustments \
-H "Authorization: Bearer $ZESTLY_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: crm-review-48213" \
-d '{ "points": 50, "reason": "courtesy", "note": "Review on our blog" }'
Headless storefronts (Hydrogen and custom storefronts)
Call Zestly from your storefront's server (a Hydrogen loader or action), never from the browser: the key stays in the server's environment, the customer's id comes from your Customer Account API session.
// app/routes/account.loyalty.tsx (Hydrogen)
export async function loader({ context }: LoaderFunctionArgs) {
const { data } = await context.customerAccount.query(`{ customer { id } }`);
const id = encodeURIComponent(data.customer.id); // gid://shopify/Customer/…
const res = await fetch(`${context.env.ZESTLY_API_URL}/members/${id}/view?locale=it`, {
headers: { Authorization: `Bearer ${context.env.ZESTLY_API_KEY}` },
});
if (!res.ok) throw new Response("Loyalty unavailable", { status: 502 });
return { loyalty: await res.json() };
}
/view is the widget's own view: balance, rewards the customer can redeem now, ways to earn with their progress, the VIP ladder, the referral link, memberships and the latest activity, in the asked language. To redeem, post /members/{customerId}/redemptions from an action with a key that has redemptions:write and an Idempotency-Key made once per click (e.g. a UUID in the form). Use a key with only members:read and redemptions:write.
Webhooks
Zestly can notify your system of every program event: a signed POST to an https address of yours, a few seconds after the event. The merchant manages the endpoints in Zestly › Sviluppatori › Webhook (there is no API for them in v1).
An endpoint is an https URL (port 443 or above 1024) on the public internet — Zestly refuses, when it is saved and at every delivery, an address that is or resolves to a private, loopback, link-local or reserved network — and the events it takes, or «Tutti gli eventi» (every event, those added later included). A shop has at most 10 endpoints. When the endpoint is added Zestly shows its signing secret (whsec_…) once; «Ruota il segreto» makes a new one and keeps signing with both for 24 hours. «Invia un evento di prova» sends a ping and shows the answer (one attempt, never retried). Adding endpoints and sending test events are limited to 10 a minute per shop each.
The body is the same for every event:
{ "id": "whd_5f0c…", "type": "points.earned", "apiVersion": "v1", "occurredAt": "2026-10-16T09:12:03.000Z",
"shop": "negozio.myshopify.com",
"data": { "customerId": "gid://shopify/Customer/123", "points": 120, "pending": false, "availableAt": null,
"source": { "type": "order", "id": "gid://shopify/Order/456" } },
"member": { "balance": 1450, "pending": 0, "tier": "oro", "snapshotAt": "2026-10-16T09:12:05.000Z" } }
data holds the event's ids and numbers (points are the change to the balance, signed); member is the member as it was when the event was distributed (snapshotAt, possibly later than occurredAt): read /members/{customerId}/activity for the balance at an exact moment. Tiers and membership plans are their handles. Never a name, an email or a phone; a reward's discount code is included (code), so your system can send it to the customer — a gift card's never (its last 4 characters, giftCardLast4).
Events (type):
| Group | Events | data beyond customerId |
|---|---|---|
| Members | member.enrolled, customer.redacted | member.enrolled: origin "import" when an import from another app made the member (M11); customer.redacted: forget this customer, member is null |
| Points | points.earned, points.released, points.adjusted, points.reversed, points.expired, points.redeemed, points.expiring | points, source (type, id); points.earned: pending, availableAt; points.expired: points only; points.redeemed: points, redemptionId; points.expiring: expiresAt, days only |
| Rewards | reward.issued, reward.cancelled, reward.used, cashback.issued | redemptionId, rewardId, type, source, points, value (amountMinor, currency), code, giftCardLast4, expiresAt; reward.used: orderId; cashback.issued: amount |
| VIP | tier.changed, tier.near | from, to, direction (up, down, grace, or same: a change between two tiers of equal rank), cause; tier.near: tier |
| Referrals | referral.qualified, referral.rewarded, referral.reversed | referralId, points |
| Memberships | membership.started, membership.renewed, membership.grace, membership.lapsed, membership.revoked | membershipId, plan, paidThrough |
| Test | ping | — (no customerId) |
A bulk action (an adjustment of many members, a VIP recompute) sends one event per member. The reminders written ahead of their time — points.expiring, tier.near, a renewing membership's membership.grace and membership.lapsed — are checked again when due and dropped if no longer true (points no longer expiring then, the tier reached, the membership renewed in its renewal window), as the emails are.
Signature (Standard Webhooks): every request carries
webhook-id: whd_5f0c… the body's id, the same on every attempt
webhook-timestamp: 1792141923 seconds
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
where the signature is base64(HMAC-SHA256(key, id + "." + timestamp + "." + body)) and the key is the base64 after whsec_. During a rotation the header holds two signatures separated by a space. Verify every request: compare in constant time, refuse a timestamp more than 5 minutes off (a replay), use the raw body. Standard Webhooks' libraries do it in every language; without a dependency:
- Node.js:
examples/verify-webhook.mjs(verifyZestlyWebhook(secret, headers, rawBody)). - PHP:
function verify_zestly_webhook(string $secret, array $headers, string $body): bool {
$id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; $sigs = $headers['webhook-signature'] ?? '';
if ($id === '' || !ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
foreach (explode(' ', $sigs) as $part) {
[$version, $signature] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) return true;
}
return false;
}
Answer with any 2xx within 10 seconds (do the work afterwards). Delivery is at least once and the order is not guaranteed: keep the webhook-ids you processed and ignore a repeat. Redirects are not followed.
Retries: a delivery that fails (no answer in 10 seconds, a DNS that does not answer, a 3xx, 4xx or 5xx) is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours — 8 attempts in about 45 hours (almost 2 days) — then given up. After a failure the endpoint rests (none of its deliveries is tried) for as long as it has been failing, from 1 minute up to 1 hour: a receiver that is down never slows the other endpoints, and a success, a test event that answers or «Riprova» ends the rest. A 410 Gone turns the endpoint off at once, and its waiting deliveries are dropped: the receiver said stop. An endpoint whose every delivery failed for 3 days is turned off too: the merchant gets an email and a notice in Zestly, the events for it wait, and «Riattiva» sends them (with the failed deliveries of the last 30 days), after what the other endpoints already have due. Consegne in Sviluppatori › Webhook shows 30 days of deliveries, with «Riprova» on a failed one and «Riprova tutte le fallite» on an endpoint.
Shopify Flow
Zestly's 8 Flow triggers and 5 Flow actions (give and take points, complete a custom action, reward a review, give a reward) need no key: see flow.md.
Privacy
The API never returns names, emails or phone numbers. When a customer is erased (Shopify's customers/redact or «Cancella membro»), Zestly deletes the call log's rows about them; an erased customer is 404. The merchant is the controller of what an integration does with the data it reads. Webhooks follow the same rule; an erased customer's deliveries are deleted, then customer.redacted tells your endpoints to forget them. Zestly keeps that customer's Shopify id only while the customer.redacted delivery may still be attempted — until it is delivered or given up, 30 days at most — and its delivery, like every delivery, 30 days.
Versions
/api/v1 only changes by addition (a new field, a new endpoint). A breaking change would be /api/v2, with both served for 12 months.