QueueUp ships two pieces:
- Data SDK — a Kotlin Multiplatform API client for the QueueUp platform (merchants, products, checkouts, payments, fulfillment, wallet, vouchers, memberships, and the end-user auth flow). Use it on its own when you build your own UI.
- UI SDK — prebuilt, themeable Compose Multiplatform UI you can drop straight into your app.
The UI SDK is built on the Data SDK. It includes and re-exposes the Data SDK, so adding the UI SDK gives you the prebuilt screens and the full data API from a single dependency — you never add both. Building your own UI instead? Take the Data SDK on its own.
| You want to… | Take | Setup |
|---|---|---|
| Call the QueueUp API and build your own UI | Data SDK | QueueUp.init(…) → a QueueUpClient; every API call hangs off it. |
| Use the SDK's prebuilt UI | UI SDK | Includes the Data SDK, so the data endpoints are available too. Configure an optional theme, then render. |
Packaging differs by platform: Android = two artifacts (add just one — the UI artifact already includes the data one); iOS = one package (contains both).
Declare PINCH's Maven repository (e.g. in settings.gradle(.kts)):
repositories {
google()
mavenCentral()
maven { url = uri("https://maven.pinch.nl/maven") }
}Then add the module(s) you need (e.g. in app/build.gradle(.kts)):
dependencies {
// Data SDK only — you build your own UI:
implementation("eu.queueup:queueup:{x.y.z}")
// …or the UI SDK (includes the Data SDK)
implementation("eu.queueup:queueup-ui:{x.y.z}")
}iOS can't link two independent Kotlin frameworks into one app, so both SDKs are delivered in a single Swift package. Add it once (Xcode → File → Add Package Dependency):
https://github.com/pinchbv/lib-queueup
import QueueUp exposes both the Data and UI APIs — there is nothing extra to add for the UI
SDK on iOS.
Skip this section if you only render the UI SDK's prebuilt screens and don't call the API yourself. The UI SDK includes the Data SDK, so everything below is also available to UI SDK consumers who want to hit the endpoints directly.
The Data SDK lets your app talk to the QueueUp platform. Android consumes it as a Kotlin library;
iOS as an Objective-C / Swift framework (the QueueUp package above).
Initialize the Data SDK once per process, as early as possible (e.g. in your Application.onCreate
on Android or your App initializer on iOS). init returns a QueueUpClient; keep this handle —
all domain APIs hang off it.
init may throw QueueUpInitException if startup fails.
val queueUp = QueueUp.init(
environment = Environment.Production,
campaignId = "your-campaign-id",
logger = { message -> Log.d("QueueUp", message) }, // optional
)MyApp.queueUp = QueueUp.shared.doInit(
environment: .production,
campaignId: "your-campaign-id",
logger: PrintLogger() // optional, conforms to QueueUp.Logger
)Calling init again tears down the previous client and replaces it; discard any handle returned
by an earlier call.
| Value | Use for |
|---|---|
Environment.Acceptance |
Integration / staging against QueueUp's acceptance backend. |
Environment.Production |
Live traffic. |
The SDK manages tokens (storage, refresh, attach to requests) for you — your job is to (a) drive the sign-in flow and (b) react when the SDK signals that authentication is needed.
client.auth.authenticationRequired is a SharedFlow<AuthenticationRequired?> (Swift sees it as
an AsyncSequence). It emits:
null— no action needed (emitted after a successful sign-in or logout).NoSession— no session has ever been established; prompt the user to sign in.SessionExpired— a previously valid session could not be refreshed; prompt the user to sign in again.
It replays its latest value, so late subscribers immediately receive it — you won't miss the signal by subscribing after the SDK has already determined that authentication is required. It also re-emits even when the reason is unchanged, so every request that still needs authentication re-signals — e.g. if the user dismisses your sign-in prompt without signing in and then retries, you'll be prompted again.
Android:
viewModelScope.launch {
client.auth.authenticationRequired
.filterNotNull()
.collect { reason ->
when (reason) {
AuthenticationRequired.NoSession -> showSignIn()
AuthenticationRequired.SessionExpired -> showSignInExpired()
}
}
}iOS:
Task { @MainActor in
for await reason in client.auth.authenticationRequired {
guard let reason else { continue }
switch onEnum(of: reason) {
case .noSession: showSignIn()
case .sessionExpired: showSignInExpired()
}
}
}The SDK supports two sign-in flows; pick whichever fits your product.
Magic link (the default). The host requests a magic-link email, the user clicks the link, your app receives the callback URL via deep / universal link, and you hand it back to the SDK:
client.auth.requestMagicLink(
email = "user@example.com",
callbackUrl = "https://your.app/auth/callback", // must be whitelisted
)
// later, when your deep-link handler receives the callback URL:
client.auth.verifyMagicLink(url = receivedUrl)Authorization code exchange. If the host already has a single-use server-to-server token, exchange it for a session directly:
client.auth.exchangeAuthorizationCode(code = "single-use-code")// local check: do we have stored tokens?
client.auth.hasAuthenticated()
// local check: are the stored tokens still valid?
client.auth.isAuthenticated()
// server round-trip
client.auth.isAuthenticated(checkRemotely = true)
// sends a logout request and clears the local tokens
client.auth.logout()All domain APIs are reached from the QueueUpClient returned by init. Every method is
suspend (Swift sees them as async throws). Errors are typed — see
Error handling.
| Property | What it does |
|---|---|
client.agreements |
Pending consent prompts (partner-driven). |
client.checkouts |
Create and confirm checkouts. |
client.fulfillment |
Available timeslots, the user's orders, their tickets, and the consolidated PDF. |
client.memberships |
The user's memberships within the campaign. |
client.merchants |
List, search, look up merchants in your campaign. |
client.payments |
Look up a payment by ID. |
client.products |
List products by merchant, look one up, fetch a calendar of availability. |
client.vouchers |
Validate and redeem voucher codes. |
client.wallet |
Loyalty token balance, transactions, partner reconciliation. |
val pending = client.agreements.getPendingAgreements(partnerUserToken = "...")
client.agreements.acceptPendingAgreements(
partnerUserToken = "...",
agreementsIds = pending.map { it.id }.toSet(),
)createCheckout reserves capacity (a "checkout") that expires if not confirmed. confirmCheckout
returns a PSP-hosted payment URL — redirect the user there. Both calls are idempotent — see
Idempotency.
val checkout = client.checkouts.createCheckout(
date = Clock.System.now(),
flexibleDate = false,
lines = listOf(Line(productId = "...", quantity = 2)),
externalReference = "cart-123",
)
val confirmed = client.checkouts.confirmCheckout(
checkoutId = checkout.id,
email = "user@example.com",
firstName = "First",
lastName = "Last",
postalCode = "1011AA",
returnUrl = "https://your.app/payment/return",
)val slots = client.fulfillment.getAvailableTimeslots(
productIds = setOf("product-uuid"),
date = LocalDate(2026, 7, 10),
)
val orders = client.fulfillment.getFulfillmentOrders(
limit = 20,
status = FulfillmentStatus.COMPLETED, // optional filter
)
// Single order (includes tickets + presigned PDF URL when ready):
val order = client.fulfillment.getFulfillmentOrderByOrderId(orderId)val memberships = client.memberships.getMemberships()val page = client.merchants.getAll(limit = 50)
val merchant = client.merchants.getById("merchant-uuid")
val nearby = client.merchants.search(
query = "museum",
geo = Geo(latitude = 52.37, longitude = 4.89, distanceMeters = 5_000),
)
// page.pagination.nextToken — pass to the next getAll/search to fetch the next page.val payment = client.payments.getById("payment-id")val products = client.products.getAllProductsByMerchantId("merchant-uuid")
val product = client.products.getById(merchantId = "...", productId = "...")
val calendar = client.products.getProductCalendar(
merchantId = "...",
productId = "...",
from = LocalDate(2026, 7, 1),
to = LocalDate(2026, 7, 14),
)val status = client.vouchers.validateVoucherCode("ABC123")
val redeemed = client.vouchers.redeemVoucher(code = "ABC123")val balance = client.wallet.getLoyaltyTokenBalance()
val txs = client.wallet.getTransactions(limit = 20)
// For partner-counter campaigns:
val reconciled = client.wallet.syncLoyaltyBalanceWithPartner(partnerUserToken = "...")Some endpoints send an Idempotency-Key header so the backend collapses duplicate requests
instead of acting on them twice (e.g., creating duplicate reservations or payment redirects). By
default the SDK manages the key for you — you don't need to do anything.
Idempotency currently applies to
client.checkouts.createCheckoutandclient.checkouts.confirmCheckout. More endpoints may opt in over time.
Default: SDK-managed. Call an idempotent endpoint without an idempotencyConfig and the SDK
takes care of the Idempotency-Key for you, including reusing the same key for an immediate
duplicate call (e.g., a double-tapped button) so the backend collapses it.
Override: caller-managed. Idempotent endpoints accept an idempotencyConfig: IdempotencyConfig?
argument. Supplying one hands key management to you:
// Supply your own key — forwarded verbatim; the SDK will not generate or manage one for this call.
IdempotencyConfig(key = "my-idempotency-key")When you provide a key, dedup, rotation, and any retry semantics become your responsibility on
that call.
All domain methods are declared @Throws(Throwable::class). The SDK throws a sealed
eu.queueup.data.errors.Error for known failure shapes:
| Type | When |
|---|---|
Error.Unauthorized |
401 from the API. Tokens may have expired or are missing. |
Error.ApiProblem |
Non-2xx with an RFC 7807 problem+json body. Inspect .problem. |
Error.ApiError |
Non-2xx without a recognized problem body (e.g. proxied third-party errors). |
Error.NetworkError |
IO / connectivity failure. |
Init failures throw QueueUpInitException instead.
try {
client.checkouts.createCheckout(/* … */)
} catch (e: Error.ApiProblem) {
// e.problem.title / e.problem.detail
} catch (e: Error.Unauthorized) {
// SDK has already emitted on authenticationRequired
} catch (e: Error.NetworkError) {
// retryable
}Built on the Data SDK. The UI SDK includes and re-exposes the Data SDK, so the data endpoints (the
QueueUpClientfrom the Data SDK above) are available from the UI artifact too. Rendering a screen needs only an optional theme; callQueueUp.initonly when you want to interact with the QueueUp network API.
On Android the UI SDK is the eu.queueup:queueup-ui artifact, which depends on the Data SDK
(eu.queueup:queueup); on iOS both live in the single QueueUp package (import QueueUp). It
bundles Compose Multiplatform — Android renders Jetpack Compose, iOS renders the same Compose UI
hosted in a UIViewController.
Configure the theme (colors, typography, component styles) once at startup, before showing any
SDK UI. It applies to all UI the SDK produces. Until configured, a neutral default theme is used;
call updateThemes again to re-theme live.
Supply an optional light and dark theme — the SDK switches between them automatically as the
system theme changes. Omit dark to use light in both modes.
QueueUpUi.updateThemes(
light = QueueUpTheme(
colors = QueueUpColors(/* backgroundPrimary, labelPrimary, tintPrimaryMain, … */),
typography = QueueUpTypography(/* heading1…heading4, body, caption */),
styles = QueueUpStyles(
button = QueueUpButtonStyle(/* shape, verticalPadding, horizontalPadding */),
card = QueueUpCardStyle(/* shape */),
input = QueueUpInputStyle(/* shape */),
),
),
dark = QueueUpTheme(/* … */), // optional
)The same updateThemes API is available on iOS via the framework, with the same named parameters.
QueueUpUi.Loyalty is the single embed point for the whole prebuilt UI — there's nothing else
to mount separately. It internally manages its own navigation between:
- Onboarding — a self-contained pager of exactly three cards, shown once (persisted locally) the first time a user reaches the flow; every later launch goes straight to the landing screen.
- The landing screen — balance, QR-scan-to-redeem, an outings link-out, "My tickets" (once authenticated), and links to transaction history and the general terms and conditions.
- Everything reached from the landing screen — the QR scanner, ticket detail, transaction history, and the terms-acceptance sheet.
The terms-and-conditions link is a good example of how self-contained this is: it only appears once
the SDK confirms (via client.agreements.getPendingAgreements, see Agreements) that
something is actually pending, and hides again immediately once the user accepts — no host-side
wiring needed.
QueueUpUi.Loyalty(
config = myLoyaltyConfig,
partnerUserToken = { currentPartnerUserToken() }, // re-read on every request; may return null
onOutingsClicked = { /* host app navigates to its own outings screen */ },
)LoyaltyViewController(config:partnerUserToken:didRequestShowOutings:) returns a
UIViewController hosting the Compose UI; wrap it in a UIViewControllerRepresentable (or present
it directly).
struct LoyaltyScreen: UIViewControllerRepresentable {
let onOutings: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
LoyaltyViewController(
config: myLoyaltyConfig,
partnerUserToken: { currentPartnerUserToken() },
didRequestShowOutings: onOutings
)
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}One LoyaltyConfig covers the whole flow — re-supply it if the locale changes.
| Field | Covers |
|---|---|
locale |
Locale used for localized content, e.g. "nl-NL". |
general |
Shared error-toast/retry copy reused across screens. |
landing |
The landing screen itself — header, scoreboard, outings, links. |
onboarding |
The one-time onboarding pager — exactly 3 cards + a call-to-action. |
qr |
Text shown in the QR scanner. Every field has a Dutch default. |
tickets |
The "My tickets" screen, including empty/error states. |
transactions |
The transaction history screen, including empty/error states. |
terms |
The general-terms-and-conditions acceptance sheet. |
Each field is its own config type with full KDoc on every property; the shape (trimmed here for readability):
val config = LoyaltyConfig(
locale = "nl-NL",
general = GeneralConfig(/* errorToastTitle, errorToastMessage, errorStateRetryCta, … */),
landing = LandingConfig(/* title, scoreboardTitleNoBalance, outingsCta, linkMyTransactions, … */),
onboarding = OnboardingConfig(
cards = listOf(
OnboardingCard(
title = "Welcome",
description = "Order ahead and skip the line.",
image = ImageSource.remote("https://example.com/card1.png"),
),
// … exactly 3 cards
),
cta = OnboardingCta(label = "Get started"),
),
qr = QrConfig(), // every field defaults — override only what you need
tickets = MyTicketsConfig(/* emptyStateMessage, errorStateMessage, ticketCountTemplate, … */),
transactions = TransactionsConfig(/* title, tableColumnDate, emptyStateMessage, … */),
terms = TermsAndConditionsConfig(title = "Algemene voorwaarden", acceptCta = "Akkoord"),
)Build images with the ImageSource factories: ImageSource.remote(url) (Android + iOS) or
ImageSource.local(painter) (Android). On iOS, build a local image from a UIImage with
ImageSource.companion.fromUIImage(image:).