Rowabi Developers

Partner API · v1

Rowabi Partner API v1 — Integration Guide

Building an ordering-app integration? Ask the merchant to enable “App integrations” in Rowabi, or contact admin@rowa.sa for a demo-store test key.

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.

  1. Your backend calls GET /menu and receives the menu, prices, options and availability.
  2. 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.
  3. The cashier accepts, prepares, marks ready and completes the order. Rowabi sends you an order.status_changed webhook at each step.
  4. When the merchant edits the menu in Rowabi, you receive a menu.updated webhook 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 429 with a Retry-After header (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.

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:

  1. 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 expectedTotal differs from Rowabi's total by more than 0.01 SAR the order is refused with 409 PRICE_MISMATCH and the correct totals. Your menu is stale: re-pull it and show the customer the new price.
  2. Idempotency. Retrying with the same externalOrderId never creates a second order: it returns 200 with the original order. Retry freely on timeouts and 5xx.
  3. 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.
  4. 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.
  5. 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 2xx within 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-Delivery and ignore an event whose at is 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.

  1. 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.
  2. Build against the demo store. Pull the menu, place orders, and watch them arrive in the demo cashier, which Rowabi operates during the test.
  3. 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 pickup order and an unpaid pickup_car order arrive with the right items, options, notes and total
    • A retry with the same externalOrderId does not create a second order
    • A stale price returns PRICE_MISMATCH and 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 pending order removes it from the cashier
  4. 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.
  5. 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