Home Developers Inner Circle read-only API for Shopify

Reference

The Inner Circle API

Read a shop's access state from your own server, ERP or middleware: who is unlocked, who has been let in, and who has asked. Read-only, one shop per credential.

Last updated 20 August 2026 · Version 1 · Growth

How a system outside Shopify reads a shop's access state: whether a customer is unlocked, who has been let in, and who has asked.

Everything on this page is a published contract, currently at version 1, addressed at /api/v1/. Fields may be added without notice. Nothing documented here changes meaning or goes away without the version in the path going up. Anything about Inner Circle that is not on this page — the app proxy's responses, the shape of a metafield, an undocumented query parameter — is internal and changes without warning.

The base URL is your own shop's app address. You can copy it, complete, from Settings → API in the Inner Circle admin — it is not the same host for every shop, so take it from there rather than from this page.

What it is for, and what it is not

This API answers questions. It cannot grant access, revoke it, approve a request or change anything at all: every endpoint is a GET, and a POST to any of them is refused with 405. Writes are a separate piece of work and are not here yet.

It is also not an enforcement point. What actually stops somebody buying is Inner Circle's checkout validation function, which runs inside Shopify. This API tells you what that decision is; it does not make it and cannot be worked around to change it.

Plan

The API is on Growth. On any lower plan every endpoint answers 403, even with a valid credential.

The theme developer contract — data-ic-show and everything else in the theme developer guide — is on every plan including Free, and is unaffected by this. If what you need is a theme that behaves differently for a member, you do not need this API and you do not need the merchant to upgrade.

Authentication

Create a credential in the Inner Circle admin, under Settings → API. Give it the name of the system that will use it — you will want to know which one you are revoking later.

You get one string:

ick_9f2a41c0d3b57e81a04c6b2f.h7Qk2sV1nR8pXwZ0mCbY6tJfL4gAe3dU9uK5rN1oS2c

The part before the dot is the key id. It is public, it is shown in the admin, and it is how we find your credential. The part after it is the secret.

Send the whole thing as a bearer token:

curl "https://your-app-url/api/v1/access?customerId=1234567890" \
  -H "Authorization: Bearer ick_9f2a41c0d3b57e81a04c6b2f.h7Qk2sV1nR8pXwZ0mCbY6tJfL4gAe3dU9uK5rN1oS2c"

The secret is shown once

It is stored only as a hash, so there is no screen, no support request and no database query that can produce it again. If you lose it, revoke that credential and create another. This is the reason a credential has a name: rotating one should not mean working out which of three systems just stopped working.

A shop can hold up to ten live credentials at a time. Revoked ones do not count.

Everything about a refusal looks the same

A missing credential, a malformed one, a key id that does not exist, a wrong secret and a revoked credential all return the same 401 with the same body. That is deliberate: a different answer for "no such key" than for "wrong secret" would let anybody with a list of leaked key ids sort the live ones from the dead ones without ever holding a working credential.

If you are debugging, the admin shows every credential you hold and when each was last used, which is the information you actually want.

Errors

Every error, on every endpoint, has the same shape:

{
  "error": {
    "type": "unauthorized",
    "message": "Send a valid credential as `Authorization: Bearer <key id>.<secret>`…"
  }
}

Branch on type. The message is written for a human reading a log and may be reworded at any time.

StatustypeMeans
400invalid_requestA parameter is missing or not one of the accepted values
401unauthorizedNo usable credential. See above
403forbiddenThe shop's plan does not include the API
404not_foundNo such endpoint
405method_not_allowedEverything here is GET
429rate_limitedOver the limit. See below

Nothing is ever half-answered. A refused request returns an error and no data — never an empty list, never a partial page. If you have a data array, it is complete for the query you sent.

Bad input is refused rather than interpreted. A limit of 5000 is a 400, not a silent 200; an unrecognised status filter is a 400, not an ignored filter. An integration that quietly stops seeing most of a shop is a worse outcome than one that fails on the day it is written.

Rate limits

120 requests per minute, per credential. Over that you get 429 with a Retry-After header giving the seconds to wait.

The limit is counted per credential rather than per IP, so two systems on the same credential share a budget and two credentials on the same server do not. If two of your systems call this API, give each its own credential.

The caveat worth reading before you build something high-throughput

The counter is held in memory on the instance that served the request. It is not shared between instances and it resets when the app deploys. In practice that means the effective limit is at least 120 a minute and sometimes more, and that a burst can be allowed through right after a deploy.

Do not design around the extra headroom. It is not a guarantee, it will tighten without notice when this moves to shared storage, and the documented number is the one that will still be true afterwards. If you need to read a whole shop, page through /grants and /requests rather than calling /access in a loop.

Caching

Every response is Cache-Control: no-store. Access state changes the moment a merchant revokes somebody, and a cached answer is a customer who is still being let in — or still being turned away — minutes after the decision changed.


GET /api/v1/access

Is this customer unlocked, and what do they hold?

This is the same question the storefront asks on every page view, answered by the same code. If this endpoint and the shop ever disagree, that is a bug worth reporting rather than a difference to work around.

Access parameters

ParameterRequiredMeaning
customerIdyesThe Shopify customer id. Bare (1234567890) or a GID (gid://shopify/Customer/1234567890)
pathnoThe page being asked about. Defaults to /
templatenoShopify template name: product, collection, page, index
productIdnoThe product being asked about, bare or GID
productTitle, productTags, productVendor, productTypenoThe product's own facts. productTags is comma-separated
collectionIdsnoComma-separated collections the product is in
collectionIdnoThe collection being asked about, for a collection page
pageHandlenoThe page handle, for a page

customerId alone asks the store-level question: is this person a member, and is the shop's front page gated for them.

We do not look your products up

This is the one thing about this endpoint that goes wrong quietly, so it is worth a heading.

There is no admin session on your request — you are a server, not a signed-in merchant — so Inner Circle does not fetch your product to find out its tags, vendor or type. It only knows what you send.

A lock that gates by tag, vendor, type or a product condition therefore matches only if you send those facts. If the merchant has a lock on everything tagged vip and you send productId without productTags, that lock will not match and the answer will say the page is not gated. That answer is wrong, and nothing about it looks wrong.

So: send the product's facts along with its id, exactly as a theme does. If you cannot, ask the store-level question and treat access.hasAccess as your answer, which needs no product at all.

Two more things this endpoint cannot see, for the same reason:

  • Customer tag and email bypasses. A lock set to let in anybody tagged wholesale needs the customer's tags from Shopify, which we do not fetch here. Such a visitor is reported as locked while the storefront lets them in.
  • Guest access. A shopper who redeemed a code without signing in holds a token in their browser and has no customer id. There is nothing to ask about, and this endpoint will not accept a guest token — see below.

Guest tokens are not accepted

Inner Circle's internal gate check also accepts a guest's unlock token. This endpoint does not, deliberately: a guest token is a bearer credential belonging to a shopper's browser, and an endpoint that tested them would be a place to try stolen ones against a shop.

Access response

{
  "customer": { "id": "1234567890" },
  "access": {
    "hasAccess": true,
    "opensEverything": false,
    "tier": "Gold",
    "locks": [{ "id": "clx8…", "name": "VIP collection" }],
    "grantCount": 2
  },
  "gate": {
    "locked": false,
    "reason": "unlocked",
    "lock": { "id": "clx8…", "name": "VIP collection" },
    "requiredTier": null,
    "unlocksAt": null
  },
  "checkedAt": "2026-08-19T20:14:03.221Z"
}

access is about the person. hasAccess is true if they hold any live grant at all, whatever page you asked about. opensEverything is true when one of their grants opens every lock — an approval, or a code with no lock scoping. locks names the locks their grants reach, and is empty when opensEverything is true. grantCount is how many live grants are behind it: redeeming two codes is two grants, and both keep their own expiry.

gate is about the page. locked is the decision for the page you described. lock is the lock that decided it, or null when no lock applied. requiredTier is the tier that lock asks for, by name. unlocksAt is when a scheduled drop ends and the page opens to everybody, or null.

reason is Inner Circle's own vocabulary for why, and is the same string the merchant sees in the admin's lock preview: gate_disabled, no_matching_lock, bypass_logged_in, bypass_tag, bypass_email, unlocked, tier_too_low, locked. New values can appear. Treat one you do not recognise as "no opinion" and read locked.

Tiers have names and no order

tier and requiredTier are names, matched exactly, ignoring case. There is deliberately no rank and no "Gold or above".

Ranking is a rule, the rules live on Inner Circle's servers, and the ordering was already applied by the time gate.locked was decided. A caller comparing ranks would be a second implementation of the access rules, and the two would eventually disagree — with both halves looking correct on their own. Ask gate.locked and you are asking the one copy that decides.


GET /api/v1/grants

Everybody who has been let in, and what let them in.

A grant is one record of access earned: a redeemed code, a followed invite, an approval. One person can hold several — a second code widens their access rather than replacing it.

Grants parameters

ParameterMeaning
statuslive, expired or revoked. Leave it out for all three
customerIdOnly this customer's grants. Bare or GID
emailOnly grants recorded against this address
limit1 to 200. Defaults to 50
cursorSee Paging

Grants response

{
  "data": [
    {
      "id": "clx9…",
      "status": "live",
      "customerId": "1234567890",
      "email": "ada@example.com",
      "tier": "Gold",
      "code": {
        "id": "clx7…",
        "label": "Trade show, Milan",
        "type": "SHARED",
        "status": "ACTIVE",
        "useCount": 12,
        "maxUses": 50,
        "tier": "Gold",
        "locks": [{ "id": "clx8…", "name": "VIP collection" }],
        "startsAt": null,
        "expiresAt": null
      },
      "createdAt": "2026-08-01T09:14:00.000Z",
      "expiresAt": "2026-09-01T09:14:00.000Z",
      "revokedAt": null,
      "lastSeenAt": "2026-08-18T17:02:11.000Z"
    }
  ],
  "page": { "limit": 50, "nextCursor": "clx9…" }
}

status is derived, not stored: revoked if it was pulled, otherwise live or expired by the clock. code is null for a grant with no code behind it, which is how an admin approval looks and which opens everything.

lastSeenAt is when the grant was last used on the storefront. Reading a grant through this API does not touch it, so you can poll without making every grant look active.

There is no code value here, and there never will be

code describes an access code by its label, type, status, use count, expiry, tier and scope. It does not contain the code itself, and no endpoint on this API does. An endpoint that listed live codes would hand over the keys to the shop to anybody who got hold of a read-only credential.

The id is the same id the admin uses, so you can link a grant back to a code a merchant can find and act on.


GET /api/v1/requests

Everybody who has asked to be let in, what they said, and what was decided.

Requests parameters

ParameterMeaning
statuspending, waitlisted, approved, declined or converted. Case-insensitive. Leave it out for all
emailOnly this address
customerIdOnly this customer. Bare or GID
limit1 to 200. Defaults to 50
cursorSee Paging

Requests response

{
  "data": [
    {
      "id": "clxa…",
      "status": "WAITLISTED",
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "phone": null,
      "company": "Analytical Engines",
      "country": "GB",
      "customerId": null,
      "sourcePath": "/collections/vip",
      "answers": { "how-did-you-hear": "A friend" },
      "items": [],
      "fulfillment": "NONE",
      "issuedCode": null,
      "draftOrder": null,
      "waitlist": { "joinedAt": "2026-08-10T11:00:00.000Z", "referralCount": 3 },
      "marketingConsentAt": "2026-08-10T11:00:00.000Z",
      "note": "Met at Milan",
      "decidedAt": null,
      "createdAt": "2026-08-10T11:00:00.000Z",
      "updatedAt": "2026-08-11T08:30:00.000Z"
    }
  ],
  "page": { "limit": 50, "nextCursor": null }
}

answers is whatever the merchant's own request form collected, keyed by field id. items is the interest list, where the shop uses one. note is the merchant's internal note, which is never shown to the customer by either of us.

marketingConsentAt is the only thing that says you may market to this person. It is set when they ticked the marketing box on the form, and it is dated. Holding an address is not consent, being approved is not consent, and being in the queue is not consent. Inner Circle applies that rule to its own Klaviyo sync and you should apply it to whatever you build.

Two fields you will not find

No referral code. A waitlist referral code is a shareable link that moves somebody up a queue, so it is treated as a token like any other and is reported by its effect — referralCount — rather than its value.

No decidedBy. Which member of the merchant's staff approved or declined is not part of a shop's record of its customers, and is not needed to act on a request.

There is no queue position either. Position is derived from the whole queue on every read, so putting it on a page of fifty rows would be fifty queue scans to answer a question the merchant's own admin already answers.


Paging

Both list endpoints page the same way, newest first.

Ask for a page, and if there is more you get a nextCursor. Pass it back as cursor for the next page. When nextCursor is null you have seen everything.

curl "https://your-app-url/api/v1/grants?limit=100" -H "Authorization: Bearer …"
curl "https://your-app-url/api/v1/grants?limit=100&cursor=clx9…" -H "Authorization: Bearer …"

The order is stable — created time, ties broken by id — so a row cannot swap places between pages and be shown to you twice or not at all. Rows created while you are paging are newer than where you are and will not appear; poll the first page for those.

Scope, and what it means for you

A credential can only ever read the shop that created it. That is a property of every query rather than a check somebody remembered to write: the shop is taken from the credential, and every filter you send narrows it. ?customerId= naming a customer of another shop returns an empty list, not that customer.

If you build one integration serving several merchants, you need one credential per shop, and you must key them by shop yourself. There is no cross-shop credential and there will not be one.

What you cannot get, and why

No redeemable code values. No unlock tokens. No waitlist referral codes. No tier rankings. No lock targets, exclusions, schedules or settings. No staff identity. And no more of any customer than the merchant already holds in Shopify.

Two rules produce all of that. The first: a token is described by what it does, never by its value, because a read-only credential must not be a way to obtain access. The second: this API publishes decisions and not the rules behind them, because a caller that could re-derive a decision would eventually derive a different one, and the version that lost would be whichever the merchant was not looking at.

If you have hit a wall this leaves you at, that is worth telling the Inner Circle team about. It is more likely to become a new field here than a reason to open up the rules.