Ruby client

This client is for a Ruby backend (Rails, Sinatra, a background job) that would rather not hand-roll Net::HTTP calls. The meisei gem 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 has no dependencies beyond the standard library and needs Ruby 3.2 or later.

Every Ruby example in this reference uses it.

Install

# Gemfile
gem "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 a Rails app can build it once in an initializer.

require "meisei"

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

review = meisei.reviews.retrieve("A4Ws1igz")
review.rating      # => 4
review.created_at  # => "2026-01-15T09:30:00Z"

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:

EndpointRuby
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: [...])

The reference shows the Ruby 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. The gem sends each one where it belongs, under the API's own name.

meisei.reviews.update("A4Ws1igz", published: false, sentiment: nil)

Sending nil sends null, which clears a field that can be cleared. Leaving a keyword out leaves the field as it is. A keyword the endpoint does not take, or a required one you left out, raises an ArgumentError before anything is sent, with a suggestion for a likely typo.

A few values are converted for you:

  • A list for a comma-separated parameter: include: [:orders_count, :tags].
  • A hash 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 Time or a Date, which is sent as ISO 8601.

The keys of a hash 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 an object with a reader for each field, in snake_case. The records this reference names (a review, a product, a customer) have their own classes, such as Meisei::Review, and nested records are wrapped the same way.

review = meisei.reviews.retrieve("A4Ws1igz", include: [:product])
review.product.title       # a Meisei::SimpleProduct
review[:created_at]        # the same as review.created_at
review["createdAt"]        # and the API's own spelling works too
review.metadata["giftWrap"] # your own keys, as a plain Hash
review.to_h                # the JSON exactly as it arrived

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

case review
in { rating: 1..2, published: false }
  flag_for_follow_up(review)
end

Lists and pagination

A list comes back as a page, which is Enumerable over its records. auto_paging_each 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" } },
)
page.map(&:email)
page.next_page?      # => true

meisei.customers.list(limit: 250).auto_paging_each do |customer|
  sync_customer(customer)
end

Without a block, auto_paging_each is lazy: auto_paging_each.first(10) stops fetching once it has ten. See Pagination for how cursors behave.

Errors

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

ClassWhen
ValidationErrorVALIDATION_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.
ConnectionErrorNo answer at all. TimeoutError is one kind of it.
begin
  meisei.customers.retrieve(id)
rescue Meisei::NotFoundError
  nil
end

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.

meisei = Meisei::Client.new(
  secret_key: ENV["MEISEI_SECRET_KEY"],
  base_url: "https://your-store.meisei.app/api",
  max_retries: 4,
  timeout: 30,
  logger: Rails.logger,
)

Any call can override these for itself with request_options:, which also takes extra headers::

meisei.products.import(
  data: rows,
  request_options: { timeout: 120, max_retries: 0 },
)

The widget API

Meisei::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.

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

widgets.rating(product_slugs: ["classic-tee", "canvas-tote"])
widgets.reviews.list(product_slug: "classic-tee").each do |review|
  puts review.title
end

A publishable_key: works in place of the secret key, for code that runs where a secret key must not.

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 = Meisei::WidgetClient.new(
  secret_key: ENV["MEISEI_SECRET_KEY"],
  signing_secret: ENV["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)

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

Meisei.sign_customer(
  current_user.email,
  signing_secret: ENV["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. 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.