Charm API
The Charm API is a shop-scoped REST API for server-to-server integrations. It reads and writes the same points ledger the app itself uses, so anything you do through the API shows up in the customer’s history, in analytics, and in the storefront widget exactly as if it had happened inside Charm.
Base URL: https://charm.appfleece.app/api/v1The machine-readable spec is served unauthenticated at
/api/v1/openapi.json
(OpenAPI 3.1) — point your client generator or API tooling at it.
The API is available on every plan. Access is switched on per store — message us in the in-app support chat (Settings → API has a button) and we’ll enable it for you. The ask-first step exists so we know who is integrating and can help from day one, not to upsell.
What stores build with it
Section titled “What stores build with it”- In-store purchases earn points. Your loyalty program shouldn’t stop at
the webshop door. A small bridge from your POS or cash register awards
points per receipt (
points/earnwith the receipt number assource_ref), so omnichannel customers see one balance everywhere. - Loyalty context in your helpdesk. Show balance, tier and recent history
next to every ticket, and let support grant goodwill points right from the
helpdesk after a rough experience (
points/adjustwith a reason the customer sees). - Reward actions that happen outside Shopify. Reviews on an external platform, wholesale-portal orders, event attendance, a packaging-return program — anything your systems can observe can earn points.
- Mirror balances into your CRM or data warehouse. Nightly reads of customers and transactions feed segmentation, BI dashboards and accrual reporting without touching the app.
- One-off imports and corrections. Migrating from a legacy program or fixing a batch of balances beats clicking through the admin one customer at a time.
Authentication
Section titled “Authentication”Create API keys in Settings → API in the Charm admin. Each key has:
- a label, so you know which integration it belongs to
- a set of scopes (below) — grant only what the integration needs; the read-only preset is pre-selected
- a token starting with
chrm_live_, shown exactly once at creation (and once more if you rotate it). Charm stores only a hash — a lost token means rotating or minting a new key.
Lost the token, or want to swap it out on a schedule? Rotate a key from its detail view (the edit icon next to any key in Settings → API): it mints a fresh token under the same name and scopes and revokes the old one in the same action. Rotation requires the access grant, like creating a key — it mints a credential either way. Revoking a key never requires the grant: taking access away is never gated. A revoked key can be permanently deleted once it’s revoked — deletion is a second, explicit step, never a shortcut around revocation.
Send the token as a bearer token on every request:
curl https://charm.appfleece.app/api/v1/shop \ -H "Authorization: Bearer chrm_live_..."The shop is always resolved from the key itself — there is no shop
parameter, and one supplied in a request is ignored. Keys are server-side
credentials: never ship one in a browser, mobile app or theme. Responses to
API-key requests carry no CORS headers on purpose, so a key pasted into a
browser fails loudly. For browsers and apps, use a customer session token.
Customer session tokens
Section titled “Customer session tokens”A session token is the browser-safe credential. Your server mints one for the signed-in shopper and hands it to their browser or app, which then calls the API directly, with CORS, instead of going through your backend:
curl -X POST https://charm.appfleece.app/api/v1/customers/123/session-tokens \ -H "Authorization: Bearer chrm_live_..." \ -H "Content-Type: application/json" \ -d '{ "scopes": ["read", "write"], "ttl_seconds": 3600 }'The response carries token (chrm_st_…), expires_at and the API scopes
it may use. The browser sends it as Authorization: Bearer chrm_st_….
- One customer. A token can read and act for its own customer only.
Another customer’s id in the path is refused, and the product estimate’s
customer_idis always the token’s. - Two scopes.
readcovers the shop, the customer, their transactions, the program, tiers, rewards and product estimates.writecovers joining the program and redeeming a reward. They are independent:writedoes not includeread. - Never more than its key. The token’s powers are intersected with the minting key’s scopes, both at minting and on every request. Revoking the key, or pausing the API, stops its tokens immediately.
- Short-lived. One hour by default, up to seven days. An expired token
answers
401 token_expired: mint a fresh one on your server and retry once. - Limited per customer. Read limits apply per shopper, so one busy browser cannot use up the budget of the rest of your store.
- Server-side only for everything else. Points writes, exclusions, tier
overrides, customer lists, analytics, webhooks and minting itself refuse a
session token with
403 insufficient_scope.
No server of your own? A theme store fetches a token from
/apps/charm/session-token on its own storefront, and a browser-only
headless app exchanges the shopper’s Shopify Customer Account API token for
one. Both are described under
Headless storefronts.
No signing secret is involved on your side, so there is nothing extra to store or rotate: the API key you already have is the only credential. See Headless storefronts for the end-to-end flow.
The Pause API switch in Settings → API stops all API traffic for the shop immediately without revoking any keys.
Scopes
Section titled “Scopes”| Scope | Grants |
|---|---|
shop:read | Shop profile, currency, points name, plan |
customers:read | Customer list and detail — balance, tier, referral code, email |
customers:write | Exclude / re-enroll customers, enroll through the join gate, set or clear a tier override |
points:read | A customer’s points transaction history |
points:write | Earn, deduct and adjust points |
redemptions:read | Redemption rules and reward catalog |
redemptions:write | Redeem rewards and reverse redemptions — mints real discount codes / store credit |
program:read | Program configuration (earning rules, rewards, referral program, expiry policy), VIP tiers, who earns, customer e-mails, paid memberships, the partner programme’s defaults and groups, per-product points estimates, the rule-type catalog, bonus campaigns and the change log |
program:write | Add, change and remove earning rules, rewards and VIP tiers; change the referral program, who earns, which customer e-mails go out, the program and points names and the expiry policies; create, change and remove paid memberships (off sale), partner groups and the partner programme’s defaults; create and change bonus campaigns; undo a change — changes what every shopper sees. Switching the program, tiers, referrals, memberships, the partner programme or all e-mails on or off stays in the admin, as do partner approvals and payouts |
analytics:read | Program performance summary — members, points, attributed revenue, trends |
webhooks:manage | Register, list and delete webhook endpoints |
There is no wildcard scope, and every :write scope automatically includes
its :read sibling — a key that can change what it cannot see would help
nobody. redemptions:write deserves respect: combined with points:write
it can manufacture discounts, which is why both carry the tightest limits
below. program:write deserves the same respect for a different reason: it
rewrites the live program. Program writes accept dry_run: true (preview the
exact diff, change nothing) and if_revision (refuse when the program changed
since you read it), and every change is logged with its actor and can be
undone — see GET /program/changes.
Partner groups are the exception: their writes take dry_run but not
if_revision, and are logged but not undoable. Adding, removing or
re-barring a VIP tier while tiers are live also needs
confirm_member_impact: true once the merchant has seen who moves, really
deleting a rule, reward, membership, partner group or campaign needs
confirm_delete: true, and switching on a campaign e-mail that
would reach members needs confirm_email_impact: true
(409 confirmation_required).
Endpoints
Section titled “Endpoints”The API reference documents every endpoint with
its scope, whether a session token may call it, parameters, the exact
response fields and an example. It is generated from the same registry that
routes requests, so it cannot drift from the API. The machine-readable form is
/api/v1/openapi.json.
{customer_id} accepts a Shopify customer GID
(gid://shopify/Customer/123) or the bare numeric id (123). Email is a
query filter on the collection endpoint, never a path segment.
The whole program is writable through the API, so an assistant or a script can set it up end to end; what stays in the Charm admin is switching things on or off for shoppers, approving partners and recording payouts.
GET /customers/{customer_id} also carries next_tier: the tier the customer
reaches next and the gap to it in that tier’s own unit (points, money in
the shop currency, orders or referrals), computed the same way the
storefront widget shows “120 points to Gold”. It is null on the top tier,
when tiers are off, or when the tier is pinned by hand.
GET /products/{product_id}/points is the product-page estimate for
storefronts without the widget. Send the unit price in the shop currency and,
where your program uses them, the product’s collection_ids and tags; no
Shopify lookup happens on our side. With customer_id the estimate applies
that customer’s tier multiplier and per-customer bonuses. enabled: false
comes with a reason (program_disabled, no_points_layer,
product_excluded, customer_excluded) instead of a zero you would have to
interpret.
POST /customers/{customer_id}/enroll is the widget’s Join button for
storefronts without the widget, on programs with a join gate: first join
wins, the signup bonus is credited at the join moment, and locale is
remembered for e-mails. An already-joined customer answers changed: false.
Programs without a join gate enroll customers on their own (account creation
or first order) and refuse the call with join_gate_enabled: false.
Outstanding liability in /analytics/summary
Section titled “Outstanding liability in /analytics/summary”GET /analytics/summary reports the points members hold but haven’t redeemed
in points.outstanding_points, and their value in the shop’s currency in
points.outstanding_liability. A point can only be priced when the shop has
an enabled reward with a fixed cash value (an amount discount, a POS amount
discount or store credit); Charm uses the one that costs the fewest points.
When no enabled reward has one (the rewards are all free shipping, product
discounts or percentage discounts), a point has no fixed price, and the
response says so instead of reporting a zero:
| Field | Shop with a fixed-value reward | No fixed-value reward |
|---|---|---|
points.outstanding_points | Unredeemed points | Unredeemed points |
points.outstanding_liability | Amount in shop currency | null |
points.outstanding_liability_unit | money | points |
A 0 in outstanding_liability is therefore a real, priced zero. Check
outstanding_liability_unit before doing arithmetic with the amount.
The same response carries revenue_series, one row per bucket with
attributed_revenue and discounts_given. revenue_series_granularity says
what a bucket is: day for windows up to about three months (the month field
then holds a date, YYYY-MM-DD), and month for longer windows (YYYY-MM).
Prefer being told over asking? Webhooks push points.earned,
tier.changed, reward.redeemed and friends to your server as they happen,
HMAC-signed and retried.
A ready-made Bruno collection with one request
per operation lives in the app repository under docs/api/bruno/.
Idempotency
Section titled “Idempotency”Every POST, PATCH and DELETE requires an idempotency key (any unique
string, e.g. a UUID) — normally the Idempotency-Key header, or the
idempotency_key query parameter for no-code clients that cannot vary a
header per run (a Gorgias action button, say). The query parameter wins when
both are present, so a stale fixed header left behind in a no-code tool’s
configuration cannot override it.
A client whose configuration is fixed — a helpdesk action button, say — can
send the literal auto instead of a key and have one derived from the request
itself. An identical repeat within 60 seconds is then refused with
409 duplicate_request, naming how long ago it ran, rather than silently
replaying the first result; anything that differs is a new operation
immediately, and a derived key can never collide with another caller’s. Retrying with the same key replays the original response — marked
with Idempotency-Replayed: true — instead of running the operation again,
so a crashed integration can retry blindly without awarding points twice or
minting two discount codes. Keys are remembered for 24 hours; reusing one
with a different request returns 409 idempotency_conflict.
Points earning is additionally idempotent at the ledger level: source_ref
(required on earn) is unique per customer, so the same event can never award
twice even across different idempotency keys.
Rate limits
Section titled “Rate limits”Limits protect your own store first — every Shopify API call the Charm API makes competes with order processing and points awarding for the same per-store budget.
| Class | Limit |
|---|---|
| Reads | 10 req/s, 600/min per key |
| Writes (points, customer state) | 5 req/s per key, 300/min per shop |
| Shopify-touching writes (redemptions, tier overrides) | 20/min per shop, plus a daily cap: 1 000 by default, 5 000 on Unlimited |
| Points volume | a daily budget that scales with your shop’s normal issuance |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset for the tightest applicable bucket; a 429 includes
Retry-After. Shopify-touching writes are also shed pre-emptively with a
503 when your store’s Shopify API budget is running low — back off and
retry after the indicated delay. Need a higher cap for a migration or a Plus
store? Contact support — limits are adjustable
per shop without a deploy.
Errors
Section titled “Errors”Errors use one envelope on the REST API and the same facts in MCP tool
results. Every response carries an X-Request-Id header (also echoed in the
body; include it when contacting support), and every error a doc_url
pointing at its entry in the error catalog, with the cause and
the fix:
{ "error": { "code": "insufficient_points", "message": "Customer has 120 points; the reward costs 500.", "request_id": "req_abc123", "doc_url": "https://docs.charmloyalty.com/api/errors#insufficient-points" }}| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No bearer token presented |
invalid_token | 401 | Token unknown or revoked |
token_expired | 401 | Customer session token expired — mint a fresh one and retry once |
insufficient_scope | 403 | Key lacks the required scope |
access_required | 403 | API access is not enabled for this store yet — ask us in the support chat |
api_paused | 403 | The merchant paused API access |
feature_disabled | 403 | The endpoint’s feature is not available to this shop (bonus campaigns, the partner programme) |
not_found | 404 | Unknown path or resource |
method_not_allowed | 405 | The path exists, the method does not |
idempotency_conflict | 409 | Same key, different request body |
duplicate_request | 409 | An auto key saw the same request within 60 seconds |
revision_conflict | 409 | The program changed since the if_revision you sent |
confirmation_required | 409 | The call needs the merchant’s yes (a tier change that moves members, a delete, a campaign e-mail, a switch): show details, then repeat with the flag the message names and a new idempotency key |
validation_error | 422 | Malformed input |
insufficient_points | 422 | Balance cannot cover the operation |
rate_limited | 429 | A limit above was hit — honor Retry-After |
internal_error | 500 | Unexpected failure on our side |
shopify_unavailable | 503 | Shopify is throttled or unreachable — retry later |
Writes report what changed
Section titled “Writes report what changed”Every write answers with changed: true | false next to the resource it
touched, so a retry, a no-op or a clamp is never mistaken for a fresh effect:
an earn whose source_ref was already awarded answers changed: false, a
deduct reports requested and applied (it stops at a zero balance), and
customer writes return the customer as it is now.
Versions
Section titled “Versions”The API is versioned by date; the current version is 2026-10-01. Additive
changes ship continuously, breaking ones only in a new dated version you opt
into. A key answers in the version it was created with (upgrade it in
Settings → API), the Charm-Version request header picks one per request,
and every response carries Charm-Version. Webhook endpoints deliver in the
version they were created with. Details and every change:
API changelog.
Developer console
Section titled “Developer console”Settings → API → Developer console lists every request of the last seven
days with its key, status, error code and request_id, and every webhook
endpoint with its recent deliveries: send one again, send a test event, or
turn a switched-off endpoint back on. Test key in a key’s details runs the
same checks a real request would. Successful reads made with customer session
tokens are not listed (they are storefront page views); their errors and
writes are.
Pagination
Section titled “Pagination”Every list returns { "data": [...], "next_cursor": "...", "has_more": true }.
Pass the cursor back as ?cursor= to fetch the next page; has_more: false
(and a null cursor) means the end. limit defaults to 50 (max 250). There is no total count — pages are
stable under concurrent writes, counts would not be.
History attribution
Section titled “History attribution”Writes show up in the customer’s points history. A reason you supply is
shown to the shopper verbatim; without one, a localized default (“Awarded via
API”) is used in the shopper’s language. Every API transaction also records
which key made it, so support can always answer “who changed this balance”.