# Zestly API > The REST API (v1) of a Shopify shop's Zestly loyalty program. Bearer keys with scopes, created by the merchant in Zestly › Sviluppatori; JSON; customers by their Shopify id; an Idempotency-Key on every write. - Base URL: https://app.zestly.wavo.it - OpenAPI 3.1: https://app.zestly.wavo.it/api/v1/openapi.json (a copy: https://zestly.wavo.it/api-docs/openapi.json) - Guide: https://zestly.wavo.it/en/docs/api/reference/ ## Rules - `Authorization: Bearer zst_live_…` on every call; a key never goes in a browser. - Every POST, PUT and DELETE needs `Idempotency-Key` (1–64 of A–Z a–z 0–9 _ -), one per operation (a UUID, or `--`): retry with the same key, never a new one. Keys belong to the API key that sends them. 409 `idempotency_in_progress` (with `Retry-After`): the first request still runs, retry the same call. - Errors: `{ "error": { "code", "message", "requestId" } }`; read `code`. 429 has `Retry-After`. - Rate limits per shop per minute: 600 reads, 300 writes, 60 writes that call Shopify (redemptions, cancellations, enrolments, memberships), 60 reads that may call Shopify (`GET /rules`, `GET /rewards`); each key gets half of each. 429 `rate_limited` with `Retry-After`. - Lists: `?limit=` and `?cursor=` (the previous page's `next`); an unreadable cursor is 400, never the first page again. - Texts follow `?locale=it|en`, else the shop's language. No names, emails or phone numbers: ids and numbers only. ## Endpoints - `GET /api/v1/members/{customerId}` (members:read): Get a member. The member's balance, pending points with the next release date, lifetime points, VIP tier, live referral code and memberships. - `POST /api/v1/members/{customerId}/adjustments` (points:write): Adjust a member's balance. Adds or removes points, as «Rettifica saldo» in the admin: the reason is shown to the customer, the note only to the staff. Never takes the balance below zero (422 insufficient_balance). `applied: false` when this Idempotency-Key was already applied. - `POST /api/v1/members/{customerId}` (members:write): Enroll a customer. Enrolls an existing Shopify customer (checked on Shopify: 404 when there is none) — 201 when enrolled now, 200 when already a member. No signup points: those come with a new customer's account. - `POST /api/v1/members/{customerId}/exclude` (members:write): Exclude a member. As «Escludi dal programma»: earns nothing and cannot redeem; the balance is kept. `changed: false` when it already was. - `POST /api/v1/members/{customerId}/reinstate` (members:write): Re-enroll an excluded member. As «Riammetti»: earns and redeems again; the VIP tier is decided again. `changed: false` when it was not excluded. - `POST /api/v1/members/{customerId}/actions/{ruleId}` (points:write): Complete a custom action. Awards an active «Azione personalizzata» rule to a member, within its limits: `granted`, `duplicate` (already given: not repeatable, or this Idempotency-Key) or `limited` (over a limit). - `POST /api/v1/members/{customerId}/redemptions` (redemptions:write): Redeem a reward for a member. Spends the member's points on a reward, with the same availability, tier and balance rules as the widget: a discount code, store credit or a gift card made on Shopify. A gift card's code is in this answer only (a replay of the call says null). - `POST /api/v1/redemptions/{redemptionId}/cancel` (redemptions:write): Cancel an unused reward. As «Storna»: the code or credit is removed on Shopify and the points come back. A reward already used, expired or given without points cannot be cancelled (422). - `PUT /api/v1/members/{customerId}/tier` (vip:write): Lock or unlock a member's VIP tier. As «Cambia livello»: `lock` keeps the member on a tier (or on none: `tierId: null`) until `until` or for ever; `auto` ends the lock and the tier is decided by the criteria again. `gift`: a move up pays the tier's entry gift. `changed: false` when nothing changed. - `POST /api/v1/members/{customerId}/memberships` (memberships:write): Grant or extend a membership. As «Concedi o estendi»: a term of the plan for `months`, or until a day included, after the time already paid; the plan's tier at once. The note is for the staff (the change log). - `DELETE /api/v1/members/{customerId}/memberships/{planId}` (memberships:write): Revoke a membership. As «Revoca»: every live term of the plan ends now, its tier goes; the monthly benefits not used yet are taken back, other points and codes stay. - `POST /api/v1/changes/{changeId}/undo` (changes:undo): Undo a change. As «Annulla» in the change log, for a member's change (an adjustment, an exclusion, a tier, a membership, a bulk job; a change to the program is 403 undo_not_allowed): through the service that made it, while it still holds (422 with the reason otherwise: already_undone, superseded, would_go_negative, window_expired, not_undoable…). - `GET /api/v1/members` (members:read): List members. Members by join date (oldest first, stable), filtered; erased members never. `updatedSince`: a balance, tier or status changed since. - `GET /api/v1/members/{customerId}/view` (members:read): Get the customer's loyalty view (headless). The same view the widget and the Loyalty Hub show the logged-in customer — balance, rewards they can redeem, ways to earn with their progress, VIP ladder, referral, memberships, the latest activity — in `?locale`, for a headless storefront's server (never call it from a browser). A customer who is not a member yet gets `member: false`. The profile says only whether a birthday is set. - `GET /api/v1/members/{customerId}/activity` (members:read): List a member's points activity. The member's points movements and cashback events, newest first, each explained. - `GET /api/v1/members/{customerId}/redemptions` (members:read): List a member's rewards. The member's rewards, newest first: redeemed with points or given. - `GET /api/v1/members/{customerId}/referrals` (referrals:read): Get a member's referrals. The member's referral code, its link's openings, its referrals by status and the latest 10, and who invited the member. - `GET /api/v1/referrals` (referrals:read): List referrals. The shop's referrals, newest first, all or one status. - `GET /api/v1/program` (program:read): Get the program. The program's status and settings in a few sentences (`?locale`), its points' name and which parts are on. - `GET /api/v1/rules` (program:read): List the ways to earn. The earning rules (archived ones never), each with its summary in `?locale` as the admin writes it. - `GET /api/v1/rewards` (program:read): List the rewards. The rewards catalogue (archived ones never), each with its summary in `?locale`. - `GET /api/v1/tiers` (program:read): List the VIP tiers. The VIP tiers by rank (archived ones never), with their criteria and benefits. - `GET /api/v1/membership-plans` (program:read): List the membership plans. The paid membership plans (archived ones never) with their members in force. - `GET /api/v1/analytics/summary` (analytics:read): Get the analytics of a period. The sums of «Analisi» for a period of the shop's days (at most 731; default: the last 30 days). Revenue «with the program» is attribution, not increment. - `GET /api/v1/changes` (changes:read): List the change log. Every change to the program and the members, newest first, with its author («API · {key name}» for this API's) and a sentence in `?locale`. - `POST /api/v1/proposals` (program:write): Propose a change to the program. A rule or reward created, changed, paused or resumed, a VIP tier changed, the program's settings changed, paused or resumed — checked now with the admin's own rules (422 invalid_proposal says what is wrong), never written by this call: the merchant reviews it in Zestly (`reviewUrl`) and applies it, with your key as the change's author, undoable from the change log. Nothing is ever deleted. At most 20 waiting (422 too_many). - `GET /api/v1/proposals/{proposalId}` (program:write): Get a proposal. A proposal and its state: still waiting, applied (with its change-log row), rejected, expired or stale. ## Webhooks - Outbound only: the merchant adds https endpoints (port 443 or above 1024, public addresses only) and picks their events in Zestly › Sviluppatori › Webhook; no API for them in v1. OpenAPI: `webhooks`; guide: https://zestly.wavo.it/en/docs/api/reference/#webhooks. - Body: `{ id, type, apiVersion: "v1", occurredAt, shop, data: { customerId: "gid://shopify/Customer/…", … }, member: { balance, pending, tier, snapshotAt } | null }`. Ids and numbers only. - Signature (Standard Webhooks): headers `webhook-id`, `webhook-timestamp`, `webhook-signature` = `v1,` + base64(HMAC-SHA256(base64-decoded secret after `whsec_`, id.timestamp.rawBody)); two during a 24-hour rotation. Verify in constant time, refuse a timestamp more than 5 minutes off. Node: https://zestly.wavo.it/api-docs/verify-webhook.mjs. - Answer any 2xx within 10 s. Delivery is at least once, order not guaranteed: dedupe on `webhook-id` (the same on every attempt). Retries: 8 attempts: at once, then after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (about 45 hours, almost 2 days), then given up. Redirects are not followed. A 410 turns the endpoint off (its waiting events dropped); 3 days of failures turn it off (its events wait for «Riattiva»). - Events (`type`) and `data` beyond `customerId`: - `member.enrolled`: origin ("import" when an import from another app made the member; else absent) - `customer.redacted`: nothing more: forget this customer (`member` is null) - `points.earned`: points, pending, availableAt, source {type, id} - `points.released`: points, source {type, id} - `points.adjusted`: points (signed), source {type, id} - `points.reversed`: points (negative), source {type, id} - `points.expired`: points (negative) - `points.redeemed`: points (negative), redemptionId - `points.expiring`: expiresAt, days (30 or 7); sent only if still true when due - `reward.issued`: redemptionId, rewardId, type, source, points, value {amountMinor, currency}, code (a discount code; a gift card's never), giftCardLast4, expiresAt - `reward.cancelled`: redemptionId, rewardId, type, source, points, value {amountMinor, currency}, code (a discount code; a gift card's never), giftCardLast4, expiresAt - `reward.used`: redemptionId, rewardId, type, source, points, value {amountMinor, currency}, code (a discount code; a gift card's never), giftCardLast4, expiresAt, orderId (GID) - `cashback.issued`: amount {amountMinor, currency} - `tier.changed`: from, to (handles), direction (up, down, grace, same: a change between two tiers of equal rank), cause - `tier.near`: tier (handle); sent only if not reached when due - `referral.qualified`: referralId, points - `referral.rewarded`: referralId, points - `referral.reversed`: referralId, points - `membership.started`: membershipId, plan (handle), paidThrough - `membership.renewed`: membershipId, plan (handle), paidThrough - `membership.grace`: membershipId, plan (handle), paidThrough; sent only if still so when due - `membership.lapsed`: membershipId, plan (handle), paidThrough; a renewing membership's only after its renewal window, and only if still lapsed - `membership.revoked`: membershipId, plan (handle), paidThrough - `campaign.started`: no customerId; campaignId, name, kind (multiplier, reward_sale, member_sale), startsAt, endsAt - `campaign.ended`: no customerId; campaignId, name, kind, startsAt, endsAt, reason (time, budget, merchant) - `milestone.reached`: kind (badge, mission, streak), ruleId, points (null when it pays a reward or the badge alone) - `commission.created`: no customerId; commissionId, affiliateId, orderId (GID), amount {amountMinor, currency}, status (pending or review), reason null - `commission.approved`: no customerId; commissionId, affiliateId, orderId (GID), amount {amountMinor, currency}, status approved - `commission.reversed`: no customerId; commissionId, affiliateId, orderId (GID), amount {amountMinor, currency} (what is left: 0), status reversed (or rejected: the merchant refused it), reason (refund, cancel, merchant) - `payout.paid`: no customerId; payoutId, affiliateId, method (manual, store_credit, gift_card), amount {amountMinor, currency}, commissions (count) - `ping`: nothing (no customerId; «Invia un evento di prova»)