Project

choiceqr

0.0
The project is in a healthy, maintained state
A Ruby gem for interacting with the ChoiceQR Open API. Handles authentication and provides a clean interface to the place, menu, location, order, booking, and feedback resources.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.13
~> 1.68
~> 3.23

Runtime

 Project Readme

choiceqr

Ruby API client for the ChoiceQR Open API.

Handles authentication and provides a clean interface to the place, menu, location, order, booking, and feedback resources. Built by Stockbird.

Installation

Add to your Gemfile:

gem "choiceqr"

Or install directly:

gem install choiceqr

Requirements

Quick start

client = ChoiceQR::Client.new(token: ENV["CHOICEQR_TOKEN"])

place = client.place.get
puts place.name

sections = client.sections.list
sections.each { |section| puts section.name }

Authentication

ChoiceQR access tokens are obtained once via an OAuth-style authorization code flow and are valid for roughly five years — there is no refresh step to manage at runtime. Once you have the code from the "Ask permission" redirect, exchange it for a token:

result = ChoiceQR::Client.exchange_token(code: code, client_id: client_id, secret: secret)
result.token       # => access token, valid ~5 years — store this securely
result.var_symbol  # => uniq company identifier
result.domain      # => company domain

client = ChoiceQR::Client.new(token: result.token)

See Authorization for the full flow (connecting your application to a client, permission dialog, callback URL).

Resources

The following resource accessors are available on the client:

Method API path
client.place place
client.section_info menu/:language/section-info/:sectionId
client.sections menu/:language/sections
client.categories menu/:language/categories, scoped by section
client.dishes menu/:language/dishes, scoped by category
client.dish_options menu/:language/options, scoped by section
client.dish_labels menu/:language/dish-labels
client.pack menu/:language/pack
client.cutlery menu/:language/cutlery (a single settings object, no id)
client.full_menu menu/:language/full/* — bulk import, availability sync, marketplace sync
client.areas location/:language/areas
client.location_points location/:language/points, scoped by area
client.orders orders
client.bookings bookings
client.feedbacks feedbacks

Most menu/location methods accept a per-call language: override; when omitted, the client's default_language ("en" unless configured otherwise) is used:

client = ChoiceQR::Client.new(token: token, default_language: "de")
client.sections.list                 # uses "de"
client.sections.list(language: "cs") # overrides to "cs" for this call

CRUD operations

Attributes are passed as keyword arguments (or splat a Hash with **), not a positional Hash — this matches how most modern Ruby API clients read, and keeps every method's required path arguments (id, section_id, …) unambiguous from the optional attributes:

section = client.sections.create(name: "Drinks", pos_id: "10")
client.sections.update(section.id, name: "Beverages")
client.sections.set_position([id1, id2, id3])
client.sections.delete(section.id)

category = client.categories.create(name: "Hot", section: section.id)
client.categories.list(section.id)

dish = client.dishes.create(name: "Cappuccino", category: category.id, price: 420) # price is in cents
client.dishes.update(dish.id, name: "Double Espresso", price: 450)
client.dishes.patch(dish.id, active: false) # partial update
client.dishes.update_areas(dish.id, takeaway: true, delivery: false)
client.dishes.find_by_pos_id("your-pos-id")

update/patch/position-bulk/attach/detach endpoints return 204 No Content on success — the corresponding gem methods return true rather than a fetched Resource (there's no ETag or similar concurrency token to round-trip). delete also returns true.

Full menu import and availability sync

client.full_menu.import(
  sections: [{ pos_id: "1", name: "Main" }],
  categories: [{ pos_id: "1", section_pos_id: "1", name: "Hot" }],
  dishes: [{ pos_id: "1", category_pos_id: "1", name: "Coffee", price: 300 }],
  preserve_missing_items: true
)

client.full_menu.sync_availability(dishes: [{ pos_id: "525", active: false }])

sync = client.full_menu.sync_marketplace_data(
  dishes: [{ pos_id: "1", data: { WOLT: { price: 1000, name: "Wolt name" } } }]
)
client.full_menu.marketplace_sync_status(sync.id)

Orders

client.orders.list(since: Time.now - 3600, include_approved: true)
client.orders.list_archive(from: Time.now - 86_400 * 30, till: Time.now) # rate limit: 1 req / 5s
order = client.orders.get(id)
client.orders.get_by_guid(order.guid)
client.orders.update_delivery(order.id, delivery_status: "processing")
client.orders.cancel(order.id, reason: "Out of stock")
client.orders.close(order.id)

Bookings

client.bookings.list(from: Time.now, till: Time.now + 86_400 * 7) # rate limit: 1 req / 5s
booking = client.bookings.get(id)
client.bookings.confirm(booking.id, location_points: [point_id])
client.bookings.cancel(booking.id, cancel_reason: "Table no longer available")

Feedbacks

client.feedbacks.list(type: "ORDER")
client.feedbacks.create(
  ref_id: order_id,
  feedback: { type: "ORDER", rate_serve: 5, rate_dish: 4, language: "en", message: "Great!" },
  customer: { name: "John Doe", phone: "+380501234567" }
) # rate limit: 1 req / 10s

Response objects

All returned data is a ChoiceQR::Resource — a generic object backed by a snake_case symbol-keyed hash. Nested data (menu options, order items, etc.) is wrapped the same way at every depth, so dot access works throughout:

dish.name                              # dot notation
dish[:name]                            # symbol key
dish["posID"]                          # camelCase string key (also works)
dish.menu_options.first.list.first.price
dish.to_h                              # plain hash, nested Resources unwrapped

API keys are transformed as follows:

  • defaultLanguage → :default_language
  • posID → :pos_id (the one field ChoiceQR capitalizes as an acronym instead of plain camelCase)
  • _id → :id (leading underscore stripped)

A field whose name collides with a real Object method (hash, method, class, send, …) isn't reachable via dot access — Ruby dispatches to the real method first. Use resource[:hash]-style hash access for those instead.

Error handling

All errors inherit from ChoiceQR::Error and carry http_status, http_body, http_headers, and error_name (the API's own "ValidationError"/"ServiceError" classification, where present):

begin
  client.dishes.get("nonexistent")
rescue ChoiceQR::NotFoundError
  # unknown id
rescue ChoiceQR::AuthenticationError
  # token missing or invalid
rescue ChoiceQR::ForbiddenError
  # token doesn't have the required scope
rescue ChoiceQR::ValidationError => e
  puts e.message
rescue ChoiceQR::RateLimitError
  # 429 — the gem already retries a couple of times with backoff before this is raised
rescue ChoiceQR::ServerError
  # 5xx
rescue ChoiceQR::Error => e
  # catch-all
end

Full error hierarchy:

ChoiceQR::Error
├── ChoiceQR::ConnectionError
├── ChoiceQR::TimeoutError
└── ChoiceQR::ClientError
    ├── ChoiceQR::ValidationError      (400)
    ├── ChoiceQR::AuthenticationError  (401)
    ├── ChoiceQR::ForbiddenError       (403)
    ├── ChoiceQR::NotFoundError        (404)
    └── ChoiceQR::RateLimitError       (429)
└── ChoiceQR::ServerError              (5xx)

Rate limits

The API documents a general 60 req/sec limit, with some endpoints stricter still (booking/order-archive listing, feedback creation, availability/marketplace sync — see the relevant method's docs above). The gem automatically retries a request up to twice with backoff on 429/5xx responses; persistent rate limiting still raises ChoiceQR::RateLimitError. The gem does not otherwise throttle requests client-side — back off in your own code if you're making high-volume calls.

Configuration

client = ChoiceQR::Client.new(
  token: "...",
  default_language: "en", # default: "en"
  timeout: 60,             # read timeout in seconds (default: 30)
  open_timeout: 10,        # connection timeout in seconds (default: 5)
  logger: Logger.new($stdout)
)

Webhooks

ChoiceQR pushes events (menu changes, new orders, booking updates, …) to a Webhook URL you configure when creating your application — there is no API for managing webhook subscriptions, so there's no client.webhooks. ChoiceQR::WebhookEvent parses the payload your endpoint receives:

post "/webhooks/choiceqr" do
  event = ChoiceQR::WebhookEvent.parse(request.body.read)

  case event.type
  when "dish.created", "dish.changed"
    Dish.upsert_from_choiceqr(event.data) # same shape as Dishes#get
  when "order.created"
    Order.create_from_choiceqr(event.data)
  when "section.positionChanged"
    Section.reorder(event.data.items) # array of section ids
  end
end

event.data's shape depends on event.type — usually the same entity schema the matching resource method returns, but *.positionChanged events carry {items: [...ids]} and *.removed events carry just an id. ChoiceQR::WebhookEvent::TYPES lists every documented event type (not enforced — an unrecognized type still parses fine). See Webhooks for the full type → shape mapping.

ChoiceQR does not document a signature or secret for verifying a webhook's authenticity. WebhookEvent parses the payload; it does not, and cannot, confirm the request actually came from ChoiceQR.

Development

bundle install
bundle exec rspec
bundle exec rubocop

License

MIT — see LICENSE.md.