All guides

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.

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).

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_…
ScopeAllows
members:readGET /members, GET /members/{customerId}, …/view, …/activity, …/redemptions
members:writePOST /members/{customerId} (enrol), …/exclude, …/reinstate
points:writePOST /members/{customerId}/adjustments, POST /members/{customerId}/actions/{ruleId}
redemptions:writePOST /members/{customerId}/redemptions, POST /redemptions/{redemptionId}/cancel
program:readGET /program, /rules, /rewards, /tiers, /membership-plans
referrals:readGET /referrals, GET /members/{customerId}/referrals
vip:writePUT /members/{customerId}/tier
memberships:writePOST /members/{customerId}/memberships, DELETE /members/{customerId}/memberships/{planId}
analytics:readGET /analytics/summary
changes:readGET /changes (the change log: it names the customers each change is about)
changes:undoPOST /changes/{changeId}/undo, for a member's changes (the program's are undone in Zestly)
program:writePOST /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

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.

Errors

{ "error": { "code": "insufficient_balance", "message": "The request was refused: insufficient_balance.", "requestId": "…" } }

Read code, never message.

StatusCode
400invalid_request: a malformed query, body or header (an unknown field too), an unreadable cursor
401unauthorized: missing, unknown, expired or revoked key
403missing_scope, api_paused, app_uninstalled, undo_not_allowed (a change to the program)
404not_found: not a member of this shop, an unknown reward, rule, plan, redemption or change, a path that is not canonical
405method_not_allowed
409idempotency_conflict, idempotency_in_progress (with Retry-After)
413payload_too_large
422the 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
429rate_limited, with Retry-After (seconds)
500server_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.

KindPer keyPer shop
Reads (GET)300600
Writes150300
Writes that call Shopify (enrolments, redemptions, cancellations, memberships)3060
Reads that may call Shopify (GET /rules, GET /rewards: product and collection names, cached 5 minutes)3060

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 pathWhat it does
GET /membersmembers 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}/viewthe widget's view of the customer, for a headless storefront
GET /members/{customerId}/activitythe points movements and cashback events, explained
GET /members/{customerId}/redemptionsthe member's rewards
GET /members/{customerId}/referralsthe member's referral code and referrals
POST /members/{customerId}enrol an existing Shopify customer (201)
POST /members/{customerId}/exclude · /reinstateexclude from the program, or re-enrol
POST /members/{customerId}/adjustmentsadd or remove points: { points, reason, note?, countsForTier?, notify? }
POST /members/{customerId}/actions/{ruleId}complete a custom action rule
POST /members/{customerId}/redemptionsredeem a reward: { rewardId, points? }
POST /redemptions/{redemptionId}/cancelcancel an unused reward, points back
PUT /members/{customerId}/tierlock the VIP tier ({ mode: "lock", tierId, until?, gift? }) or unlock it ({ mode: "auto" })
POST /members/{customerId}/membershipsgrant or extend a membership: { planId, months | until, note }
DELETE /members/{customerId}/memberships/{planId}revoke a membership
GET /referralsthe shop's referrals
GET /program · /rules · /rewards · /tiers · /membership-plansthe program, with the admin's sentences
GET /analytics/summarythe period's totals of «Analisi» (from, to)
GET /changesthe change log, with the author of each change
POST /changes/{changeId}/undoundo 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):

GroupEventsdata beyond customerId
Membersmember.enrolled, customer.redactedmember.enrolled: origin "import" when an import from another app made the member (M11); customer.redacted: forget this customer, member is null
Pointspoints.earned, points.released, points.adjusted, points.reversed, points.expired, points.redeemed, points.expiringpoints, source (type, id); points.earned: pending, availableAt; points.expired: points only; points.redeemed: points, redemptionId; points.expiring: expiresAt, days only
Rewardsreward.issued, reward.cancelled, reward.used, cashback.issuedredemptionId, rewardId, type, source, points, value (amountMinor, currency), code, giftCardLast4, expiresAt; reward.used: orderId; cashback.issued: amount
VIPtier.changed, tier.nearfrom, to, direction (up, down, grace, or same: a change between two tiers of equal rank), cause; tier.near: tier
Referralsreferral.qualified, referral.rewarded, referral.reversedreferralId, points
Membershipsmembership.started, membership.renewed, membership.grace, membership.lapsed, membership.revokedmembershipId, plan, paidThrough
Testping— (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:

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.