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 (there is no CORS support on purpose).

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, 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 setup, expiry policy) and VIP tiers
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.

MethodPathScope
GET/shopshop:read
GET/customers?email=&cursor=&limit=customers:read
GET/customers/{customer_id}customers:read
GET/customers/{customer_id}/transactionspoints:read
GET/programprogram:read
GET/tiersprogram:read
GET/rewardsredemptions:read
POST/customers/{customer_id}/points/earnpoints:write
POST/customers/{customer_id}/points/adjustpoints:write
POST/customers/{customer_id}/points/deductpoints:write
POST/customers/{customer_id}/excludecustomers:write
POST/customers/{customer_id}/reenrollcustomers:write
POST/customers/{customer_id}/tier-overridecustomers:write
DELETE/customers/{customer_id}/tier-overridecustomers:write
POST/customers/{customer_id}/redemptionsredemptions:write
POST/redemptions/{redemption_id}/reverseredemptions:write
GET/analytics/summary?period_days=&from=&to=analytics:read
GET/webhookswebhooks:manage
POST/webhookswebhooks:manage
DELETE/webhooks/{webhook_id}webhooks:manage

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

Program configuration is read-only in v1 — rules, tiers and referral settings are managed in the Charm admin.

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 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, and every response carries an X-Request-Id header (also echoed in the body) — include it when contacting support:

{
"error": {
"code": "insufficient_points",
"message": "Customer has 120 points; the reward costs 500.",
"request_id": "req_abc123"
}
}
CodeStatusMeaning
unauthorized401No bearer token presented
invalid_token401Token unknown or revoked
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
not_found404Unknown path or resource
idempotency_conflict409Same key, different request body
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

List endpoints return { "data": [...], "next_cursor": "..." }. Pass the cursor back as ?cursor= to fetch the next page; 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”.