Python client

This client is for a Python backend (Django, FastAPI, a background worker) that would rather not hand-roll HTTP calls. The meisei package covers the admin API and the widget API, and is generated from the same OpenAPI documents this reference is built from, so it never drifts from what the API accepts. It is built on httpx and pydantic, is fully typed, and needs Python 3.10 or later.

Every Python example in this reference uses it.

Install

pip install meisei

Settings, API keys, where both keys and the widget signing secret live

The admin API

Build a client with a secret key and your store's base URL (see Your base URL). Keep it on your server. A secret key that reaches a browser has leaked. One client is safe to share between threads, so an app can build it once at startup.

import os

from meisei import Client

meisei = Client(
    secret_key=os.environ["MEISEI_SECRET_KEY"],
    base_url="https://your-store.meisei.app/api",
)

review = meisei.reviews.retrieve("A4Ws1igz")
review.rating      # 4
review.created_at  # datetime.datetime(2026, 1, 15, 9, 30, tzinfo=datetime.timezone.utc)

Leave both arguments out and the client reads MEISEI_SECRET_KEY and MEISEI_BASE_URL from the environment. An app installed through OAuth passes access_token= instead of secret_key=.

Calling an endpoint

Endpoints are grouped the way their paths are. The usual five are list, retrieve, create, update and delete, and anything else is named for what it does:

EndpointPython
GET /reviewsmeisei.reviews.list()
PATCH /reviews/{id}meisei.reviews.update(id, published=True)
POST /reviews/{id}/responsemeisei.reviews.response.create(id, body="...")
PATCH /sites/{siteId}/hostnames/{id}meisei.sites.hostnames.update(site_id, id, ...)
POST /loyalty-programs/{id}/publishmeisei.loyalty_programs.publish(id)
POST /products/importmeisei.products.import_(data=[...])

import_ has a trailing underscore because import is a Python keyword. The reference shows the Python call for every endpoint. Path parameters are positional, in the order the path names them. Everything else, query parameters and body fields alike, is a keyword argument in snake_case, and so are the keys of the dicts you pass inside a body. The client sends each one where it belongs, under the API's own name.

meisei.reviews.update("A4Ws1igz", published=False, sentiment=None)

Passing None sends null, which clears a field that can be cleared. Leaving an argument out leaves the field as it is. Every argument is typed, so your editor and a type checker such as mypy catch a misspelled or missing one before anything is sent, and Python raises a TypeError if one gets through.

A few values are converted for you:

  • A list for a comma-separated parameter: include=["orders_count", "tags"].
  • A dict for filter, which is sent as its JSON document. See Filtering.
  • A sort order or an include value in snake_case: sort="-created_at".
  • A datetime or a date, which is sent as ISO 8601.

The keys of a dict you send are left alone wherever they are yours, such as metadata, so metadata={"giftWrap": True} arrives exactly as written.

Reading a response

A record comes back as a pydantic model with an attribute for each field, in snake_case. The records this reference names (a review, a product, a customer) have their own classes in meisei.types, such as Review, and nested records are models too. Timestamps are datetimes.

review = meisei.reviews.retrieve("A4Ws1igz", include=["product"])
if review.product:
    review.product.title      # a SimpleProduct
review.metadata["giftWrap"]   # your own keys, as a plain dict
review.to_dict(mode="json")   # the JSON as it arrived, with the API's own names

A field added to the API after your version of the package still reads the same way, so you only need to upgrade for new endpoints. Records also work with match:

from meisei.types import Review

match review:
    case Review(rating=1 | 2, published=False):
        flag_for_follow_up(review)

Lists and pagination

A list comes back as a page. page.data is the records on it, and looping over the page keeps fetching pages with the same parameters until the last one, so a whole collection is one loop:

page = meisei.customers.list(
    limit=250,
    filter={"createdAt": {"$gte": "2026-01-01T00:00:00Z"}},
)
[customer.email for customer in page.data]
page.has_next_page()  # True

for customer in meisei.customers.list(limit=250):
    sync_customer(customer)

The loop fetches lazily, so itertools.islice(meisei.customers.list(), 10) stops once it has ten. See Pagination for how cursors behave.

Errors

A failed request raises. Every error the package raises is a MeiseiError, and every answer from the API that is an error is an APIError carrying status, code and message (see Errors for the codes):

ClassWhen
RequestValidationErrorVALIDATION_ERROR. issues says which fields were wrong.
BadRequestErrorBAD_REQUEST, including reaching a plan limit.
AuthenticationErrorUNAUTHORIZED: no key, or one that is not valid.
PermissionDeniedErrorFORBIDDEN: the key lacks the endpoint's permission.
NotFoundErrorNOT_FOUND.
ConflictErrorCONFLICT.
RateLimitErrorTOO_MANY_REQUESTS. retry_after is in seconds.
ServerErrorA 5xx.
APIConnectionErrorNo answer at all. APITimeoutError is one kind of it.
import meisei

try:
    meisei.customers.delete(customer_id)
except meisei.NotFoundError:
    pass  # already gone

Retries and timeouts

The client retries a rate-limited request after the Retry-After interval the API gives it. It also retries a 5xx or a dropped connection, with a short backoff, but only on GET, PUT and DELETE, so a POST is never sent twice. Two retries is the default. Each request is logged at debug level and each retry as a warning, on the meisei logger unless you pass your own.

meisei = Client(
    secret_key=os.environ["MEISEI_SECRET_KEY"],
    base_url="https://your-store.meisei.app/api",
    max_retries=4,
    timeout=30,
    logger=logging.getLogger("myapp.meisei"),
)

with_options gives a copy for one call, sharing the same connections. Every method also takes timeout= and extra_headers= for itself:

meisei.with_options(timeout=120, max_retries=0).products.import_(data=rows)

Async

AsyncClient takes the same arguments, every method is awaited, and a list pages with async for:

from meisei import AsyncClient

async with AsyncClient() as meisei:
    async for review in meisei.reviews.list(filter={"published": False}):
        await meisei.reviews.update(review.id, published=True)

The widget API

WidgetClient calls the widget API. From a server, give it a secret key. It reads what the publishable key reads, and draws on your organization's API allowance rather than one shopper's. See Calling the widget API from a server.

from meisei import WidgetClient

widgets = WidgetClient(
    secret_key=os.environ["MEISEI_SECRET_KEY"],
    base_url="https://your-store.meisei.app/api",
)

widgets.rating(product_slugs=["classic-tee", "canvas-tote"])
for review in widgets.reviews.list(product_slug="classic-tee"):
    print(review.title)

A publishable_key= works in place of the secret key, for code that runs where a secret key must not. AsyncWidgetClient is the async version.

Identifying the shopper

Anything personal, such as a shopper's wishlists or loyalty balance, needs their signed email. Pass customer= and the client does the rest. Give the client your widget signing secret and customer= takes the email itself:

widgets = WidgetClient(
    secret_key=os.environ["MEISEI_SECRET_KEY"],
    signing_secret=os.environ["MEISEI_SIGNING_SECRET"],
    base_url="https://your-store.meisei.app/api",
)

widgets.wishlists.list(customer=current_user.email)

To keep the signing secret out of your own code, ask the admin API to sign instead, and pass what it returns. The key needs the customers:write permission.

signed = meisei.widget_signing_secret.sign(email=current_user.email)
widgets.wishlists.create(name="Birthday", customer=signed)

sign_customer signs an email locally, which is also how to hand a signature to a page so the storefront widgets can identify the shopper. See Identifying the customer.

from meisei import sign_customer

sign_customer(current_user.email, signing_secret=os.environ["MEISEI_SIGNING_SECRET"])
# {"customer_email": "ada@example.com", "timestamp": "1735689600", "signature": "48eb87..."}

Talking to several organizations

A client holds one key and one base URL, so build one per organization, passing the same http_client= to share a connection pool between them. If you are building an application many merchants install, use OAuth rather than asking each of them for a secret key, and pass each one's token as access_token=. See Authentication.