---
title: "Inner Circle read-only API for Shopify"
description: "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."
source: https://innercircle.monochrome.digital/developers/api/
---

[Home](https://innercircle.monochrome.digital/) [Developers](https://innercircle.monochrome.digital/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

## On this page

1. [What it is for, and what it is not](#what-it-is-for-and-what-it-is-not)
2. [Plan](#plan)
3. [Authentication](#authentication)
4. [Errors](#errors)
5. [Rate limits](#rate-limits)
6. [Caching](#caching)
7. [`GET /api/v1/access`](#get-api-v1-access)
8. [`GET /api/v1/grants`](#get-api-v1-grants)
9. [`GET /api/v1/requests`](#get-api-v1-requests)
10. [Paging](#paging)
11. [Scope, and what it means for you](#scope-and-what-it-means-for-you)
12. [What you cannot get, and why](#what-you-cannot-get-and-why)

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](https://innercircle.monochrome.digital/developers/theme/) — 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:

```bash
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:

```json
{
  "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.

| Status | `type` | Means |
| --- | --- | --- |
| 400 | `invalid_request` | A parameter is missing or not one of the accepted values |
| 401 | `unauthorized` | No usable credential. See above |
| 403 | `forbidden` | The shop's plan does not include the API |
| 404 | `not_found` | No such endpoint |
| 405 | `method_not_allowed` | Everything here is `GET` |
| 429 | `rate_limited` | Over 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

| Parameter | Required | Meaning |
| --- | --- | --- |
| `customerId` | yes | The Shopify customer id. Bare (`1234567890`) or a GID (`gid://shopify/Customer/1234567890`) |
| `path` | no | The page being asked about. Defaults to `/` |
| `template` | no | Shopify template name: `product`, `collection`, `page`, `index`… |
| `productId` | no | The product being asked about, bare or GID |
| `productTitle`, `productTags`, `productVendor`, `productType` | no | The product's own facts. `productTags` is comma-separated |
| `collectionIds` | no | Comma-separated collections the product is in |
| `collectionId` | no | The collection being asked about, for a collection page |
| `pageHandle` | no | The 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

```json
{
  "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

| Parameter | Meaning |
| --- | --- |
| `status` | `live`, `expired` or `revoked`. Leave it out for all three |
| `customerId` | Only this customer's grants. Bare or GID |
| `email` | Only grants recorded against this address |
| `limit` | 1 to 200. Defaults to 50 |
| `cursor` | See [Paging](#paging) |

### Grants response

```json
{
  "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

| Parameter | Meaning |
| --- | --- |
| `status` | `pending`, `waitlisted`, `approved`, `declined` or `converted`. Case-insensitive. Leave it out for all |
| `email` | Only this address |
| `customerId` | Only this customer. Bare or GID |
| `limit` | 1 to 200. Defaults to 50 |
| `cursor` | See [Paging](#paging) |

### Requests response

```json
{
  "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.

```bash
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.

## Also for developers

[Inner Circle for theme developers](https://innercircle.monochrome.digital/developers/theme/) [Ask us something](https://innercircle.monochrome.digital/contact/)
