The Rowabi Partner API lets an ordering app pull a merchant's live menu from the Rowabi POS and push customer orders straight into the merchant's cashier screen, then follow each order's status until pickup or delivery. One API key per merchant; JSON over HTTPS; prices are always recalculated by Rowabi.
How it works
Your backend talks only to the Rowabi Partner API; the merchant's staff work only in the Rowabi cashier they already use.
- Your backend calls
GET /menuand receives the menu, prices, options and availability. - A customer orders in your app; your backend calls
POST /orders. The order appears in the merchant's cashier as pending, with a sound alert. - The cashier accepts, prepares, marks ready and completes the order. Rowabi sends you an
order.status_changedwebhook at each step. - When the merchant edits the menu in Rowabi, you receive a
menu.updatedwebhook and re-pull it.
The menu and all prices live in Rowabi, so your app always shows what the cashier sells.
Access and authentication
Every request carries one merchant's API key in the X-Api-Key header; the key alone tells Rowabi which merchant (store) the call is for.
- Base URL:
https://rawa-api.onrender.com/api/partner/v1 - Format: JSON, UTF-8, HTTPS only. Times are UTC in ISO 8601 (
2026-10-12T14:05:00Z). Money is SAR as a decimal number with 2 places. - One key per merchant. A partner serving ten Rowabi merchants holds ten keys. The merchant generates the key from Rowabi admin (Settings → App integrations) or Rowabi support issues it; either side can revoke it at any time.
- Key prefixes:
rwb_test_…points at a Rowabi demo store for development;rwb_live_…points at a real merchant. Never ship a test key in a production build. - Keep keys server-side. Call the API from your backend, never from the customer's phone. A leaked key lets anyone place orders into that merchant's cashier.
- Rate limit: 120 requests per minute per key. Over the limit returns
429with aRetry-Afterheader (seconds).
GET /api/partner/v1/menu?branchId=7d1c… HTTP/1.1
Host: rawa-api.onrender.com
X-Api-Key: rwb_live_9f2c…
Accept: application/json
A missing, revoked or wrong key returns 401 UNAUTHORIZED. A valid key for a merchant whose integration is switched off returns 403 INTEGRATION_DISABLED.
Menu sync
Pull the menu from Rowabi instead of keeping your own copy: the cashier edits it in Rowabi, and an order for a product or option that no longer exists is refused.
GET /store
The merchant's name, logo, branches and pricing rules. Call it once at setup and daily after.
| Field | Meaning |
|---|---|
merchant.name, merchant.logoUrl |
Display name and logo (download the logo with your X-Api-Key header; it may be served by the API itself) |
branches[].id |
The branchId every other call needs |
branches[].openTime / closeTime (+ openTime2 / closeTime2) |
Opening hours, local Riyadh time HH:mm; a second shift when present |
branches[].supportsPickup / supportsDriveThru / supportsDelivery |
Which fulfilment types the branch accepts |
branches[].deliveryFee, minDeliveryAmount |
Per-branch delivery economics, SAR |
pricing.pricesIncludeVat |
true = menu prices already contain VAT. Never add VAT on top |
pricing.vatActivated, pricing.vatRate |
Whether the merchant charges VAT at all, and the rate (e.g. 15) |
settings.estimatedPrepTime |
Typical preparation time, minutes |
GET /menu?branchId={id}
The menu as sold at one branch: products the merchant restricted to other branches, and products not sold on apps, are already removed. Omit branchId for the merchant-wide menu.
| Collection | Key fields |
|---|---|
categories[] |
id, name (Arabic), nameEn, sortOrder, parentCategoryId (sub-categories) |
products[] |
id, name, nameEn, description, categoryId, sellingPrice, imageUrl, saleUnit |
optionGroups[] |
id, productId, name, nameEn, type (single / multiple), isRequired, minSelections, maxSelections, items[] |
optionGroups[].items[] |
id, name, nameEn, priceDelta (added to the product price; can be 0) |
version |
Changes whenever anything a customer can see in the menu changes; send it back as If-None-Match to get 304 Not Modified |
The price of a line is always sellingPrice + sum(priceDelta × option quantity), times the item quantity. Promotions are not part of v1: Rowabi does not apply offers to partner orders, so do not show discounted prices.
{
"version": "5f1c9a0e7b2d…",
"categories": [
{ "id": "c1a0…", "name": "الأكثر طلباً", "nameEn": "Best sellers", "sortOrder": 1, "parentCategoryId": null }
],
"products": [
{ "id": "p7e2…", "name": "بوكس الهدايا", "nameEn": "Gift box", "description": "…", "categoryId": "c1a0…",
"sellingPrice": 18.00, "imageUrl": "https://…/gift-box.jpg", "saleUnit": "piece" }
],
"optionGroups": [
{ "id": "g31b…", "productId": "p7e2…", "name": "الحجم", "nameEn": "Size", "type": "single",
"isRequired": true, "minSelections": 1, "maxSelections": 1,
"items": [
{ "id": "o901…", "name": "صغير", "nameEn": "Small", "priceDelta": 0 },
{ "id": "o902…", "name": "كبير", "nameEn": "Large", "priceDelta": 4.00 }
] }
]
}
Refresh rule: re-pull when you receive the menu.updated webhook, and as a safety net every 15 minutes with If-None-Match. Hide a product the moment it disappears from the menu; do not keep selling it from cache. Images are public URLs you may cache.
Placing an order
POST /orders drops the order into the merchant's cashier screen (the «app orders» tab, with a sound alert) as pending; the cashier accepts it and it goes to the kitchen.
| Field | Type | Required | Notes |
|---|---|---|---|
externalOrderId |
string ≤ 64 | yes | Your order id. Unique per merchant; makes the call idempotent (below) |
branchId |
uuid | yes | From GET /store |
fulfillment.type |
pickup | pickup_car | delivery |
yes | Must be supported by the branch |
fulfillment.vehicle.description, .plate |
string | for pickup_car |
e.g. «كامري أبيض», «أ ب ج 1234» |
fulfillment.location.lat, .lng, .address |
number, string | for delivery |
Checked against the merchant's delivery radius |
customer.name |
string ≤ 100 | yes | Printed on the ticket |
customer.phone |
string | yes | Saudi mobile, 05XXXXXXXX or +9665XXXXXXXX |
items[].productId |
uuid | yes | From GET /menu |
items[].quantity |
number > 0 | yes | Decimals allowed only for weighed products |
items[].options[] |
{ optionItemId, quantity } |
when a group is required | optionItemId = optionGroups[].items[].id |
items[].notes, notes |
string ≤ 250 | no | Item note / whole-order note, shown to the order-preparation staff |
payment.status |
paid | unpaid |
yes | paid = you already collected the money; the cashier does not charge again |
payment.method |
card | apple_pay | stc_pay | cash | other |
yes | cash only with unpaid |
payment.amount |
decimal | when paid |
What you charged the customer, SAR incl. VAT |
payment.reference |
string ≤ 100 | no | Your payment/transaction id, kept for reconciliation |
expectedTotal |
decimal | recommended | The total your app showed the customer |
{
"externalOrderId": "ORD-100245",
"branchId": "7d1c…",
"fulfillment": { "type": "pickup_car", "vehicle": { "description": "كامري أبيض", "plate": "أ ب ج 1234" } },
"customer": { "name": "فهد", "phone": "0551234567" },
"items": [
{ "productId": "p7e2…", "quantity": 2, "notes": "تغليف هدية",
"options": [ { "optionItemId": "o902…", "quantity": 1 } ] }
],
"payment": { "status": "paid", "method": "apple_pay", "amount": 44.00, "reference": "pay_8812" },
"expectedTotal": 44.00
}
Response 201 Created:
{
"orderId": "a3f0…",
"orderNumber": "APP-261012-A3F0",
"externalOrderId": "ORD-100245",
"status": "pending",
"totals": { "subtotal": 44.00, "discount": 0, "deliveryFee": 0, "vat": 5.74, "total": 44.00 },
"estimatedPrepMinutes": 15,
"createdAt": "2026-10-12T14:05:12Z"
}
The rules that matter:
- Rowabi prices the order. Unit prices, option deltas, VAT and delivery fee all come from the merchant's settings; any price you send is ignored. If
expectedTotaldiffers from Rowabi's total by more than 0.01 SAR the order is refused with409 PRICE_MISMATCHand the correcttotals. Your menu is stale: re-pull it and show the customer the new price. - Idempotency. Retrying with the same
externalOrderIdnever creates a second order: it returns200with the original order. Retry freely on timeouts and 5xx. - Paid orders. With
payment.status: "paid"the cashier sees the order marked «مدفوع — {your app name}» and closes it without taking money; in the merchant's reports the sale is booked under a separate «platform» payment type, outside the cash drawer. Refunds for paid orders are made by you; the merchant settles with you outside this API. - Opening hours and availability are enforced: an order for a closed branch, an unsupported fulfilment type, or a product not sold at that branch is refused with a specific code (see Errors). Show the message; do not retry.
- The order number (
orderNumber) is what the order-preparation staff call out and print. Show it to the customer.
Order status and cancellation
Every order moves through the statuses below; webhooks tell you each change, and GET /orders/{orderId} (or GET /orders?externalOrderId=ORD-100245) returns the current one.
| Status | Meaning | Customer-facing text (suggested) |
|---|---|---|
pending |
Received in the cashier screen, not yet accepted | «بانتظار تأكيد المحل» |
accepted |
Cashier accepted; the kitchen ticket is printed and preparation starts | «تم قبول طلبك وجاري التحضير» |
ready |
Ready for pickup / handed to the driver | «طلبك جاهز» |
completed |
Handed over and closed | «تم التسليم» |
rejected |
The merchant declined it, or nobody accepted it in time (reason given) |
«اعتذر المحل عن الطلب» |
cancelled |
Cancelled by you through the API | «تم إلغاء الطلب» |
A busy store may go from accepted straight to completed. Treat completed, rejected and cancelled as final. The values preparing and out_for_delivery are reserved for a later version; handle them as accepted if you ever receive them.
Response of GET /orders/{orderId}: the same body as the create response, plus statusHistory[] (status, at) and, for rejected/cancelled, reason.
Cancelling: POST /orders/{orderId}/cancel with { "reason": "customer_request" }. Allowed only while the order is pending; it disappears from the cashier screen. After acceptance the call returns 409 ORDER_NOT_CANCELLABLE: the kitchen has started, so the customer must contact the store.
Pending timeout: if the cashier does not accept within 10 minutes, Rowabi rejects the order automatically with reason: "merchant_timeout". Refund a paid order in that case.
Polling: if you cannot receive webhooks, poll GET /orders/{orderId} no more than once every 30 seconds per active order, and stop at a final status.
Webhooks
Give Rowabi one HTTPS URL (port 443); Rowabi POSTs an event to it on every order status change and every menu change, signed with a secret shared once at onboarding.
| Event | Sent when | data contains |
|---|---|---|
order.status_changed |
Any status change in the table above | orderId, externalOrderId, orderNumber, status, previousStatus, reason, at |
menu.updated |
Products, prices, options or availability changed | branchId (null = all branches), version, at |
store.updated |
Anything returned by GET /store changed (name, logo, branches, hours, fulfilment types, fees, pricing rules) |
branchId (null), version, at |
POST https://your-app.example/rowabi/webhook
Content-Type: application/json
X-Rowabi-Event: order.status_changed
X-Rowabi-Delivery: 5b0e9c1a-…
X-Rowabi-Timestamp: 1791820000
X-Rowabi-Signature: sha256=9a41…
{
"event": "order.status_changed",
"merchantId": "t-42f1…",
"data": { "orderId": "a3f0…", "externalOrderId": "ORD-100245", "orderNumber": "APP-261012-A3F0",
"status": "ready", "previousStatus": "accepted", "reason": null, "at": "2026-10-12T14:19:40Z" }
}
Verify every call before trusting it. The signature is HMAC-SHA256 over {timestamp}.{raw body} with your webhook secret, hex-encoded:
const crypto = require('crypto');
function isValid(req, rawBody, secret) {
const ts = req.headers['x-rowabi-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 min
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`).digest('hex');
const got = req.headers['x-rowabi-signature'] || '';
return got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
- Answer
2xxwithin 10 seconds, then do the work. Anything else counts as a failure. Redirects are not followed. - Retries: 6 attempts over about 2 hours (1 min, 5 min, 15 min, 30 min, 1 h). Then the event is dropped;
GET /orders/{id}still has the truth. - Duplicates and order: the same event can arrive twice and out of order. De-duplicate on
X-Rowabi-Deliveryand ignore an event whoseatis older than the status you already hold.
Errors
Every error returns the same body: a stable code to branch on and an Arabic message you may show the customer as is.
{ "code": "BRANCH_CLOSED", "message": "الفرع مغلق حالياً. ساعات العمل: 06:00 — 23:00", "details": {} }
| HTTP | code |
What to do |
|---|---|---|
| 400 | VALIDATION_ERROR |
A field is missing or malformed. Fix the request |
| 400 | INVALID_PHONE |
Ask the customer for a valid Saudi mobile number |
| 400 | INVALID_QUANTITY |
Quantity ≤ 0, too large, or a fraction on a non-weighed product |
| 400 | MISSING_VEHICLE_DESCRIPTION / MISSING_VEHICLE_PLATE |
pickup_car needs both |
| 400 | MISSING_REQUIRED_OPTION / INVALID_OPTION |
A required option group has no choice, or an option does not belong to the product |
| 401 | UNAUTHORIZED |
Missing, wrong or revoked API key |
| 403 | INTEGRATION_DISABLED |
The merchant's integration is switched off. Stop sending orders |
| 404 | NOT_FOUND |
Unknown orderId or branchId for this merchant |
| 409 | PRICE_MISMATCH |
Your price is stale. details.totals holds Rowabi's; re-pull the menu |
| 409 | ORDER_NOT_CANCELLABLE |
Already accepted; the customer must call the store |
| 422 | BRANCH_CLOSED |
Outside opening hours |
| 422 | FULFILLMENT_NOT_SUPPORTED |
The branch does not offer that pickup/delivery type |
| 422 | PRODUCT_UNAVAILABLE / PRODUCT_NOT_AT_BRANCH |
Removed, out of stock, or not sold at that branch. Hide it |
| 422 | BELOW_MIN_ORDER |
details.minOrderAmount and details.subtotal given |
| 422 | OUTSIDE_DELIVERY_RADIUS |
details.distanceKm and details.maxDistanceKm given |
| 429 | RATE_LIMITED |
Wait Retry-After seconds |
| 5xx | SERVER_ERROR |
Retry with the same externalOrderId (safe: idempotent), backing off 2, 4, 8… seconds |
Go-live checklist
A partner goes live merchant by merchant, after passing the same test run on the Rowabi demo store.
- Request access. Send Rowabi your company name, app name (as the cashier should see it), a technical contact, and your webhook URL. You receive a
rwb_test_key, a webhook secret, and the demo store's branch ids. - Build against the demo store. Pull the menu, place orders, and watch them arrive in the demo cashier, which Rowabi operates during the test.
- Pass the test run (Rowabi checks each in the demo cashier):
- Menu shows categories, Arabic and English names, images and option groups correctly
- A paid
pickuporder and an unpaidpickup_carorder arrive with the right items, options, notes and total - A retry with the same
externalOrderIddoes not create a second order - A stale price returns
PRICE_MISMATCHand your app shows the new price - Accept → ready → completed reaches your app by webhook, with a valid signature check
- A rejected and a timed-out order both trigger your refund flow
- Cancelling a
pendingorder removes it from the cashier
- Merchant consent. Rowabi enables the integration for the merchant, who then generates a
rwb_live_key from Rowabi admin (or asks Rowabi support) and gives it to you. Without the merchant's key there is no access to their store. - First live day. Rowabi watches the first orders with the merchant's cashier. Report any issue to the contact below.
Contact: Rowabi Solutions — admin@rowa.sa — www.rowa.sa