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
- 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
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 (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.
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, 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 |
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.
Endpoints
| Method | Path | Scope |
|---|---|---|
| GET | /shop | shop:read |
| GET | /customers?email=&cursor=&limit= | customers:read |
| GET | /customers/{customer_id} | customers:read |
| GET | /customers/{customer_id}/transactions | points:read |
| GET | /program | program:read |
| GET | /tiers | program:read |
| GET | /rewards | redemptions:read |
| POST | /customers/{customer_id}/points/earn | points:write |
| POST | /customers/{customer_id}/points/adjust | points:write |
| POST | /customers/{customer_id}/points/deduct | points:write |
| POST | /customers/{customer_id}/exclude | customers:write |
| POST | /customers/{customer_id}/reenroll | customers:write |
| POST | /customers/{customer_id}/tier-override | customers:write |
| DELETE | /customers/{customer_id}/tier-override | customers:write |
| POST | /customers/{customer_id}/redemptions | redemptions:write |
| POST | /redemptions/{redemption_id}/reverse | redemptions:write |
{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.
A ready-made Bruno collection with one request
per operation lives in the app repository under docs/api/bruno/.
Idempotency
Every POST and DELETE requires an Idempotency-Key header (any
unique string, e.g. a UUID). 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 body 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
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
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" }}| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No bearer token presented |
invalid_token | 401 | Token unknown or revoked |
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 |
not_found | 404 | Unknown path or resource |
idempotency_conflict | 409 | Same key, different request body |
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 |
Pagination
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.
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”.