Connect once. Receive every delivery platform.
RapidHub is an order hub. Your POS integrates with one API and receives orders from Wolt, Uber Eats, Delivery Hero (foodora, Glovo …), Just Eat Takeaway and the built-in Mock sandbox platform, all in one canonical JSON format. State changes, menus and store hours flow back to every platform through the same API.
Key concepts
| Concept | What it is |
|---|---|
| Tenant | Your customer's account in RapidHub (the merchant or chain). Platform connections belong to the tenant. |
| Restaurant | One branch / till location of the tenant. Orders are routed to exactly one restaurant. |
| Restaurant key | The machine credential of a restaurant. Your POS uses it for every REST call and the real-time connection. |
| Connection | One store on one platform (e.g. a Wolt venue), linked to a restaurant. It has an id, a platform, a platformStoreId and capabilities. |
| Canonical order | The platform-independent order JSON your POS receives (schema 2.0). |
| OrderEvent | A real-time message that carries a full canonical order: Received, Updated or Canceled. |
Quickstart
This walk-through uses the built-in Mock platform, so you need no Wolt, Uber Eats or Delivery Hero test store.
- Get a RapidHub accountSign up at hub.rapidpaygroup.com/signup (or ask RapidHub to create a tenant for your customer).
- Create a restaurant and copy its keyDashboard → Restaurants → create a restaurant. The restaurant key is shown once; store it in your POS settings. You can rotate it later; the old key stops working immediately.
- Create a Mock connectionDashboard → Sandbox → pick the restaurant → Create Mock connection. No credentials are needed.
- Connect your POSOpen the real-time connection to
/hubs/ordersand callGET /pos/orders/activeonce to catch up. - Push a menuSend your menu with
POST /pos/catalog/push. The Mock platform validates it like a real platform. - Send a test orderDashboard → Sandbox → Send test order. Your POS receives an
OrderEventof kindReceived. - Drive the orderAccept, mark ready and deliver it with
POST /pos/orders/{id}/state. Each call is listed under Sandbox → Test orders.
Check the key with your first request:
curl https://hub.rapidpaygroup.com/pos/orders/active \
-H "X-Restaurant-Key: $RAPIDHUB_KEY"using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://hub.rapidpaygroup.com") };
http.DefaultRequestHeaders.Add("X-Restaurant-Key", restaurantKey);
var json = await http.GetStringAsync("/pos/orders/active"); // 401 = wrong or inactive key
using var doc = JsonDocument.Parse(json);
foreach (var order in doc.RootElement.EnumerateArray())
Console.WriteLine($"{order.GetProperty("displayCode")} {order.GetProperty("state")}");// Ktor client + kotlinx.serialization
val http = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; explicitNulls = false }) }
defaultRequest {
url("https://hub.rapidpaygroup.com/")
header("X-Restaurant-Key", restaurantKey)
}
}
val res = http.get("pos/orders/active")
check(res.status != HttpStatusCode.Unauthorized) { "Invalid restaurant key" }
val orders = Json.parseToJsonElement(res.bodyAsText()).jsonArrayconst res = await fetch("https://hub.rapidpaygroup.com/pos/orders/active", {
headers: { "X-Restaurant-Key": process.env.RAPIDHUB_KEY },
});
if (res.status === 401) throw new Error("Invalid restaurant key");
const orders = await res.json(); // array of canonical ordersAuthentication
Every POS request is authenticated with the restaurant key. One key identifies one restaurant; it only sees that restaurant's orders.
| Where | How to send the key |
|---|---|
REST /pos/* | X-Restaurant-Key: <key> (preferred) or Authorization: Bearer <key>. If both are sent, X-Restaurant-Key wins. |
SignalR /hubs/orders | Query string ?access_token=<key> or header X-Restaurant-Key. The hub does not read Authorization: Bearer. |
- A key is 48 lowercase hex characters. RapidHub stores only a hash, so it cannot show the key again. Rotating it (dashboard → Restaurants) invalidates the old key at once.
- A missing, unknown or deactivated key returns
401with an empty body. On the hub, the connection is closed right after the handshake. - Every authenticated call updates the restaurant's "last seen" time in the dashboard.
- All traffic is HTTPS / WSS. Treat the key like a password: keep it on the till or your server, never in a customer-facing web page or app.
Catalog and store endpoints work on the platform connections of the tenant that owns the key. A chain with several restaurants under one tenant sees all of the tenant's connections in GET /pos/catalog/connections. Always pass explicit connectionIds for the restaurant you are configuring.
Base URL & environments
- REST base URL
https://hub.rapidpaygroup.com- Real-time hub
wss://hub.rapidpaygroup.com/hubs/orders- OpenAPI
/integration/openapi.json
There is one RapidHub host. Whether an order is a test or a live order depends on the connection, not on the URL:
- Mock connections (
platform: "Mock") are RapidHub's sandbox. They never call a real platform. - Real platform connections are either
Staging(the platform's test environment, e.g. a Wolt test venue) orProduction. Every order carriesenvironment.
Real-time events
RapidHub pushes orders to your POS over an ASP.NET Core SignalR hub. Any SignalR client works, and so does a plain WebSocket (see below).
/hubs/orders?access_token=<restaurant key> or X-Restaurant-Key- Each connection joins its restaurant's group. Every device that connects with the same key receives every event of that restaurant.
- The server sends a single client method,
OrderEvent, with one argument: the envelope below. - The hub has no methods for you to call. State changes go through REST.
The OrderEvent envelope
| Field | Type | Description |
|---|---|---|
eventKind | string | Received, Updated or Canceled. See event kinds. |
platform | string | Platform of the order, e.g. Wolt, Mock. |
platformOrderId | string | The platform's order id. |
orderId | uuid | RapidHub order id. Same as order.id; use it for state calls and de-duplication. |
order | string | The full canonical order as a JSON string. Parse it a second time. |
{
"eventKind": "Received",
"platform": "Mock",
"platformOrderId": "mock-261015123000-1",
"orderId": "3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40",
"order": "{\"schemaVersion\":\"2.0\",\"id\":\"3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40\",\"platform\":\"Mock\", … }"
}
Connect
// dotnet add package Microsoft.AspNetCore.SignalR.Client
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
var hub = new HubConnectionBuilder()
.WithUrl("https://hub.rapidpaygroup.com/hubs/orders", o =>
{
// The hub reads X-Restaurant-Key or ?access_token= (not "Authorization: Bearer").
o.Headers["X-Restaurant-Key"] = restaurantKey;
})
.WithAutomaticReconnect()
.Build();
hub.On<OrderEvent>("OrderEvent", async e =>
{
using var order = JsonDocument.Parse(e.Order); // e.Order is a JSON string
await store.UpsertAsync(e.OrderId, e.EventKind, order.RootElement); // idempotent, keyed by OrderId
});
hub.Reconnected += _ => CatchUpAsync(); // GET /pos/orders/active
hub.Closed += async _ => { await Task.Delay(5000); await hub.StartAsync(); await CatchUpAsync(); };
await hub.StartAsync();
await CatchUpAsync();
public sealed record OrderEvent(string EventKind, string Platform, string PlatformOrderId, Guid OrderId, string Order);// npm i @microsoft/signalr (Node.js, Electron, or a POS web app running on the till)
import * as signalR from "@microsoft/signalr";
const key = process.env.RAPIDHUB_KEY;
const hub = new signalR.HubConnectionBuilder()
.withUrl(`https://hub.rapidpaygroup.com/hubs/orders?access_token=${encodeURIComponent(key)}`, {
transport: signalR.HttpTransportType.WebSockets,
skipNegotiation: true,
})
.withAutomaticReconnect()
.build();
hub.on("OrderEvent", (e) => {
const order = JSON.parse(e.order); // canonical order (JSON string → object)
upsertOrder(e.orderId, e.eventKind, order); // idempotent, keyed by e.orderId
});
hub.onreconnected(() => catchUp()); // GET /pos/orders/active
hub.onclose(() => setTimeout(() => hub.start().then(catchUp), 5000));
await hub.start();
await catchUp();// Plain Ktor WebSocket, no SignalR library (works on every Kotlin Multiplatform target).
private const val RS = '\u001E' // SignalR record separator
private val json = Json { ignoreUnknownKeys = true }
suspend fun listen(http: HttpClient, key: String, onEvent: suspend (OrderEventEnvelope) -> Unit) {
http.webSocket("wss://hub.rapidpaygroup.com/hubs/orders?access_token=$key") {
send("""{"protocol":"json","version":1}$RS""") // 1. handshake
val reply = (incoming.receive() as Frame.Text).readText() // "{}" + RS = accepted
require(!reply.contains("\"error\"")) { "handshake rejected: $reply" }
val pinger = launch { while (isActive) { delay(15_000); send("""{"type":6}$RS""") } }
try {
var buffer = ""
for (frame in incoming) {
buffer += (frame as? Frame.Text)?.readText() ?: continue
while (true) {
val end = buffer.indexOf(RS).takeIf { it >= 0 } ?: break
val raw = buffer.substring(0, end)
buffer = buffer.substring(end + 1)
if (raw.isBlank()) continue
val msg = json.parseToJsonElement(raw).jsonObject
when (msg["type"]?.jsonPrimitive?.int) {
1 -> if (msg["target"]?.jsonPrimitive?.content == "OrderEvent") // invocation
onEvent(json.decodeFromJsonElement(msg["arguments"]!!.jsonArray[0]))
7 -> return@webSocket // close
else -> Unit // 6 = ping
}
}
}
} finally {
pinger.cancel()
}
}
}
@Serializable
data class OrderEventEnvelope(
val eventKind: String,
val platform: String,
val platformOrderId: String,
val orderId: String,
val order: String, // canonical order as a JSON string
)Call GET /pos/orders/active after every successful (re)connect. Events sent while your POS was offline are not replayed on the socket; see delivery guarantees.
Without a SignalR library
SignalR's JSON protocol is easy to speak over a raw WebSocket. This is how RapidPay's own Kotlin Multiplatform POS client connects.
- Open
wss://hub.rapidpaygroup.com/hubs/orders?access_token=<restaurant key>. You can skip the HTTP negotiate step; the server accepts a direct WebSocket. - Handshake: send
{"protocol":"json","version":1}followed by the record separator0x1E. The server answers{}+0x1E, or{"error":"…"}+0x1E. - Messages are JSON objects, each terminated by
0x1E. One WebSocket frame can contain several messages, and one message can span frames: buffer and split on0x1E. - Types:
1= invocation (target: "OrderEvent",arguments: [envelope]),6= ping,7= close (optionalerror). Ignore other types. - Keep-alive: send
{"type":6}+0x1Eevery 15 seconds. The server also pings every 15 seconds and drops clients it has not heard from for 30 seconds. - Reconnect with backoff, for example
min(30 s, 2^attempt s), reset after the connection was stable for a few seconds, then catch up.
→ {"protocol":"json","version":1}␞
← {}␞
← {"type":6}␞
← {"type":1,"target":"OrderEvent","arguments":[{"eventKind":"Received","platform":"Mock","platformOrderId":"mock-261015123000-1","orderId":"3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40","order":"{\"schemaVersion\":\"2.0\",\"id\":\"3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40\", …}"}]}␞
→ {"type":6}␞
Event kinds
Every event carries the complete, current order. Replace your stored copy; never apply events as diffs.
| eventKind | When | What to do |
|---|---|---|
Received | RapidHub stored a new order for your restaurant. Usually state: "Received"; already Accepted when the platform accepted it itself (acceptance: "AutoByPlatform"). | Show it, print a ticket, start the accept timer (acceptDeadline). |
Updated | Anything changed: your own state change (yes, you get an echo), a platform status change (courier picked up, delivered), a customer edit, an auto-accept. | Replace the order. Do not re-print unless items changed. |
Canceled | The platform cancelled or rejected the order (customer cancel, accept deadline missed, platform auto-reject). state is Canceled or Rejected, rejectReason may say why. | Stop preparing, alert the staff, void any open receipt. |
Delivery guarantees
RapidHub writes every order change to a transactional outbox in the same database transaction as the order itself, then delivers it to your restaurant's SignalR group.
- At-least-once. The same event can arrive more than once (several devices, retries, reconnects). De-duplicate by
orderIdand treat every event as "this is the order now". - Offline restaurant. If no device of the restaurant is connected, delivery is retried with exponential backoff (2, 4, 8 … 256 seconds, 8 attempts, about 8½ minutes) and then parked. Parked events are not pushed again automatically, so poll
GET /pos/orders/activeafter every reconnect, and every 30–60 seconds while the socket is down. - Partially online. When at least one device is connected the event counts as delivered. A second till that was offline at that moment must catch up through the REST call too.
- Ordering. Events are not sequence-numbered. Never move an order backwards: keep the most advanced state and let
Canceled/Rejectedfrom the platform win, the same merge rule RapidHub uses. - Missed platform webhooks. RapidHub reconciles active orders with each platform every 2 minutes, so an order whose webhook was lost still reaches you, as
ReceivedorUpdated. - Same id forever. A re-fetched or updated platform order keeps its RapidHub
id.
If an order you hold as open is missing from GET /pos/orders/active, it reached a terminal state (Delivered, Rejected or Canceled) while you were offline. RapidHub has no endpoint to read a single finished order yet. Mark the order "closed on the platform" and ask the staff to check it, rather than guessing whether it was delivered or cancelled.
Active orders (REST)
/pos/orders/activeX-Restaurant-KeyReturns every order of the restaurant that is not in a terminal state, oldest first, as a plain JSON array of canonical orders. There is no paging; the list holds what is currently open.
curl https://hub.rapidpaygroup.com/pos/orders/active \
-H "X-Restaurant-Key: $RAPIDHUB_KEY"async Task CatchUpAsync()
{
var json = await http.GetStringAsync("/pos/orders/active");
using var doc = JsonDocument.Parse(json);
var open = new HashSet<Guid>();
foreach (var order in doc.RootElement.EnumerateArray())
{
var id = order.GetProperty("id").GetGuid();
open.Add(id);
await store.UpsertAsync(id, "Updated", order);
}
await store.MarkClosedOnPlatformAsync(exceptIds: open); // see the warning above
}suspend fun catchUp() {
val body = http.get("pos/orders/active").bodyAsText()
val orders = json.parseToJsonElement(body).jsonArray
val open = orders.map { it.jsonObject["id"]!!.jsonPrimitive.content }.toSet()
orders.forEach { store.upsert(it.jsonObject) }
store.markClosedOnPlatform(except = open)
}async function catchUp() {
const res = await fetch(`${BASE}/pos/orders/active`, { headers: { "X-Restaurant-Key": key } });
const orders = await res.json();
const open = new Set(orders.map((o) => o.id));
orders.forEach((o) => upsertOrder(o.id, "Updated", o));
markClosedOnPlatform({ except: open });
}[
{
"schemaVersion": "2.0",
"id": "3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40",
"platform": "Mock",
"platformOrderId": "mock-261015123000-1",
"displayCode": "TEST-1",
"state": "Received",
"channel": "Delivery",
"…": "see the full examples below"
}
]
Canonical order
Every platform's order is normalized into one model. Your POS parses one format and never branches on the platform.
JSON conventions
- camelCase property names; enums are strings (
"Delivery", never1). - Null values are omitted. A missing property means "unknown / not provided". Give every optional field a default.
- Money is an integer in minor units (cents):
{"amountMinor": 890, "currency": "EUR"}is €8.90. Amounts inamountsare plain integers inamounts.currency. - Timestamps are ISO 8601 with an offset, usually UTC:
"2026-10-15T12:30:00+00:00". - Ignore unknown properties and accept unknown enum values. New, optional fields are added without notice (see versioning).
- Prices are gross (VAT included) as the customer paid them, before discounts.
Order fields
| Field | Type | Description |
|---|---|---|
schemaVersion | string | Order schema version, currently "2.0". |
idkey | uuid | RapidHub order id. Stable for the life of the order. Use it in POST /pos/orders/{id}/state. |
platform | enum | PlatformKey: Wolt, UberEats, DeliveryHero, JustEatTakeaway, Glovo, Mock … |
platformOrderId | string | The platform's internal order id. Unique per platform. |
displayCode | string? | Short code the customer and courier see (e.g. "8LAB2Z"). Print it large on the ticket. |
environment | enum | Staging or Production (of the connection). |
tenantRef | string | Tenant reference. |
store | object | platformStoreId (store id on the platform) and restaurantId (uuid of your restaurant). |
channel | enum | Delivery, Pickup or DineIn. |
fulfillment | object | courierType (Platform · Self · Marketplace), promisedFor (when the customer expects the order, or the pickup time; for pre-orders the scheduled time), preparationDeadline, deliveryAddress. |
fulfillment.deliveryAddress | object? | street, houseNumber, zip, city, lat, lng, notes. All optional. Present for delivery orders, most useful with courierType: "Self". |
state | enum | Canonical state, see the state machine. |
platformState | string? | Raw platform status (audit only, do not branch on it). |
timestamps | object | placedAt (customer placed it), receivedByHubAt, acceptedAt (set on the first transition to Accepted). |
customer | object? | name, phone (often a platform proxy number), note (order note for the kitchen / courier). |
items | array | Order lines, see items & modifiers. |
amounts | object | Totals in minor units, see amounts. |
payment | object | status, method, cashToCollect. |
discounts v2 | array? | Discount breakdown; the sum equals amounts.discounts. Missing = the platform gives no breakdown. |
fees v2 | array? | Fee breakdown (delivery, service, small order …). |
timing v2 | enum | Instant or Scheduled (pre-order; prepare for fulfillment.promisedFor). |
acceptance v2 | enum | ByVenue (you must accept) or AutoByPlatform (the platform accepts; show no accept button). |
acceptDeadline | datetime? | Accept before this time or the platform cancels the order. Missing = no known deadline. |
rejectReason | string? | Reason text of a rejection or cancellation. |
platformExtras v2 | object? | String-to-string map of display-only details: courierName, cashGiven (cash the customer hands over, minor units), venueName, deliveryStatus, noContactDelivery, companyName, companyTaxId, loyaltyCard, prepTimeSeconds, mock … Show them; never branch business logic on them (exception: mock, see sandbox). |
signatureVerified | boolean | The platform webhook signature was verified. |
notificationOnly | boolean | Internal; always false for orders delivered to the POS. |
rawPayload | string? | The platform's original order JSON, for support and debugging. Do not parse it: its format belongs to the platform and changes without notice. It can be large and contains personal data; do not log it. |
Items & modifiers
| Field | Type | Description |
|---|---|---|
items[].lineId | string | Line id, unique within the order. Referenced by item discounts. |
items[].ref | object | Product reference for mapping to your POS: sku (your SKU from the menu push), plu, barcode, platformItemId. Map by sku; fall back to the name and flag the line if the SKU is unknown. |
items[].name | string | Name as the customer saw it. |
items[].quantity | int | Quantity. |
items[].unitPrice | Money | Price of one unit without modifiers, before discounts. |
items[].modifiers | array | Selected options. Always present (may be empty). |
items[].note | string? | Line note ("well done"). |
items[].taxRatePercent v2 | number? | VAT rate the platform reported. Missing = use your own rate for the SKU. |
items[].deposit v2 | object? | Deposit (e.g. German Pfand) per unit: unitAmount (Money) and taxRatePercent. Not included in unitPrice; print it as its own receipt line. |
items[].discount v2 | Money? | Total discount on this line (all units, modifiers included). |
items[].lineTotal v2 | Money? | The platform's line total: (unitPrice + Σ modifier price × qty) × quantity, before discounts, without deposits. Use it to check your own calculation. |
modifiers[].name | string | Option name. Removals ("no onions") arrive as normal modifiers priced 0, typically named Ohne … / No …. |
modifiers[].quantity | int | Quantity of this option per unit of the item. |
modifiers[].price | Money | Price of one unit of the option. |
modifiers[].groupName | string? | Modifier group, or the combo step ("Drink"). |
modifiers[].sku v2 | string? | The option's SKU from your menu push. For combo steps this is the product SKU. |
modifiers[].taxRatePercent v2 | number? | VAT rate of the option, if reported. |
modifiers[].deposit v2 | object? | Deposit of the option (e.g. the drink inside a combo), per unit. |
Amounts & payment
| Field | Type | Description |
|---|---|---|
amounts.currency | string | ISO 4217 code for every amount of the order. |
amounts.itemsSubtotal | int64 | Sum of the lines (with modifiers, before discounts, without deposits). |
amounts.deliveryFee | int64 | Delivery fee charged to the customer (0 for pickup). |
amounts.discounts | int64 | Total of all discounts, as a positive number. |
amounts.tip | int64 | Tip. |
amounts.tax | int64 | VAT included in the order, as reported by the platform. 0 can mean "not reported" (e.g. Wolt); compute VAT from the line rates. |
amounts.total | int64 | The order total as reported by the platform. |
payment.status | enum | PaidOnline, CashOnDelivery, CardOnDelivery or Unknown. Informational: fiscal handling happens in your POS. |
payment.method | string? | Platform payment method name (free text). |
payment.cashToCollect v2 | int64? | Cash your own courier or counter must collect (self-delivery / pickup with cash). With a platform courier the courier collects cash, so the order counts as PaidOnline for you. |
amounts.total containstotal is the platform's own figure, and platforms define it differently. On the Mock platform it is itemsSubtotal + deposits + Σ fees − Σ discounts + tip. Wolt reports the basket total after discounts, without platform fees and tip. Build receipts from items, discounts, fees and tip, and use total as a cross-check and as the amount to collect.
Discounts & fees
| Field | Type | Description |
|---|---|---|
discounts[].scope | enum | Item (one line, see lineId), Basket or Delivery. |
discounts[].amount | Money | Positive amount subtracted from the total. |
discounts[].fundedBy | enum | Who pays for it: Venue, Platform, Shared or Unknown. Only the venue-funded part reduces your revenue. |
discounts[].lineId | string? | For scope: Item: the discounted line. |
discounts[].name | string? | Campaign / voucher name. |
fees[].type | string | delivery, service, small_order, priority, delivery_discount (Wolt) or other. Treat unknown values as other. |
fees[].amount | Money | Fee amount. |
fees[].venueRevenue | boolean? | Whether the fee is your revenue (e.g. your own delivery fee). Missing = unknown; platform fees usually are not. |
fees[].taxRatePercent | number? | VAT rate of the fee, if reported. |
fees[].platformType | string? | Raw platform fee type (audit). |
Enumerations
All enums are serialized as strings. New values can be added, so handle unknown values gracefully.
| Enum | Values |
|---|---|
state | Received · Accepted · Preparing · Ready · Dispatched · Delivered · Rejected · Canceled |
platform | Wolt · UberEats · DeliveryHero (foodora, Glovo on the DH API …) · JustEatTakeaway (Just Eat, Lieferando, Takeaway.com …) · Glovo (legacy Glovo API) · Deliveroo (reserved) · Mock (sandbox) |
environment | Staging · Production |
channel | Delivery · Pickup · DineIn |
fulfillment.courierType | Platform (platform courier) · Self (your own courier delivers) · Marketplace (the venue hands over / delivers, e.g. pickup orders) |
payment.status | Unknown · PaidOnline · CashOnDelivery · CardOnDelivery |
timing | Instant · Scheduled |
acceptance | ByVenue · AutoByPlatform |
discounts[].scope | Basket · Item · Delivery |
discounts[].fundedBy | Unknown · Venue · Platform · Shared |
rejectCode (request) | Other · ItemsUnavailable · TooBusy · Closing · Closed |
eventKind | Received · Updated · Canceled |
Example orders
Real orders produced by the Mock platform from the sample menu. Amounts reconcile exactly. Download them for your tests: delivery-platform.json, self-cash.json, pickup-discount.json, combo-deposit.json, scheduled-auto.json.
{
"schemaVersion": "2.0",
"id": "3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40",
"platform": "Mock",
"platformOrderId": "mock-261015123000-1",
"displayCode": "TEST-1",
"environment": "Staging",
"tenantRef": "acme-pos:tenant:1042",
"store": {
"platformStoreId": "mock-3f9a1c2e",
"restaurantId": "b5e1f0d2-8c3a-4e7b-9f1d-6a2c4e8b0d13"
},
"channel": "Delivery",
"fulfillment": {
"courierType": "Platform",
"promisedFor": "2026-10-15T13:00:00+00:00",
"deliveryAddress": {
"street": "Teststraße",
"houseNumber": "1",
"zip": "10115",
"city": "Berlin",
"lat": 52.5321,
"lng": 13.3849,
"notes": "2nd floor, ring \"Sandbox\""
}
},
"state": "Received",
"platformState": "mock:created",
"timestamps": {
"placedAt": "2026-10-15T12:30:00+00:00",
"receivedByHubAt": "2026-10-15T12:30:00+00:00"
},
"customer": {
"name": "Test Customer",
"phone": "+49 30 1234567"
},
"items": [
{
"lineId": "1",
"ref": {
"sku": "BURGER",
"plu": "BURGER",
"platformItemId": "mock-item-BURGER"
},
"name": "Cheeseburger",
"quantity": 1,
"unitPrice": {
"amountMinor": 890,
"currency": "EUR"
},
"modifiers": [
{
"name": "Extra cheese",
"quantity": 1,
"price": {
"amountMinor": 100,
"currency": "EUR"
},
"groupName": "Extras",
"sku": "X-CHEESE",
"taxRatePercent": 7
},
{
"name": "Ohne Onions",
"quantity": 1,
"price": {
"amountMinor": 0,
"currency": "EUR"
},
"groupName": "Extras",
"sku": "NO-X-ONION",
"taxRatePercent": 7
}
],
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 990,
"currency": "EUR"
}
},
{
"lineId": "2",
"ref": {
"sku": "FRIES",
"plu": "FRIES",
"platformItemId": "mock-item-FRIES"
},
"name": "Fries",
"quantity": 1,
"unitPrice": {
"amountMinor": 350,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 350,
"currency": "EUR"
}
}
],
"amounts": {
"currency": "EUR",
"itemsSubtotal": 1340,
"deliveryFee": 299,
"discounts": 0,
"tip": 200,
"tax": 88,
"total": 1839
},
"payment": {
"status": "PaidOnline",
"method": "mock_online"
},
"discounts": [],
"fees": [
{
"type": "delivery",
"amount": {
"amountMinor": 299,
"currency": "EUR"
},
"venueRevenue": false,
"taxRatePercent": 19,
"platformType": "mock_delivery_fee"
}
],
"timing": "Instant",
"acceptance": "ByVenue",
"platformExtras": {
"mock": "1",
"venueName": "RapidHub Sandbox",
"courierName": "Test Courier"
},
"signatureVerified": true,
"notificationOnly": false,
"acceptDeadline": "2026-10-15T12:33:00+00:00"
}{
"schemaVersion": "2.0",
"id": "8a41f6c3-2d9e-4b75-a0c8-1e7f3b9d5c62",
"platform": "Mock",
"platformOrderId": "mock-261015123000-2",
"displayCode": "TEST-2",
"environment": "Staging",
"tenantRef": "acme-pos:tenant:1042",
"store": {
"platformStoreId": "mock-3f9a1c2e",
"restaurantId": "b5e1f0d2-8c3a-4e7b-9f1d-6a2c4e8b0d13"
},
"channel": "Delivery",
"fulfillment": {
"courierType": "Self",
"promisedFor": "2026-10-15T13:00:00+00:00",
"deliveryAddress": {
"street": "Teststraße",
"houseNumber": "1",
"zip": "10115",
"city": "Berlin",
"lat": 52.5321,
"lng": 13.3849,
"notes": "2nd floor, ring \"Sandbox\""
}
},
"state": "Received",
"platformState": "mock:created",
"timestamps": {
"placedAt": "2026-10-15T12:30:00+00:00",
"receivedByHubAt": "2026-10-15T12:30:00+00:00"
},
"customer": {
"name": "Test Customer",
"phone": "+49 30 1234567",
"note": "Please ring twice. (test note)"
},
"items": [
{
"lineId": "1",
"ref": {
"sku": "BURGER",
"plu": "BURGER",
"platformItemId": "mock-item-BURGER"
},
"name": "Cheeseburger",
"quantity": 1,
"unitPrice": {
"amountMinor": 890,
"currency": "EUR"
},
"modifiers": [],
"note": "Extra crispy please (test item note)",
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 890,
"currency": "EUR"
}
},
{
"lineId": "2",
"ref": {
"sku": "COLA",
"plu": "COLA",
"platformItemId": "mock-item-COLA"
},
"name": "Cola 0.33 l",
"quantity": 1,
"unitPrice": {
"amountMinor": 250,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 19,
"deposit": {
"unitAmount": {
"amountMinor": 25,
"currency": "EUR"
},
"taxRatePercent": 19
},
"lineTotal": {
"amountMinor": 250,
"currency": "EUR"
}
}
],
"amounts": {
"currency": "EUR",
"itemsSubtotal": 1140,
"deliveryFee": 299,
"discounts": 0,
"tip": 0,
"tax": 102,
"total": 1464
},
"payment": {
"status": "CashOnDelivery",
"method": "cash",
"cashToCollect": 1464
},
"discounts": [],
"fees": [
{
"type": "delivery",
"amount": {
"amountMinor": 299,
"currency": "EUR"
},
"venueRevenue": true,
"taxRatePercent": 19,
"platformType": "mock_delivery_fee"
}
],
"timing": "Instant",
"acceptance": "ByVenue",
"platformExtras": {
"mock": "1",
"venueName": "RapidHub Sandbox",
"cashGiven": "2000"
},
"signatureVerified": true,
"notificationOnly": false,
"acceptDeadline": "2026-10-15T12:33:00+00:00"
}{
"schemaVersion": "2.0",
"id": "e2b7c9a1-6f3d-4c08-b5e4-9d1a7c3f8e25",
"platform": "Mock",
"platformOrderId": "mock-261015123000-3",
"displayCode": "TEST-3",
"environment": "Staging",
"tenantRef": "acme-pos:tenant:1042",
"store": {
"platformStoreId": "mock-3f9a1c2e",
"restaurantId": "b5e1f0d2-8c3a-4e7b-9f1d-6a2c4e8b0d13"
},
"channel": "Pickup",
"fulfillment": {
"courierType": "Marketplace",
"promisedFor": "2026-10-15T12:50:00+00:00"
},
"state": "Received",
"platformState": "mock:created",
"timestamps": {
"placedAt": "2026-10-15T12:30:00+00:00",
"receivedByHubAt": "2026-10-15T12:30:00+00:00"
},
"customer": {
"name": "Test Customer",
"phone": "+49 30 1234567"
},
"items": [
{
"lineId": "1",
"ref": {
"sku": "BURGER",
"plu": "BURGER",
"platformItemId": "mock-item-BURGER"
},
"name": "Cheeseburger",
"quantity": 2,
"unitPrice": {
"amountMinor": 890,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 7,
"discount": {
"amountMinor": 178,
"currency": "EUR"
},
"lineTotal": {
"amountMinor": 1780,
"currency": "EUR"
}
},
{
"lineId": "2",
"ref": {
"sku": "FRIES",
"plu": "FRIES",
"platformItemId": "mock-item-FRIES"
},
"name": "Fries",
"quantity": 1,
"unitPrice": {
"amountMinor": 350,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 350,
"currency": "EUR"
}
}
],
"amounts": {
"currency": "EUR",
"itemsSubtotal": 2130,
"deliveryFee": 0,
"discounts": 378,
"tip": 0,
"tax": 115,
"total": 1752
},
"payment": {
"status": "PaidOnline",
"method": "mock_online"
},
"discounts": [
{
"scope": "Item",
"amount": {
"amountMinor": 178,
"currency": "EUR"
},
"fundedBy": "Venue",
"lineId": "1",
"name": "10% item discount (test)"
},
{
"scope": "Basket",
"amount": {
"amountMinor": 200,
"currency": "EUR"
},
"fundedBy": "Shared",
"name": "Test voucher"
}
],
"fees": [],
"timing": "Instant",
"acceptance": "ByVenue",
"platformExtras": {
"mock": "1",
"venueName": "RapidHub Sandbox"
},
"signatureVerified": true,
"notificationOnly": false,
"acceptDeadline": "2026-10-15T12:33:00+00:00"
}{
"schemaVersion": "2.0",
"id": "5f8e1d3b-9c2a-47e6-8b0f-3d6a2e9c1b74",
"platform": "Mock",
"platformOrderId": "mock-261015123000-4",
"displayCode": "TEST-4",
"environment": "Staging",
"tenantRef": "acme-pos:tenant:1042",
"store": {
"platformStoreId": "mock-3f9a1c2e",
"restaurantId": "b5e1f0d2-8c3a-4e7b-9f1d-6a2c4e8b0d13"
},
"channel": "Delivery",
"fulfillment": {
"courierType": "Platform",
"promisedFor": "2026-10-15T13:00:00+00:00",
"deliveryAddress": {
"street": "Teststraße",
"houseNumber": "1",
"zip": "10115",
"city": "Berlin",
"lat": 52.5321,
"lng": 13.3849,
"notes": "2nd floor, ring \"Sandbox\""
}
},
"state": "Received",
"platformState": "mock:created",
"timestamps": {
"placedAt": "2026-10-15T12:30:00+00:00",
"receivedByHubAt": "2026-10-15T12:30:00+00:00"
},
"customer": {
"name": "Test Customer",
"phone": "+49 30 1234567"
},
"items": [
{
"lineId": "1",
"ref": {
"sku": "COLA",
"plu": "COLA",
"platformItemId": "mock-item-COLA"
},
"name": "Cola 0.33 l",
"quantity": 1,
"unitPrice": {
"amountMinor": 250,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 19,
"deposit": {
"unitAmount": {
"amountMinor": 25,
"currency": "EUR"
},
"taxRatePercent": 19
},
"lineTotal": {
"amountMinor": 250,
"currency": "EUR"
}
},
{
"lineId": "2",
"ref": {
"sku": "MENU1",
"plu": "MENU1",
"platformItemId": "mock-item-MENU1"
},
"name": "Burger Menu",
"quantity": 1,
"unitPrice": {
"amountMinor": 1190,
"currency": "EUR"
},
"modifiers": [
{
"name": "Cheeseburger",
"quantity": 1,
"price": {
"amountMinor": 0,
"currency": "EUR"
},
"groupName": "Main",
"sku": "BURGER",
"taxRatePercent": 7
},
{
"name": "Fries",
"quantity": 1,
"price": {
"amountMinor": 0,
"currency": "EUR"
},
"groupName": "Side",
"sku": "FRIES",
"taxRatePercent": 7
},
{
"name": "Cola 0.33 l",
"quantity": 1,
"price": {
"amountMinor": 0,
"currency": "EUR"
},
"groupName": "Drink",
"sku": "COLA",
"taxRatePercent": 19,
"deposit": {
"unitAmount": {
"amountMinor": 25,
"currency": "EUR"
},
"taxRatePercent": 19
}
}
],
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 1190,
"currency": "EUR"
}
}
],
"amounts": {
"currency": "EUR",
"itemsSubtotal": 1440,
"deliveryFee": 299,
"discounts": 299,
"tip": 0,
"tax": 126,
"total": 1490
},
"payment": {
"status": "PaidOnline",
"method": "mock_online"
},
"discounts": [
{
"scope": "Delivery",
"amount": {
"amountMinor": 299,
"currency": "EUR"
},
"fundedBy": "Platform",
"name": "Free delivery (test)"
}
],
"fees": [
{
"type": "delivery",
"amount": {
"amountMinor": 299,
"currency": "EUR"
},
"venueRevenue": false,
"taxRatePercent": 19,
"platformType": "mock_delivery_fee"
}
],
"timing": "Instant",
"acceptance": "ByVenue",
"platformExtras": {
"mock": "1",
"venueName": "RapidHub Sandbox",
"courierName": "Test Courier"
},
"signatureVerified": true,
"notificationOnly": false,
"acceptDeadline": "2026-10-15T12:33:00+00:00"
}{
"schemaVersion": "2.0",
"id": "0b6c4e2a-7d1f-4a93-9e5b-8c2f1a6d3e07",
"platform": "Mock",
"platformOrderId": "mock-261015123000-5",
"displayCode": "TEST-5",
"environment": "Staging",
"tenantRef": "acme-pos:tenant:1042",
"store": {
"platformStoreId": "mock-3f9a1c2e",
"restaurantId": "b5e1f0d2-8c3a-4e7b-9f1d-6a2c4e8b0d13"
},
"channel": "Pickup",
"fulfillment": {
"courierType": "Marketplace",
"promisedFor": "2026-10-15T15:30:00+00:00"
},
"state": "Accepted",
"platformState": "mock:auto_accepted",
"timestamps": {
"placedAt": "2026-10-15T12:30:00+00:00",
"receivedByHubAt": "2026-10-15T12:30:00+00:00",
"acceptedAt": "2026-10-15T12:30:00+00:00"
},
"customer": {
"name": "Test Customer",
"phone": "+49 30 1234567"
},
"items": [
{
"lineId": "1",
"ref": {
"sku": "FRIES",
"plu": "FRIES",
"platformItemId": "mock-item-FRIES"
},
"name": "Fries",
"quantity": 1,
"unitPrice": {
"amountMinor": 350,
"currency": "EUR"
},
"modifiers": [],
"taxRatePercent": 7,
"lineTotal": {
"amountMinor": 350,
"currency": "EUR"
}
}
],
"amounts": {
"currency": "EUR",
"itemsSubtotal": 350,
"deliveryFee": 0,
"discounts": 0,
"tip": 0,
"tax": 23,
"total": 350
},
"payment": {
"status": "PaidOnline",
"method": "mock_online"
},
"discounts": [],
"fees": [],
"timing": "Scheduled",
"acceptance": "AutoByPlatform",
"platformExtras": {
"mock": "1",
"venueName": "RapidHub Sandbox"
},
"signatureVerified": true,
"notificationOnly": false
}| Example | What to look at |
|---|---|
| Delivery · platform courier | Modifiers with a paid extra and a removal (Ohne Onions, price 0); delivery fee in fees with venueRevenue: false; tip; acceptDeadline 3 minutes after placement. |
| Own courier · cash | courierType: "Self", payment.status: "CashOnDelivery", cashToCollect = total; platformExtras.cashGiven for change; deposit on the cola; item note and customer note. |
| Pickup · discounts | channel: "Pickup", no fees; quantity 2; an Item discount (venue-funded, lineId: "1", also in items[0].discount) and a Basket voucher (shared). |
| Combo · deposit · free delivery | A combo is one line whose modifiers are the steps (groupName = step, sku = product SKU, price = surcharge); the drink step carries its own deposit; free delivery as a Delivery discount funded by the platform. |
| Pre-order · auto-accepted | timing: "Scheduled" with promisedFor 3 hours ahead; acceptance: "AutoByPlatform", arrives as Accepted with acceptedAt and no acceptDeadline. |
The Mock platform's amounts satisfy:
itemsSubtotal = Σ items[].lineTotal (unit + modifiers) × quantity, before discounts
discounts = Σ discounts[].amount
total = itemsSubtotal + deposits + Σ fees[] − discounts + tip
deposits = Σ (items[].deposit + Σ modifiers[].deposit × modifier qty) × item quantity
tax = VAT included in the lines and deposits (each at its own rate)
State machine
All platforms share one lifecycle. RapidHub translates each transition into the platform's own call (accept, ready, picked up …) and rejects transitions the machine does not allow.
| From | Allowed targets |
|---|---|
Received | Accepted · Rejected · Canceled |
Accepted | Preparing · Ready · Dispatched · Delivered · Canceled |
Preparing | Ready · Dispatched · Delivered · Canceled |
Ready | Dispatched · Delivered · Canceled |
Dispatched | Delivered · Canceled |
Delivered · Rejected · Canceled | none (terminal) |
Dispatchedis for delivery orders. ForPickup/DineIngo fromReadystraight toDelivered(handed over).- Platform couriers: the platform reports pickup and delivery itself; you receive them as
Updatedevents. SendingDispatched/Deliveredfor a platform-courier order is accepted and recorded in RapidHub, but not forwarded. Preparingis optional and not forwarded to most platforms.
Change an order's state
/pos/orders/{orderId}/stateX-Restaurant-KeyRapidHub checks the transition, calls the platform, and only then stores the new state and sends an Updated event. If the platform call fails, nothing changes.
| Body field | Type | Description |
|---|---|---|
staterequired | string | Target state (case-insensitive): Accepted, Rejected, Preparing, Ready, Dispatched, Delivered, Canceled. |
prepTimeMinutesoptional | int | With Accepted: minutes until the food is ready (0–240). Omit to keep the platform's estimate. Needs capabilities.supportsPrepTime. |
rejectCodeoptional | string | With Rejected: Other, ItemsUnavailable, TooBusy, Closing, Closed. Needs capabilities.supportsRejectCodes. |
reasonoptional | string | With Rejected / Canceled: free text. Wolt shows it to the customer. |
curl -X POST https://hub.rapidpaygroup.com/pos/orders/3c9d2b7e-41a8-4f0b-8e6d-5a1c9f2e7b40/state \
-H "X-Restaurant-Key: $RAPIDHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"state":"Accepted","prepTimeMinutes":20}'using System.Net.Http.Json;
var res = await http.PostAsJsonAsync($"/pos/orders/{orderId}/state",
new { state = "Accepted", prepTimeMinutes = 20 });
switch ((int)res.StatusCode)
{
case 200: // {"status":"ok","state":"Accepted"}
break;
case 409: // invalid_transition | state_mismatch
await CatchUpAsync(); // re-read the order, then decide
break;
case 502: // platform call failed, nothing changed: retry with backoff
ScheduleRetry(orderId, "Accepted");
break;
default:
res.EnsureSuccessStatusCode();
break;
}@Serializable
data class StateRequest(
val state: String,
val prepTimeMinutes: Int? = null,
val rejectCode: String? = null,
val reason: String? = null,
)
val res = http.post("pos/orders/$orderId/state") {
contentType(ContentType.Application.Json)
setBody(StateRequest(state = "Accepted", prepTimeMinutes = 20))
}
when (res.status.value) {
200 -> Unit
409 -> catchUp() // invalid_transition / state_mismatch
502 -> scheduleRetry(orderId) // platform unreachable, nothing changed
else -> error("set state failed: ${res.status} ${res.bodyAsText()}")
}const res = await fetch(`${BASE}/pos/orders/${orderId}/state`, {
method: "POST",
headers: { "X-Restaurant-Key": key, "Content-Type": "application/json" },
body: JSON.stringify({ state: "Accepted", prepTimeMinutes: 20 }),
});
if (res.status === 409) {
const { error, detail, upstream } = await res.json(); // invalid_transition | state_mismatch
await catchUp();
} else if (res.status === 502) {
scheduleRetry(orderId); // nothing changed on the platform
} else if (!res.ok) {
throw new Error(`state change failed: ${res.status}`);
}{
"state": "Rejected",
"rejectCode": "ItemsUnavailable",
"reason": "Sorry, the cheeseburger is sold out."
}
{
"status": "ok",
"state": "Accepted"
}
Accepting
- Watch
acceptDeadline. Show a countdown. If you do not accept in time the platform cancels the order and you receive aCanceledevent. Typical windows: Wolt 3 minutes (pre-orders 15 minutes), Uber Eats about 11 minutes after RapidHub received the order, Delivery Hero as set by the platform per order. acceptance: "AutoByPlatform": the platform accepted the order already (it usually arrives asAccepted). Show no accept / reject buttons; just prepare it.- Auto-accept in RapidHub. A connection can be set in the dashboard to accept orders immediately, or shortly before the deadline as a safety net. You then receive an
Updatedevent withstate: "Accepted". - Pre-orders (
timing: "Scheduled"): accept them like any order; prepare forfulfillment.promisedFor. On Wolt, accepting a pre-order confirms it. - Preparation time: send
prepTimeMinuteswithAcceptedwhencapabilities.supportsPrepTimeis true. Platforms may clamp it (Wolt: at most 25 minutes later than its own estimate).
Rejecting & cancelling
Rejectedis only allowed fromReceived. After accepting, useCanceled.- Several platforms have no API for a venue to cancel an accepted order (Wolt, Delivery Hero). The cancel is then applied in RapidHub only; the restaurant must also cancel through the platform's merchant tools or support.
| rejectCode | Wolt | Delivery Hero | Uber Eats |
|---|---|---|---|
ItemsUnavailable | ITEMS_UNAVAILABLE | ITEM_UNAVAILABLE | Codes not supported: always STORE_UNAVAILABLE |
TooBusy | GENERIC | TOO_BUSY | |
Closing / Closed | VENUE_CLOSING_SOON | CLOSED | |
Other / none | GENERIC | TOO_BUSY | |
| Test orders | n/a | always TEST_ORDER |
Wolt requires a reason text and shows it to the customer. Without reason, RapidHub sends a polite default text in German.
State errors
| HTTP | Body | Meaning and what to do |
|---|---|---|
| 400 | {"error":"invalid_state","state":"Done"} | Unknown state. Also invalid_reject_code, invalid_prep_time (outside 0–240). |
| 401 | empty | Bad restaurant key. |
| 403 | {"error":"not_your_order"} | The order belongs to another restaurant. |
| 404 | empty | Unknown order id. Use id, not platformOrderId. |
| 409 | {"error":"invalid_transition","detail":"Received→Ready"} | The state machine does not allow it, including sending the state the order already has. If the order is already in the target state, treat it as success. |
| 409 | {"error":"state_mismatch","upstream":"CANCELLED"} | The platform has the order in another state (raw platform status in upstream). If the platform already closed it, RapidHub updates the order and sends you an event. Re-read the order. |
| 502 | {"error":"failed","detail":"…"} | The platform call failed (network, platform error). Local state unchanged; retry with backoff, then tell the staff to use the platform tablet. |
Connections & capabilities
/pos/catalog/connectionsX-Restaurant-KeyLists the tenant's platform connections. Build your "delivery platforms" settings screen from it, and decide which actions to offer from capabilities, not from the platform name.
[
{
"id": "7d0c2a4e-5b1f-4c8e-9a3d-2f6b8e1c4a70",
"platform": "Mock",
"platformStoreId": "mock-3f9a1c2e",
"environment": "Staging",
"brand": null,
"isActive": true,
"capabilities": {
"supportsPrepTime": true,
"supportsRejectCodes": true,
"supportsStoreStatus": true,
"supportsItemAvailability": true,
"menuPush": "Full"
}
},
{
"id": "1e5a9c3f-7b2d-4e8a-b6c0-4f9d2a7e1c38",
"platform": "Wolt",
"platformStoreId": "65f1c0a2b3d4e5f6a7b8c9d0",
"environment": "Production",
"brand": null,
"isActive": true,
"capabilities": {
"supportsPrepTime": true,
"supportsRejectCodes": true,
"supportsStoreStatus": true,
"supportsItemAvailability": true,
"menuPush": "Full"
}
},
{
"id": "9b3e7d1a-4c6f-4a2b-8d5e-0f1a3c5e7b92",
"platform": "UberEats",
"platformStoreId": "0f6a3c1e-2b4d-4e8f-9a7c-5d3b1e9f7a24",
"environment": "Production",
"brand": null,
"isActive": true,
"capabilities": {
"supportsPrepTime": false,
"supportsRejectCodes": false,
"supportsStoreStatus": false,
"supportsItemAvailability": false,
"menuPush": "None"
}
}
]
| Capability | Meaning |
|---|---|
supportsPrepTime | prepTimeMinutes on accept is forwarded. |
supportsRejectCodes | rejectCode is mapped to a platform code. |
supportsStoreStatus | Open/pause and status reading work. |
supportsItemAvailability | Item updates (sold out / price) work. |
menuPush | Full = full menu push + item updates; UpdatesOnly = the menu is managed on the platform, only item updates by SKU; None = no menu actions. |
| Platform | Prep time | Reject codes | Store status | Item updates | Menu push |
|---|---|---|---|---|---|
| Wolt | yes | yes | yes | yes | Full |
| Delivery Hero | yes | yes | yes | yes | Full |
| Mock | yes | yes | yes | yes | Full |
| Uber Eats | no | no | no | no | None |
| Just Eat Takeaway · Glovo (legacy) | no | no | no | no | None |
The table shows what the API reports today; always read capabilities at runtime.
Push the menu
/pos/catalog/pushX-Restaurant-KeySend the menu once; RapidHub converts it for every target platform (prices, VAT, deposits, allergens, food info, combos) and returns one job per connection.
connectionIdsomitted or empty = all active connections of the tenant. Pass explicit ids for a single restaurant.- A push replaces the menu on the platform.
- Prices are gross, in minor units.
currencydefaults toEUR. - Partial failure is normal: one platform can accept while another rejects. Check every job.
- The last pushed menu per connection is visible in the dashboard (Restaurants → Menu), with errors and warnings.
curl -X POST https://hub.rapidpaygroup.com/pos/catalog/push \
-H "X-Restaurant-Key: $RAPIDHUB_KEY" \
-H "Content-Type: application/json" \
-d @menu.jsonvar menu = new
{
connectionIds = new[] { connectionId },
currency = "EUR",
products = new[]
{
new { sku = "BURGER", name = "Cheeseburger", priceMinor = 890L, taxRatePercent = 7m,
modifierGroupIds = new[] { "G-EXTRAS" } },
// …
},
categories = new[] { new { id = "cat-burgers", name = "Burgers", itemSkus = new[] { "BURGER" } } },
modifierGroups = new[]
{
new { id = "G-EXTRAS", name = "Extras", minSelect = 0, maxSelect = 3,
options = new[] { new { sku = "X-CHEESE", name = "Extra cheese", priceMinor = 100L } } },
},
};
var res = await http.PostAsJsonAsync("/pos/catalog/push", menu);
var result = await res.Content.ReadFromJsonAsync<JsonElement>();
foreach (var job in result.GetProperty("jobs").EnumerateArray())
Console.WriteLine($"{job.GetProperty("platform")}: {job.GetProperty("status")} {job.GetProperty("lastError")}");@Serializable data class MenuItem(val sku: String, val name: String, val priceMinor: Long,
val taxRatePercent: Double? = null, val available: Boolean = true,
val modifierGroupIds: List<String>? = null, val depositMinor: Long? = null)
@Serializable data class Category(val id: String, val name: String, val itemSkus: List<String>)
@Serializable data class PushRequest(val connectionIds: List<String>?, val currency: String,
val products: List<MenuItem>, val categories: List<Category>? = null)
val res = http.post("pos/catalog/push") {
contentType(ContentType.Application.Json)
setBody(PushRequest(listOf(connectionId), "EUR",
products = listOf(MenuItem("BURGER", "Cheeseburger", 890, taxRatePercent = 7.0)),
categories = listOf(Category("cat-burgers", "Burgers", listOf("BURGER")))))
}
val jobs = json.parseToJsonElement(res.bodyAsText()).jsonObject["jobs"]!!.jsonArrayconst res = await fetch(`${BASE}/pos/catalog/push`, {
method: "POST",
headers: { "X-Restaurant-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(menu), // see the sample menu below
});
const { jobs } = await res.json();
for (const job of jobs) console.log(job.platform, job.status, job.lastError ?? "");{
"connectionIds": [
"7d0c2a4e-5b1f-4c8e-9a3d-2f6b8e1c4a70"
],
"currency": "EUR",
"products": [
{
"sku": "BURGER",
"name": "Cheeseburger",
"description": "Beef patty, cheddar, pickles, house sauce",
"priceMinor": 890,
"taxRatePercent": 7,
"imageUrl": "https://example.com/img/cheeseburger.jpg",
"modifierGroupIds": [
"G-EXTRAS"
],
"allergens": [
"Gluten",
"Milk",
"Mustard"
]
},
{
"sku": "FRIES",
"name": "Fries",
"priceMinor": 350,
"taxRatePercent": 7,
"foodInfo": {
"vegetarian": true
}
},
{
"sku": "COLA",
"name": "Cola 0.33 l",
"priceMinor": 250,
"taxRatePercent": 19,
"depositMinor": 25,
"depositTaxRatePercent": 19,
"foodInfo": {
"additives": [
"Mit Farbstoff(en)",
"Koffeinhaltig"
],
"caffeineMgPer100ml": 10,
"netAmount": 330,
"netUnit": "ml",
"prepackaged": true
}
},
{
"sku": "MENU1",
"name": "Burger Menu",
"description": "Cheeseburger, fries and a drink",
"priceMinor": 1190,
"taxRatePercent": 7,
"modifierGroupIds": [
"STEP-MAIN",
"STEP-SIDE",
"STEP-DRINK"
]
}
],
"categories": [
{
"id": "cat-burgers",
"name": "Burgers",
"itemSkus": [
"BURGER"
]
},
{
"id": "cat-sides",
"name": "Sides",
"itemSkus": [
"FRIES"
]
},
{
"id": "cat-drinks",
"name": "Drinks",
"itemSkus": [
"COLA"
]
},
{
"id": "cat-menus",
"name": "Menus",
"itemSkus": [
"MENU1"
]
}
],
"modifierGroups": [
{
"id": "G-EXTRAS",
"name": "Extras",
"minSelect": 0,
"maxSelect": 3,
"options": [
{
"sku": "X-CHEESE",
"name": "Extra cheese",
"priceMinor": 100
},
{
"sku": "X-ONION",
"name": "Onions",
"priceMinor": 0
}
]
},
{
"id": "STEP-MAIN",
"name": "Main",
"minSelect": 1,
"maxSelect": 1,
"options": [
{
"sku": "BURGER",
"name": "Cheeseburger",
"priceMinor": 0,
"isDefault": true
}
]
},
{
"id": "STEP-SIDE",
"name": "Side",
"minSelect": 1,
"maxSelect": 1,
"options": [
{
"sku": "FRIES",
"name": "Fries",
"priceMinor": 0,
"isDefault": true
}
]
},
{
"id": "STEP-DRINK",
"name": "Drink",
"minSelect": 1,
"maxSelect": 1,
"options": [
{
"sku": "COLA",
"name": "Cola 0.33 l",
"priceMinor": 0,
"isDefault": true,
"depositMinor": 25
}
]
}
]
}
{
"jobs": [
{
"connectionId": "7d0c2a4e-5b1f-4c8e-9a3d-2f6b8e1c4a70",
"platform": "Mock",
"platformStoreId": "mock-3f9a1c2e",
"jobId": "a1f3c5e7-0b2d-4f6a-8c9e-1d3f5a7b9c0e",
"status": "Completed",
"externalJobId": null,
"itemCount": 4,
"lastError": null
}
]
}
| HTTP | error | Meaning |
|---|---|---|
| 400 | empty_catalog | products is empty. |
| 400 | no_target_connections | No active connection to push to. |
| 404 | unknown_connection | A connectionIds entry is not a connection of this tenant. |
Menu schema
products[]
| Field | Type | Description |
|---|---|---|
skurequired | string | Your POS id. Comes back in orders as items[].ref.sku. Unique within the menu. |
namerequired | string | Display name. |
priceMinorrequired | int64 | Gross price in minor units. 0 only for items whose price comes from modifiers. |
available | boolean | Default true. false = sold out. |
taxRatePercent | number | VAT rate (7, 19 …). Most platforms require it. |
description | string | Description. |
categoryName | string | Category, when you do not send categories. |
imageUrl | string | Public http(s) image URL. |
barcode | string | EAN / GTIN. |
modifierGroupIds | string[] | Modifier groups offered with the item, in order. |
depositMinor | int64 | Deposit per unit, not included in priceMinor. |
depositTaxRatePercent | number | VAT of the deposit; default = the item's rate. |
allergens | string[] | Allergen names in any language ("Gluten", "Milch", "Sellerie" …). RapidHub converts them to each platform's codes. |
foodInfo | object | Food information, see below. |
categories[] and modifierGroups[]
| Field | Type | Description |
|---|---|---|
categories[].id / name | string | Category id and name. |
categories[].itemSkus | string[] | SKUs in display order. An item must be in a category to be visible. |
modifierGroups[].id / name | string | Group id (referenced by modifierGroupIds) and the name the customer sees. |
modifierGroups[].minSelect / maxSelect | int | Selection rules. 0/3 = up to three optional extras; 1/1 = exactly one (a combo step). |
options[].sku / name | string | Option SKU (comes back as modifiers[].sku) and name. |
options[].priceMinor | int64 | Surcharge (0 = included). |
options[].available | boolean | Default true. |
options[].isDefault | boolean | Pre-selected value. |
options[].maxQuantity | int | How often the option can be picked (default 1). |
options[].taxRatePercent, depositMinor, allergens, imageUrl, foodInfo | … | As for products. |
Combos, deposits, food info
- Combo menus are a product whose
modifierGroupIdsare its steps; each step is a group withminSelect: 1, whose options use the product SKUs of the step's products (price = surcharge). In orders a combo arrives as one line whose modifiers are the chosen step products (see the combo example). - Deposits (Pfand) are separate from the price. In Germany Wolt accepts only the statutory amounts 0.25, 0.15 and 0.08 €; other amounts are not sent and the job reports a warning.
- foodInfo carries EU food information (LMIV):
additives(legal German phrases, e.g."Mit Farbstoff(en)"),alcoholPercent,caffeineMgPer100ml,netAmount+netUnit(ml·g·pcs, used for the unit price),prepackaged,ingredients,producer,distributor,countryOfOrigin,storage,vegetarian, andnutritionper 100 g / 100 ml (per100ml,energyKj,energyKcal,fat,saturatedFat,carbohydrate,sugar,protein,salt).
Item updates
/pos/catalog/itemsX-Restaurant-KeyMark items or modifier options sold out / available again, or change a price, by SKU, without sending the whole menu. Batch changes and send them together.
{
"items": [
{
"sku": "COLA",
"available": false
},
{
"sku": "BURGER",
"available": true,
"priceMinor": 950
},
{
"sku": "X-CHEESE",
"available": false
}
]
}
{
"jobs": [
{
"connectionId": "7d0c2a4e-5b1f-4c8e-9a3d-2f6b8e1c4a70",
"platform": "Mock",
"platformStoreId": "mock-3f9a1c2e",
"jobId": "d2e4f6a8-1b3c-4d5e-9f7a-2b4c6d8e0f13",
"status": "Completed",
"externalJobId": null,
"itemCount": 3,
"lastError": null
},
{
"connectionId": "1e5a9c3f-7b2d-4e8a-b6c0-4f9d2a7e1c38",
"platform": "Wolt",
"platformStoreId": "65f1c0a2b3d4e5f6a7b8c9d0",
"jobId": "e3f5a7b9-2c4d-4e6f-8a0b-3c5d7e9f1a24",
"status": "Queued",
"externalJobId": null,
"itemCount": 3,
"lastError": "queued until 2026-10-15T12:45:00.0000000+00:00"
}
]
}
availabledefaults totrue; omitpriceMinorto keep the price.- Rate-limited platforms queue. Wolt allows one item update per venue every 15 minutes. RapidHub merges your updates in a durable queue (the latest value per SKU wins) and sends them as soon as allowed. The job is then
QueuedwithlastError: "queued until <time>". This is not an error. - A platform that rejects an update permanently (validation error) drops it from the queue; the job is
Failed. - Errors:
400 no_items,400 no_target_connections,404 unknown_connection.
Push jobs
/pos/catalog/jobs/{jobId}X-Restaurant-KeyEvery menu, item and store call creates a job. Asynchronous platforms finish later: Delivery Hero reports the result by callback, the legacy Glovo API is polled when you call this endpoint.
| status | Meaning |
|---|---|
Pending | Created, not sent yet. |
Queued | Waiting: rate limit (item updates) or the platform's queue. |
InProgress | The platform is processing it. |
Completed | Done. lastError may still list warnings (… Warnings: a; b) or done_with_errors: …: show them to the user. |
Failed | Rejected; lastError has the platform's message. |
Wolt processes menus in the background without a status endpoint, so a Wolt menu job is Completed once Wolt accepted the upload. Wolt allows one menu upload per venue every 15 minutes; a second push within that time fails with "Wolt allows one menu upload per 15 minutes per venue."
Store status
/pos/catalog/{connectionId}/store-statusX-Restaurant-Key{
"isOpen": true,
"isOnline": true,
"openingTimes": [
{
"openDay": "MONDAY",
"openTime": "11:00",
"closeDay": "MONDAY",
"closeTime": "22:00"
},
{
"openDay": "FRIDAY",
"openTime": "11:00",
"closeDay": "SATURDAY",
"closeTime": "02:00"
},
{
"openDay": "SUNDAY",
"openTime": "00:00",
"closeDay": "SUNDAY",
"closeTime": "24:00"
}
]
}
isOpen / isOnline come from the platform (null = not reported). openingTimes is filled for Wolt and Mock. Unsupported platforms return 501 {"error":"not_supported"}; a failing platform call returns 502 {"error":"platform_error"}.
/pos/catalog/{connectionId}/store-statusX-Restaurant-Key{ "open": false, "closedUntil": "2026-10-15T18:00:00Z" }
{ "jobId": "c4d6e8f0-1a3b-4c5d-8e7f-9a0b1c2d3e4f", "status": "Completed", "lastError": null }
open: falsepauses the store;closedUntil(ISO 8601) lets the platform reopen it automatically. Omit it for an indefinite pause;open: truereopens.- The response is
200even if the platform refused: checkstatus(Completed/Failed) andlastError. - Platform test stores often start offline, so their items look "sold out". Set them online once after the first menu push.
Opening hours
/pos/catalog/{connectionId}/opening-hoursX-Restaurant-KeyReplaces the whole weekly schedule on the platform (Wolt, Mock). Send every window of the week as a JSON array. Success is 204 No Content.
- Days:
MONDAY…SUNDAY(case-insensitive). Times:HH:mm, 24-hour, in the store's local time.24:00is allowed as a closing time. - Overnight: close on the next day, e.g.
FRIDAY 11:00 → SATURDAY 02:00. - Open 24/7: seven windows
00:00 → 24:00, one per day. - Several windows per day (lunch and dinner) are separate entries.
- Errors:
400 invalid_day,404,501 not_supported,502 platform_error.
[
{
"openDay": "MONDAY",
"openTime": "11:00",
"closeDay": "MONDAY",
"closeTime": "22:00"
},
{
"openDay": "TUESDAY",
"openTime": "11:00",
"closeDay": "TUESDAY",
"closeTime": "22:00"
},
{
"openDay": "WEDNESDAY",
"openTime": "11:00",
"closeDay": "WEDNESDAY",
"closeTime": "22:00"
},
{
"openDay": "THURSDAY",
"openTime": "11:00",
"closeDay": "THURSDAY",
"closeTime": "22:00"
},
{
"openDay": "FRIDAY",
"openTime": "11:00",
"closeDay": "SATURDAY",
"closeTime": "02:00"
},
{
"openDay": "SATURDAY",
"openTime": "12:00",
"closeDay": "SUNDAY",
"closeTime": "02:00"
},
{
"openDay": "SUNDAY",
"openTime": "12:00",
"closeDay": "SUNDAY",
"closeTime": "21:30"
}
]
24/7 schedule
[
{ "openDay": "MONDAY", "openTime": "00:00", "closeDay": "MONDAY", "closeTime": "24:00" },
{ "openDay": "TUESDAY", "openTime": "00:00", "closeDay": "TUESDAY", "closeTime": "24:00" },
{ "openDay": "WEDNESDAY", "openTime": "00:00", "closeDay": "WEDNESDAY", "closeTime": "24:00" },
{ "openDay": "THURSDAY", "openTime": "00:00", "closeDay": "THURSDAY", "closeTime": "24:00" },
{ "openDay": "FRIDAY", "openTime": "00:00", "closeDay": "FRIDAY", "closeTime": "24:00" },
{ "openDay": "SATURDAY", "openTime": "00:00", "closeDay": "SATURDAY", "closeTime": "24:00" },
{ "openDay": "SUNDAY", "openTime": "00:00", "closeDay": "SUNDAY", "closeTime": "24:00" }
]
Sandbox (Mock platform)
The Mock platform is a delivery platform built into RapidHub. It behaves like a real platform toward your POS (same events, same endpoints, same responses) but has no connection to any real marketplace. As a second safety net, RapidHub never forwards an order marked platformExtras.mock = "1" to a real platform.
- Create a Mock connectionDashboard → Sandbox → Create Mock connection (or Restaurants → Manage → Connect a platform → Mock). It appears in
GET /pos/catalog/connectionswithplatform: "Mock"and every capability enabled. - Push your menuThe Mock platform checks it like real platforms do: items without price, SKU, name, category or VAT rate; duplicate SKUs and empty menus (both fail the push); modifier groups with min > max or too few options; non-http(s) image URLs; unknown, empty or unused groups and categories. See the result under Sandbox → Catalog.
- Send test ordersSandbox → Send test order. Combine scenarios; products come from your pushed menu.
- Drive them from your POSEvery state call your POS sends is listed under Sandbox → Test orders with time, state, prep time, reject code, reason and HTTP status, plus a JSON download of the whole exchange.
- Simulate platform eventsPlatform cancel, courier picked up, delivered, accept deadline expired (auto-reject), customer changed the order.
| Scenario | What the order contains |
|---|---|
| Delivery, platform courier | channel: Delivery, courierType: Platform, delivery fee 2.99 |
| Delivery, own courier | courierType: Self; with cash, payment.cashToCollect |
| Pickup | channel: Pickup, no delivery fee |
| Paid online / cash | payment.status PaidOnline / CashOnDelivery (cash with a platform courier counts as paid) |
| Item with deposit | items[].deposit (and modifiers[].deposit in combos) |
| Combo menu | One item whose modifiers are the menu steps |
| Modifiers incl. removal | A paid extra and an Ohne … modifier priced 0 |
| Item / basket discount, free delivery | items[].discount and discounts[] with scope Item, Basket, Delivery |
| Tip, customer note | amounts.tip, customer.note and an item note |
| Scheduled (pre-order) | timing: Scheduled, fulfillment.promisedFor |
| Unmapped item | A line with SKU UNMAPPED-n that is not in your menu |
| Quantity > 1 | The first line has quantity 2 |
| Accepted by the platform | acceptance: AutoByPlatform, arrives Accepted, no deadline |
Mock orders have platform: "Mock", platformOrderId: "mock-<yyMMddHHmmss>-<n>", displayCode: "TEST-<n>", platformExtras.mock: "1" and an accept deadline (3 minutes by default). Item updates, store status and opening hours are stored per Mock connection and shown under Sandbox → Store; the Mock platform has no rate limits. Mock orders are not billed.
Test orders are only "test" if your POS treats them so. If a Mock order is paid on a fiscal POS, a real fiscal record is created: receipt, TSE / fiscal signature, Z report and any export (DSFinV-K, TicketBAI, VeriFactu …). Test on a training or test till, or cancel / void the receipt according to your country's rules. Detect test orders with platform == "Mock" or platformExtras.mock == "1" and, ideally, block fiscalization for them.
Postman
A ready-made collection covers every POS endpoint, with test scripts that carry ids from one request to the next, and a sandbox flow to run in order.
baseUrl, restaurantKey, orderId, connectionId, jobId.- In Postman: Import → paste
https://hub.rapidpaygroup.com/integration/postman/collection.json, then the environment URL. - Select the RapidHub environment and paste your restaurant key into
restaurantKey. - Run Auth check → Verify restaurant key, then the Sandbox flow folder step by step (step 4 waits for a test order you send from the dashboard).
Platform notes
RapidHub hides most platform differences. The ones that remain visible to a POS:
Wolt
- Accept within 3 minutes (pre-orders: 15 minutes), otherwise Wolt rejects the order. If Wolt accepts orders itself for the venue, orders arrive with
acceptance: "AutoByPlatform"and no deadline. prepTimeMinutesbecomes the new pickup time (at most +25 minutes over Wolt's estimate); for own-courier orders it sets the delivery time.- Self-delivery (
courierType: "Self"): sendDispatchedwhen your courier leaves andDeliveredon arrival. With a Wolt courier, pickup and delivery are reported by Wolt. - Cash: with a Wolt courier the courier collects the cash, so the order is
PaidOnlinefor you. With self-delivery it isCashOnDeliverywithcashToCollect;platformExtras.cashGiventells you what the customer will hand over. - No venue-cancel API after acceptance. Reject reasons are shown to the customer.
- Menu: one full upload per venue every 15 minutes; item updates once per 15 minutes (queued). Wolt does not report VAT totals (
amounts.taxis 0).
Uber Eats
- Accept within about 11 minutes of RapidHub receiving the order (
acceptDeadline). - Prep time and reject codes are not forwarded; a rejection is sent as "store unavailable".
- Courier orders: pickup and delivery are tracked by Uber; your
Dispatched/Deliveredare recorded locally. Restaurant-delivered orders forwardDispatched(started) andDeliveredas delivery status updates (best effort).
Delivery Hero (foodora, Glovo on the DH API …)
acceptDeadlinecomes from the platform per order. On accept RapidHub sends an acceptance time of at least now + 2 minutes:now + prepTimeMinutes, or the platform's own time when that is later; without a prep time the platform time, or now + 20 minutes.- If Delivery Hero is still dispatching when you accept, RapidHub retries the acceptance every 10 seconds for up to 5 minutes in the background; your call returns
200immediately. - Cancelling a
Receivedorder is sent as a rejection. After acceptance there is no cancel API. Readyis forwarded for platform-courier orders; for pickup and own delivery,Dispatched/Deliveredsend "picked up".- Prices arrive as decimal strings and are converted to minor units. Menu pushes are asynchronous; the job is updated by the platform's callback.
Just Eat Takeaway
- One connector for the Just Eat Takeaway brands (Lieferando, Just Eat, Takeaway.com …);
connections[].brandtells them apart. - No accept deadline is reported. Capabilities are currently all off.
Mock
- Accepts every transition the state machine allows (an invalid one returns 409). See Sandbox.
Errors
Errors are JSON objects with a stable error code, plus context fields. Authentication failures and some "not found" cases have an empty body; rely on the HTTP status first.
| HTTP | error | Endpoints | Meaning |
|---|---|---|---|
| 400 | invalid_state · invalid_reject_code · invalid_prep_time | state | Body value not accepted. |
| 400 | empty_catalog · no_items · no_target_connections | push, items | Nothing to send / nowhere to send it. |
| 400 | invalid_day | opening-hours | Day name not recognized. |
| 401 | (empty) | all | Missing, unknown or inactive restaurant key. |
| 403 | not_your_order | state | Order of another restaurant. |
| 404 | (empty) · unknown_connection | state, catalog, store | Unknown order, job or connection. |
| 409 | invalid_transition · state_mismatch | state | See state errors. |
| 429 | (empty) | all | Rate limit exceeded; retry after a minute. |
| 501 | not_supported | store | The platform does not support it. |
| 502 | failed · platform_error | state, store | The platform call failed; nothing changed. Retry with backoff. |
Retries and idempotency. There are no idempotency keys. GET calls are safe to repeat. A repeated state call returns 409 invalid_transition once the first one succeeded: check the current state. Menu pushes and item updates are "last write wins" and safe to resend (mind the Wolt limits).
Rate limits
- RapidHub: 600 requests per minute per client IP address across all endpoints. Exceeding it returns
429. Several tills behind one router share an IP, so prefer the real-time stream over frequent polling. - Platforms: Wolt allows one menu upload and one item update per venue every 15 minutes (RapidHub queues item updates for you). Other platforms apply their own limits; errors appear in the job's
lastError.
Versioning
- The REST paths are unversioned and kept backward compatible. The order schema has its own version in
schemaVersion(currently2.0). - 2.0 only added optional fields to 1.0: line VAT, deposits and discounts,
discounts[],fees[],timing,acceptance,payment.cashToCollect,platformExtras, modifier SKUs. A 1.0 consumer keeps working. - Expect new optional fields, new enum values (states excepted) and new platforms at any time. Breaking changes would raise the major
schemaVersionand be announced in advance.
Go-live checklist
- The restaurant key is stored securely per till / location and can be replaced after a rotation.
- The real-time connection reconnects with backoff, and every (re)connect is followed by
GET /pos/orders/active. - Events are de-duplicated by
orderId; the newest order replaces the stored one; states never move backwards. - The accept countdown uses
acceptDeadline;AutoByPlatformorders show no accept button. Canceledevents and orders that vanish from the active list alert the staff.- Items map by
ref.sku/modifiers[].sku; unknown SKUs are flagged, never silently dropped. - Receipts handle deposits, discounts (by
fundedBy), fees, tips andcashToCollect. 409and502on state calls are handled; the staff are told to use the platform tablet when the platform is down.- UI decisions use
capabilities, not platform names. - Mock / test orders cannot create real fiscal records.
- Unknown JSON fields and enum values are ignored.