Skip to content

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/v1

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

  • 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/earn with the receipt number as source_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/adjust with 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.

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:

Terminal window
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.

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:

Terminal window
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_id is always the token’s.
  • Two scopes. read covers the shop, the customer, their transactions, the program, tiers, rewards and product estimates. write covers joining the program and redeeming a reward. They are independent: write does not include read.
  • 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.

ScopeGrants
shop:readShop profile, currency, points name, plan
customers:readCustomer list and detail — balance, tier, referral code, email
customers:writeExclude / re-enroll customers, enroll through the join gate, set or clear a tier override
points:readA customer’s points transaction history
points:writeEarn, deduct and adjust points
redemptions:readRedemption rules and reward catalog
redemptions:writeRedeem rewards and reverse redemptions — mints real discount codes / store credit
program:readProgram 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:writeAdd, 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:readProgram performance summary — members, points, attributed revenue, trends
webhooks:manageRegister, 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).

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:

FieldShop with a fixed-value rewardNo fixed-value reward
points.outstanding_pointsUnredeemed pointsUnredeemed points
points.outstanding_liabilityAmount in shop currencynull
points.outstanding_liability_unitmoneypoints

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

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.

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.

ClassLimit
Reads10 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 volumea 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 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"
}
}
CodeStatusMeaning
unauthorized401No bearer token presented
invalid_token401Token unknown or revoked
token_expired401Customer session token expired — mint a fresh one and retry once
insufficient_scope403Key lacks the required scope
access_required403API access is not enabled for this store yet — ask us in the support chat
api_paused403The merchant paused API access
feature_disabled403The endpoint’s feature is not available to this shop (bonus campaigns, the partner programme)
not_found404Unknown path or resource
method_not_allowed405The path exists, the method does not
idempotency_conflict409Same key, different request body
duplicate_request409An auto key saw the same request within 60 seconds
revision_conflict409The program changed since the if_revision you sent
confirmation_required409The 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_error422Malformed input
insufficient_points422Balance cannot cover the operation
rate_limited429A limit above was hit — honor Retry-After
internal_error500Unexpected failure on our side
shopify_unavailable503Shopify is throttled or unreachable — retry later

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.

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.

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.

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.

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