DeskOS Aggregator Partner API
Welcome to the DeskOS Aggregator Partner API. This guide is for workspace aggregators and marketplaces: platforms that list coworking spaces and sell day passes and meeting-room bookings to their own customers. It covers everything you need to authenticate, discover live inventory across DeskOS-powered operators, push bookings, handle approvals, cancel, pull booking history, and receive status callbacks.
This API is partner-agnostic. The same integration works whether you are GoFloaters, LiquidSpace, Coworker.com, Deskpass, Upflex, Optix marketplace, or any other aggregator. You receive one set of credentials and see every DeskOS operator who has chosen to list with you.
Prefer to explore in Postman? Download the ready-to-import collection: deskos-aggregator-api.postman_collection.json
Import it, open the collection Variables tab, set baseUrl, client_id, and client_secret,
then run Authentication → Get access token first. It saves the bearer token automatically and
every other request reuses it.
Overview
Coworking operators run their day-to-day business on DeskOS (members, bookings, billing, access control, and more). Aggregators bring those operators incremental demand by listing their spaces on a marketplace. This API is the bridge between the two:
How it works:
- The DeskOS team issues your aggregator a single set of OAuth client credentials
(a
client_idandclient_secret). - Each DeskOS operator decides, from inside their own dashboard, whether to list with you. Only operators who explicitly enable your integration become visible to you.
- You call the API to discover operators, cities, campuses, and live inventory, check availability for a date or time slot, then push bookings.
- Depending on each operator's settings, a booking is either auto-confirmed instantly or held for staff approval. Either way, you get a signed callback when the final status is known.
- Bookings you create are recorded in the operator's DeskOS account, clearly tagged as coming from your platform (with your name and your own booking reference), and settled with the operator on a business-to-business basis.
Unlike most integrations where DeskOS calls an external service, here you call DeskOS. You hold DeskOS-issued credentials and act as a trusted machine client. There is no end-user login; your server authenticates as your platform.
Who this is for
| Aggregator type | Examples |
|---|---|
| On-demand workspace marketplaces | GoFloaters, Deskpass, Upflex, LiquidSpace |
| Space discovery & booking platforms | Coworker.com, Workfrom, Desana |
| Corporate flex-space programs | Upflex for Enterprise, NeoNomad, Worka |
The example values in this guide (
GoFloaters,GF-98231, etc.) are illustrative. Substitute your own platform name and booking references.
Core concepts
| Term | Meaning |
|---|---|
| Aggregator (partner) | Your platform. Identified by a slug (e.g. gofloaters) and one set of credentials. |
| Organization (operator) | A coworking operator/brand running on DeskOS. One operator may run many campuses. Returned by List operators as operator_id; organization_id is the same value. |
| Campus | A physical location/center belonging to an organization. Inventory and bookings are per campus. |
| Inventory | The sellable assets at a campus, split into meeting rooms, seats / day passes, and virtual offices. |
| Booking mode | Each operator chooses, per booking type, whether your bookings are auto-confirmed or require approval. |
| External reference | Your booking ID, sent on every booking so both sides can reconcile. |
| Commission | A percentage configured per operator, reported back to you on confirmed bookings for settlement. |
| Callback | A signed webhook DeskOS sends you when a booking is confirmed, rejected, or cancelled. |
Onboarding flow
- Get credentials. The DeskOS team creates your aggregator profile and issues your
client_idandclient_secret. The secret is shown once, so store it securely. - Share your callback endpoint (optional but recommended). Give the DeskOS team the HTTPS URL where you want to receive status callbacks, and agree on the shared signing secret.
- Operators opt in. Each DeskOS operator enables your integration from their dashboard and chooses their booking modes (auto-confirm vs. approval) and which campuses to expose.
- Go live. Authenticate, read inventory for the operators who opted in, and start booking.
You do not need separate credentials per operator. One credential set sees all opted-in operators. Visibility is controlled entirely by each operator's opt-in, not by your keys.
Environments & server URLs
| Environment | Purpose | Server URL |
|---|---|---|
| Test / Staging | Development and testing | Provided by the DeskOS team |
| Production | Live inventory and bookings | Provided by the DeskOS team |
All endpoint paths in this guide are relative to the server URL you receive during onboarding plus
the API base path. For example, if your production server URL is https://api.deskos.net, the token
endpoint is:
https://api.deskos.net/api/v1/integrations/aggregator/v1/oauth/token
Always validate your integration on staging before switching to production.
Authentication
The Aggregator API uses the OAuth 2.0 Client Credentials grant. Your server exchanges your
client_id and client_secret for a short-lived access token (a bearer JWT), then sends that
token on every subsequent request.
Base path
/api/v1/integrations/aggregator/v1/
Step 1: Get an access token
POST /api/v1/integrations/aggregator/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
Body parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be client_credentials. |
client_id | Yes | Your client ID. |
client_secret | Yes | Your client secret. |
scope | No | Space-separated subset of your granted scopes. Omit to request all of them. |
Request
curl -X POST "https://<your-server-url>/api/v1/integrations/aggregator/v1/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
Response: 200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "cities:read campuses:read inventory:read bookings:write bookings:cancel"
}
Step 2: Use the token
Send the access token as a bearer token on every other request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Token lifecycle
- Tokens are valid for
expires_inseconds (default 3600 / 1 hour). - There is no refresh token. When a token expires, request a new one with the same call.
- Cache the token on your side and reuse it until it is close to expiry. Do not request a new token for every API call.
Scopes
Each endpoint requires a specific scope. Your credentials are granted a set of scopes during onboarding; a token can request all of them or a narrower subset.
| Scope | Grants access to |
|---|---|
cities:read | List cities |
campuses:read | List operators, list campuses |
inventory:read | Read campus inventory, check availability |
bookings:write | Create bookings (and read them back) |
bookings:read | Read bookings and booking history, without the ability to create |
bookings:cancel | Cancel bookings |
Booking reads (Booking history and Get a booking) accept either bookings:read or
bookings:write. If you already hold bookings:write you can read your history today with no
credential change. bookings:read exists so you can issue a separate reporting-only credential to
a finance or analytics system that must never be able to create a booking.
Treat your client_secret like a password. Never expose it in a browser, mobile app, or public
repository. DeskOS stores only a hash of it; if it leaks, the DeskOS team will rotate it.
Conventions
| Topic | Convention |
|---|---|
| Auth | Authorization: Bearer <access_token> on every endpoint except the token endpoint. |
| Content type | Send application/json on POST bodies. |
| IDs | operator_id, campus_id and resource IDs are integers. booking_id is a UUID string. |
| Inventory IDs | Meeting-room IDs and space IDs share one ID space and never collide, so inventory_id alone identifies which kind of inventory you mean. |
| Times | Availability parameters (date, time) are campus-local. Responses return timezone-aware ISO-8601 with the campus offset. |
| Money | Decimal amounts in the campus currency (see each campus/inventory response). Tax is included in booking amount. |
| Currency | ISO 4217 code (e.g. INR, USD, GBP, AED, SGD). |
| Timestamps | ISO-8601. Send timezone-aware datetimes (include the offset). |
| Idempotency | Every booking write must carry an idempotency_key (see Bookings). |
| Visibility | Only operators who enabled your integration are visible. Anything else returns 404. |
API reference
1. List operators
GET /api/v1/integrations/aggregator/v1/operators
Returns every operator (coworking brand) that has enabled your integration. This is the entry point
if you sell more than one operator's space: the operator_id returned here feeds straight into
GET /campuses?operator_id=, so you never need to hardcode a default operator.
Scope: campuses:read
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/operators" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
{
"count": 2,
"operators": [
{
"operator_id": 1,
"organization_id": 1,
"name": "CoWork Solutions",
"slug": "cowork-solutions",
"logo": "https://.../logo.png",
"timezone": "Asia/Kolkata",
"campus_count": 7,
"cities": ["Bengaluru", "Mumbai"],
"currencies": ["INR"]
},
{
"operator_id": 4,
"organization_id": 4,
"name": "Nimbus Works",
"slug": "nimbus",
"logo": null,
"timezone": "Asia/Singapore",
"campus_count": 2,
"cities": ["Singapore"],
"currencies": ["SGD"]
}
]
}
| Field | Description |
|---|---|
operator_id | The operator's ID. Pass it to GET /campuses?operator_id=. |
organization_id | The same value under its original name. Both are returned so either naming works. |
slug | The operator's DeskOS slug (their workspace lives at <slug>.deskos.net). |
campus_count | How many campuses this operator has exposed to you. |
cities / currencies | Distinct values across the campuses exposed to you, sorted. |
Counts, cities and currencies are computed over the campuses the operator exposed to you. An
operator who enabled your integration for two of their ten campuses reports campus_count: 2.
Operators are sorted by name. The typical discovery path is operators → campuses → inventory → availability → book.
2. List cities
GET /api/v1/integrations/aggregator/v1/cities
Returns the distinct cities where operators who listed with you are active, with a campus count per city. Use this to build a city picker.
Scope: cities:read
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/cities" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
{
"cities": [
{ "name": "Bengaluru", "country": "India", "campus_count": 7 },
{ "name": "Mumbai", "country": "India", "campus_count": 3 },
{ "name": "Singapore", "country": "Singapore", "campus_count": 2 }
]
}
3. List campuses
GET /api/v1/integrations/aggregator/v1/campuses
Returns the campuses you can sell, across all opted-in operators. Filter by city or operator.
Scope: campuses:read
Query parameters
| Parameter | Required | Description |
|---|---|---|
city | No | Filter to a single city (case/space-insensitive). |
operator_id | No | Filter to a single operator (from List operators). |
organization_id | No | Alias for operator_id, kept for existing integrations. |
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/campuses?city=Bengaluru" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
{
"count": 1,
"campuses": [
{
"campus_id": 12,
"name": "Whitefield Hub",
"organization": { "id": 1, "name": "CoWork Solutions" },
"address": "5th Floor, Tech Park, Whitefield",
"city": "Bengaluru",
"state": "Karnataka",
"country": "India",
"pincode": "560066",
"lat": 12.9698,
"lon": 77.7499,
"currency": "INR",
"images": [
{ "url": "https://.../campus-1.jpg", "title": "Reception", "order": 0 }
]
}
]
}
4. Get campus inventory
GET /api/v1/integrations/aggregator/v1/campuses/{campus_id}/inventory
Returns the sellable assets at a campus, split into three sections. Returns 404 if the campus's
operator has not enabled your integration (or has not exposed this campus).
Scope: inventory:read
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/campuses/12/inventory" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
{
"campus_id": 12,
"currency": "INR",
"meeting_rooms": [
{
"id": 5,
"name": "Boardroom",
"capacity": 8,
"floor": "3",
"hourly_rate": 800,
"minimum_booking_minutes": 30,
"slot_minutes": 30,
"amenities": ["TV", "Whiteboard", "Video conferencing"],
"status": "active"
}
],
"seats": [
{
"space_id": 9,
"space_name": "Hot Desk Zone",
"space_type": "Hot Desk",
"floor": "1",
"day_pass_available": true,
"day_pass_price": 500,
"total_seats": 40,
"available_seats_today": 14,
"status": "active"
}
],
"virtual_offices": [
{
"package_id": 3,
"name": "Virtual Office (Premium)",
"description": "Business address + mail handling + 10 day passes/month",
"includes_day_passes": true,
"includes_meeting_room_credits": true,
"pricing": "on_request"
}
]
}
Inventory sections
| Section | What it is | Bookable via this API? |
|---|---|---|
meeting_rooms | Bookable rooms charged by the hour. Use id as the resource_id for a meeting-room booking. | ✅ Yes |
seats | Day-pass-enabled desk areas, with the space type (Hot Desk, Dedicated Desk, etc.), live availability for today, and the campus day-pass price. Use space_id as the resource_id for a day-pass booking. | ✅ Yes |
virtual_offices | Virtual-office packages. Listing-only: pricing is on_request and these are not booked through this API. | ❌ Listing only |
available_seats_today reflects availability for the current day. To check any other date, or a
specific meeting-room time slot, use the Check availability endpoint. The
create-booking call also re-checks availability for the requested date/slot and rejects the request
if it is no longer free.
Meeting-room id and seat space_id come from a single ID space: an ID is never both a meeting
room and a desk area. That is why Check availability takes one inventory_id and works out for
itself which of the two you are asking about.
5. Check availability
GET /api/v1/integrations/aggregator/v1/availability
A read-only availability check. It reserves nothing, creates nothing, and can be called as often as your listing pages need. Use it to grey out unavailable dates and time slots before the customer reaches checkout.
Works for both meeting rooms and day-pass desk areas: pass the id (meeting room) or space_id
(seats) from the inventory response as inventory_id, and the endpoint returns the right answer for
that kind of inventory.
Scope: inventory:read
Query parameters
| Parameter | Required | Applies to | Description |
|---|---|---|---|
inventory_id | Yes | Both | Meeting-room id or seat space_id from the inventory response. |
date | No | Both | YYYY-MM-DD. Defaults to today in the campus's timezone. |
time | No | Meeting rooms | Start of the slot you want, HH:MM (campus-local). Omit for a whole-day view. |
duration_minutes | No | Meeting rooms | Length of the slot. Defaults to the room's minimum booking time. |
end_time | No | Meeting rooms | Explicit slot end, HH:MM. Use instead of duration_minutes. |
seats | No | Day passes | How many passes you want to sell. Defaults to 1. |
Two ways to call it
| You send | You get back |
|---|---|
inventory_id + date | Day view. A full grid of the day's slots, each marked available or not, plus the windows already taken. Best for rendering a date/time picker in one call. |
inventory_id + date + time | Window check. A yes/no for that exact slot, plus the price that booking it would cost. Best for validating a customer's selection. |
The response shape is the same either way. In the day view, requested and price are null.
Example: meeting-room day view
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/availability?inventory_id=5&date=2026-07-01" \
-H "Authorization: Bearer <access_token>"
{
"inventory_id": 5,
"inventory_type": "meeting_room",
"name": "Boardroom",
"campus_id": 12,
"campus_name": "Whitefield Hub",
"operator_id": 1,
"operator_name": "CoWork Solutions",
"date": "2026-07-01",
"timezone": "Asia/Kolkata",
"currency": "INR",
"available": true,
"reason": null,
"requested": null,
"price": null,
"campus_hours": {
"opens": "09:00",
"closes": "19:00",
"is_closed": false,
"is_holiday": false,
"holiday_name": null,
"configured": true
},
"rules": {
"capacity": 8,
"floor": "3",
"status": "active",
"hourly_rate": "800.00",
"minimum_booking_minutes": 30,
"maximum_booking_minutes": null,
"slot_minutes": 30,
"advance_booking_days": 30
},
"booked_slots": [
{ "start_time": "2026-07-01T10:00:00+05:30", "end_time": "2026-07-01T11:00:00+05:30" }
],
"slots": [
{ "start_time": "2026-07-01T09:00:00+05:30", "end_time": "2026-07-01T09:30:00+05:30", "available": true },
{ "start_time": "2026-07-01T10:00:00+05:30", "end_time": "2026-07-01T10:30:00+05:30", "available": false }
]
}
slots is stepped by the room's slot_minutes and each slot is as long as the room's
minimum_booking_minutes. Slots that have already passed are marked unavailable. In the day view,
top-level available means "at least one slot is still free today".
Example: meeting-room window check
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/availability?inventory_id=5&date=2026-07-01&time=14:00&duration_minutes=60" \
-H "Authorization: Bearer <access_token>"
{
"inventory_id": 5,
"inventory_type": "meeting_room",
"available": true,
"reason": null,
"requested": {
"start_time": "2026-07-01T14:00:00+05:30",
"end_time": "2026-07-01T15:00:00+05:30",
"duration_minutes": 60
},
"price": {
"pre_tax_amount": "800.00",
"tax_amount": "144.00",
"tax_rate_pct": "18.00",
"amount": "944.00",
"currency": "INR"
},
"outside_opening_hours": false
}
(Trimmed. The full response also carries the campus, operator, rules, booked_slots and slots
fields shown in the day view.)
Example: day-pass check
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/availability?inventory_id=9&date=2026-07-01&seats=2" \
-H "Authorization: Bearer <access_token>"
{
"inventory_id": 9,
"inventory_type": "day_pass",
"name": "Hot Desk Zone",
"campus_id": 12,
"date": "2026-07-01",
"timezone": "Asia/Kolkata",
"currency": "INR",
"available": true,
"reason": null,
"requested": { "seats": 2 },
"total_seats": 40,
"available_seats": 14,
"price": {
"pre_tax_amount": "1000.00",
"tax_amount": "180.00",
"tax_rate_pct": "18.00",
"amount": "1180.00",
"unit_price": "500.00",
"currency": "INR"
},
"rules": {
"space_type": "Hot Desk",
"floor": "1",
"status": "active",
"day_pass_available": true,
"min_bookable_seats": 1,
"max_bookable_seats": null
},
"campus_hours": { "opens": "09:00", "closes": "19:00", "is_closed": false, "is_holiday": false, "holiday_name": null, "configured": true }
}
Why something is unavailable
When available is false, reason tells you why in a machine-readable form, so you can show the
customer something better than a generic error.
reason | Meaning |
|---|---|
slot_taken | The requested window is already held by another booking. |
fully_booked | Day view only: no slot is left on that date. |
insufficient_seats | Fewer seats are free than the seats you asked for. Compare available_seats. |
campus_closed | The campus is shut that day (weekly off or a holiday). Check campus_hours. |
in_the_past | The requested date or slot has already passed. |
below_minimum_duration | Shorter than the room's minimum_booking_minutes. |
above_maximum_duration | Longer than the room's maximum_booking_minutes. |
inventory_inactive | The operator has deactivated this room or desk area. |
day_pass_not_enabled | This desk area is not sold as day passes. |
no_day_pass_plan | The campus has no active day-pass plan configured. |
outside_opening_hours is advisory rather than blocking: some operators do allow bookings outside
posted hours, so a true here does not by itself make a slot unavailable.
The price quoted here is produced by the same code that charges the booking, and slot availability is
evaluated with the same rules, so a quote will not drift from the actual booking. Availability is
still a snapshot: another customer can take the slot in between. Always treat the create-booking
response as final and handle a 400 gracefully.
6. Create a booking
POST /api/v1/integrations/aggregator/v1/bookings
Creates a meeting-room or day-pass booking. Depending on the operator's settings the booking
is either confirmed immediately (status: "confirmed") or held for staff approval
(status: "pending_approval").
Scope: bookings:write
Common fields
| Field | Type | Required | Description |
|---|---|---|---|
booking_type | enum | Yes | meeting_room or day_pass. |
campus_id | integer | Yes | Target campus (from the campuses/inventory responses). |
resource_id | integer | Yes | Meeting room id (for meeting_room) or space space_id (for day_pass). |
guest | object | Yes | The end customer (see below). |
external_ref | string | Yes | Your booking reference. Returned in responses and callbacks for reconciliation. |
idempotency_key | string | Yes | Unique per booking on your side. Re-sending the same key returns the original booking instead of creating a duplicate. |
guest object
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The customer's email. Used to link/return the guest profile on the operator side. |
name | string | Recommended | Full name of the customer. |
phone | string | No | Contact number. |
Meeting-room fields
| Field | Type | Required | Description |
|---|---|---|---|
start_time | datetime (ISO-8601) | Yes | Start of the booking. Include timezone offset. |
end_time | datetime (ISO-8601) | Yes | End of the booking. Must be after start_time. |
Day-pass fields
| Field | Type | Required | Description |
|---|---|---|---|
date | date (YYYY-MM-DD) | Yes | The day the pass is for. |
seats | integer | No (default 1) | Number of seats/passes. |
Example: meeting room
{
"booking_type": "meeting_room",
"campus_id": 12,
"resource_id": 5,
"start_time": "2026-07-01T10:00:00+05:30",
"end_time": "2026-07-01T11:00:00+05:30",
"guest": { "name": "Riya Sharma", "email": "riya@example.com", "phone": "+919990001111" },
"external_ref": "GF-98231",
"idempotency_key": "GF-98231"
}
Example: day pass
{
"booking_type": "day_pass",
"campus_id": 12,
"resource_id": 9,
"date": "2026-07-01",
"seats": 2,
"guest": { "name": "Amit Verma", "email": "amit@example.com" },
"external_ref": "GF-98232",
"idempotency_key": "GF-98232"
}
Success response
Status: 201 Created (new booking) or 200 OK (idempotent repeat)
{
"booking_id": "9b1f0c2e-7a8d-4c1a-9f3e-2b6d5a4c1e90",
"external_ref": "GF-98231",
"booking_type": "meeting_room",
"status": "confirmed",
"campus_id": 12,
"amount": 944.0,
"commission_amount": 94.4,
"currency": "INR",
"created_at": "2026-06-29T08:30:00.000000Z"
}
| Field | Description |
|---|---|
booking_id | DeskOS booking UUID. Use it together with external_ref to reconcile. |
status | confirmed (auto-confirm operators) or pending_approval (approval operators). |
amount | Total amount including tax, in currency. |
commission_amount | Your commission on this booking, per the operator's configured rate. |
If status is pending_approval, the operator's staff must approve the booking. You'll receive a
callback when it becomes confirmed or rejected. You can also poll the Get a booking
endpoint. Operators may set an auto-expiry window after which an unactioned booking is auto-rejected.
7. Booking history
GET /api/v1/integrations/aggregator/v1/bookings
Lists your own bookings, past and current, newest first. Built for reporting, reconciliation and customer-service lookups: filter by date range, status, operator or campus, and read the money totals straight off the response.
Scope: bookings:read or bookings:write
The list is scoped to your partner account at the database level. You can only ever read back bookings you yourself pushed, never another aggregator's and never an operator's direct bookings.
Query parameters
| Parameter | Description |
|---|---|
status | Filter by status. Repeat the parameter or pass a comma-separated list: ?status=confirmed,cancelled. |
booking_type | meeting_room and/or day_pass. Same repeat/comma rules. |
campus_id | Restrict to one campus. |
operator_id | Restrict to one operator (organization_id also accepted). |
external_ref | Look up one booking by your own reference. |
created_from / created_to | YYYY-MM-DD. When the booking was pushed to DeskOS. from / to are accepted as short aliases. |
date_from / date_to | YYYY-MM-DD. When the space is actually used (the day-pass date or the meeting-room date). |
page | 1-based page number. Default 1. |
page_size | Rows per page. Default 50, maximum 200. |
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/bookings?status=confirmed&date_from=2026-07-01&date_to=2026-07-31&page_size=100" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
{
"count": 2,
"page": 1,
"page_size": 100,
"total_pages": 1,
"has_next": false,
"totals": [
{
"currency": "INR",
"confirmed_bookings": 2,
"gross_amount": "1534.00",
"commission_amount": "153.40",
"net_payable": "1380.60"
}
],
"bookings": [
{
"booking_id": "9b1f0c2e-7a8d-4c1a-9f3e-2b6d5a4c1e90",
"external_ref": "GF-98231",
"booking_type": "meeting_room",
"status": "confirmed",
"campus_id": 12,
"campus_name": "Whitefield Hub",
"operator_id": 1,
"operator_name": "CoWork Solutions",
"inventory_id": 5,
"seats": 1,
"date": null,
"start_time": "2026-07-01T10:00:00+05:30",
"end_time": "2026-07-01T11:00:00+05:30",
"guest": { "name": "Riya Sharma", "email": "riya@example.com", "phone": "+919990001111" },
"amount": 944.0,
"commission_amount": 94.4,
"net_payable": "849.60",
"currency": "INR",
"approved_at": "2026-06-29T09:05:00+00:00",
"rejected_reason": null,
"hold_expires_at": null,
"internal_reference": "meeting_room_booking:8891",
"created_at": "2026-06-29T08:30:00.000000Z",
"updated_at": "2026-06-29T09:05:00.000000Z"
}
]
}
totals covers confirmed bookings in the filtered set, grouped by currency, so a
multi-currency portfolio reconciles per currency rather than in one meaningless sum. It reflects your
filters: narrow to a month and an operator and you get exactly that month's settlement figure for
that operator.
| Field | Description |
|---|---|
gross_amount | Sum of amount (inclusive of tax) across confirmed bookings. |
commission_amount | Sum of your commission. |
net_payable | gross_amount minus commission_amount: what settles to the operator. |
internal_reference | The operator-side record this booking became. Quote it when raising a support query with an operator. |
date | Set for day passes. null for meeting rooms, which carry start_time / end_time instead. |
For a month-end statement with one operator, call
?status=confirmed&operator_id=<id>&date_from=2026-07-01&date_to=2026-07-31 and read totals. Then
page through bookings for the line-item detail behind that number.
8. Get a booking
GET /api/v1/integrations/aggregator/v1/bookings/{external_ref}
Returns the current state of a single booking by your reference. Use this to poll the status of an approval-mode booking if you can't receive callbacks.
Scope: bookings:read or bookings:write
Request
curl -X GET "https://<your-server-url>/api/v1/integrations/aggregator/v1/bookings/GF-98231" \
-H "Authorization: Bearer <access_token>"
Response: 200 OK
Returns the same detailed shape as a row in Booking history:
{
"booking_id": "9b1f0c2e-7a8d-4c1a-9f3e-2b6d5a4c1e90",
"external_ref": "GF-98231",
"booking_type": "meeting_room",
"status": "pending_approval",
"campus_id": 12,
"campus_name": "Whitefield Hub",
"operator_id": 1,
"operator_name": "CoWork Solutions",
"inventory_id": 5,
"seats": 1,
"date": null,
"start_time": "2026-07-01T10:00:00+05:30",
"end_time": "2026-07-01T11:00:00+05:30",
"guest": { "name": "Riya Sharma", "email": "riya@example.com", "phone": "+919990001111" },
"amount": 944.0,
"commission_amount": 94.4,
"net_payable": "849.60",
"currency": "INR",
"approved_at": null,
"rejected_reason": null,
"hold_expires_at": "2026-06-29T10:30:00+00:00",
"internal_reference": null,
"created_at": "2026-06-29T08:30:00.000000Z",
"updated_at": "2026-06-29T08:30:00.000000Z"
}
These extra fields were added to this response; every field the create call returns is still present and unchanged. Existing integrations reading only the original fields keep working as-is.
hold_expires_at is set on approval-mode bookings that have an auto-expiry window: if staff have not
actioned the booking by then, it is auto-rejected.
9. Cancel a booking
POST /api/v1/integrations/aggregator/v1/bookings/{external_ref}/cancel
Cancels a booking by your reference. Works for both pending and confirmed bookings. Cancelling a booking that is already cancelled or rejected is a no-op.
Scope: bookings:cancel
Body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Free-text cancellation reason, stored on the operator side. |
Request
curl -X POST "https://<your-server-url>/api/v1/integrations/aggregator/v1/bookings/GF-98231/cancel" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer cancelled" }'
Response: 200 OK
{
"booking_id": "9b1f0c2e-7a8d-4c1a-9f3e-2b6d5a4c1e90",
"external_ref": "GF-98231",
"booking_type": "meeting_room",
"status": "cancelled",
"campus_id": 12,
"amount": 944.0,
"commission_amount": 0.0,
"currency": "INR",
"created_at": "2026-06-29T08:30:00.000000Z"
}
On cancellation, the booking is released on the operator side and the commission is reversed
(commission_amount becomes 0.0). Operator cancellation policies may still apply for
already-confirmed bookings.
Booking lifecycle
create ─┬─ (auto-confirm operator) ─────────────► confirmed ──► (cancel) ──► cancelled
│
└─ (approval operator) ──► pending_approval ─┬─ staff approves ──► confirmed
├─ staff rejects ──► rejected
└─ window expires ──► rejected
| Status | Meaning |
|---|---|
confirmed | The booking is live on the operator side. |
pending_approval | Awaiting operator staff approval. |
rejected | Staff declined, or the approval window expired. |
cancelled | Cancelled (by you via the cancel endpoint, or by the operator). |
failed | The booking could not be materialized. Rare; investigate or retry with a new reference. |
Status callbacks (webhooks)
When a booking changes to a final state, DeskOS sends an HTTP POST to the callback URL you
configured during onboarding. This is how you learn the outcome of approval-mode bookings without
polling.
The header names and payload shape below are the DeskOS default. If your platform requires a different signature scheme or payload, share your spec with the DeskOS team during onboarding and we will align to it.
Delivered events: confirmed, rejected, cancelled.
Request DeskOS sends to you
POST <your callback URL>
Content-Type: application/json
X-DeskOS-Event: confirmed
X-DeskOS-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the shared secret>
{
"event": "confirmed",
"booking_id": "9b1f0c2e-7a8d-4c1a-9f3e-2b6d5a4c1e90",
"external_ref": "GF-98231",
"booking_type": "meeting_room",
"status": "confirmed",
"amount": "944.00",
"currency": "INR",
"timestamp": "2026-06-29T09:05:00.000000+00:00"
}
Verifying the signature
Compute an HMAC-SHA256 over the raw request body using your shared callback secret, and compare
it (constant-time) to the hex digest in X-DeskOS-Signature (after the sha256= prefix).
import hmac, hashlib
def is_valid(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
Acknowledging & retries
- Respond with any
2xxstatus to acknowledge. - Any non-
2xx(or a timeout) is retried with exponential backoff, up to 5 attempts. - Make your handler idempotent: the same event may be delivered more than once. Use
external_ref+eventto de-duplicate.
Settlement & commission
Bookings made through the API are not charged to the end customer through DeskOS. Instead:
- Each booking is recorded in the operator's DeskOS account, tagged with your platform name and
your
external_ref, and billed to a dedicated "<Your Platform> Bookings" account on the operator side (business-to-business). - Each confirmed booking returns a
commission_amountbased on the rate the operator configured for your platform. - You and the operator settle periodically out-of-band. The
amount(gross, incl. tax) andcommission_amounton each confirmed booking are your source of truth for reconciliation.
Store booking_id, external_ref, amount, and commission_amount for every confirmed booking.
That's everything you need to reconcile statements with each operator.
Error responses
All errors return a JSON body. Business/validation errors use {"error": "..."}; auth and
permission errors use {"detail": "..."}.
400: Bad request / validation
{ "error": "end_time must be after start_time." }
{ "error": "This time slot is not available." }
{ "error": "No active day-pass plan configured for this campus." }
401: Authentication failed
Returned by the token endpoint for bad credentials:
{ "error": "invalid_client" }
Returned by other endpoints for a missing, malformed, or expired token:
{ "detail": "Token expired." }
403: Missing scope
{ "detail": "Missing required scope: bookings:write" }
404: Not found / not enabled
Returned when a resource doesn't exist or when the operator has not enabled your integration (or not exposed that campus). DeskOS deliberately does not distinguish the two, to avoid leaking the existence of operators who haven't opted in.
{ "detail": "Resource not found." }
429: Rate limited
{ "detail": "Request was throttled. Expected available in 12 seconds." }
OAuth token errors
| Body | Meaning |
|---|---|
{"error": "unsupported_grant_type"} | grant_type must be client_credentials. |
{"error": "invalid_request"} | client_id / client_secret missing. |
{"error": "invalid_client"} | Wrong credentials or inactive client. |
{"error": "invalid_scope"} | You requested a scope you weren't granted. |
Rate limiting
The API is rate limited per token/IP. Default ceilings:
| Bucket | Default limit |
|---|---|
Token requests (/oauth/token) | 60 / minute |
| Read endpoints (operators, cities, campuses, inventory, availability, booking reads) | 6,000 / hour |
| Write endpoints (create, cancel) | 600 / hour |
GET /bookings and POST /bookings share a path but not a bucket: the read counts against the read
limit and only the create counts against the write limit.
Exceeding a limit returns 429. Cache access tokens and inventory responses to stay well under these
ceilings. If you run high-volume campaigns and need higher limits, contact the DeskOS team.
End-to-end walkthrough
cURL
BASE="https://<your-server-url>/api/v1/integrations/aggregator/v1"
# 1. Get a token
TOKEN=$(curl -s -X POST "$BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" | jq -r .access_token)
# 2. Discover inventory
curl -s "$BASE/operators" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/cities" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/campuses?operator_id=1&city=Bengaluru" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/campuses/12/inventory" -H "Authorization: Bearer $TOKEN"
# 3. Check the slot before offering it
curl -s "$BASE/availability?inventory_id=5&date=2026-07-01&time=10:00&duration_minutes=60" \
-H "Authorization: Bearer $TOKEN"
# 4. Create a meeting-room booking
curl -s -X POST "$BASE/bookings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"booking_type": "meeting_room",
"campus_id": 12,
"resource_id": 5,
"start_time": "2026-07-01T10:00:00+05:30",
"end_time": "2026-07-01T11:00:00+05:30",
"guest": { "name": "Riya Sharma", "email": "riya@example.com" },
"external_ref": "GF-98231",
"idempotency_key": "GF-98231"
}'
# 5. Cancel it
curl -s -X POST "$BASE/bookings/GF-98231/cancel" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "Customer cancelled" }'
# 6. Reconcile the month
curl -s "$BASE/bookings?status=confirmed&date_from=2026-07-01&date_to=2026-07-31" \
-H "Authorization: Bearer $TOKEN"
Python
import requests
BASE = "https://<your-server-url>/api/v1/integrations/aggregator/v1"
# 1. Token
tok = requests.post(f"{BASE}/oauth/token", data={
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
}).json()["access_token"]
H = {"Authorization": f"Bearer {tok}"}
# 2. Discover
operators = requests.get(f"{BASE}/operators", headers=H).json()["operators"]
campuses = requests.get(f"{BASE}/campuses", headers=H, params={
"operator_id": operators[0]["operator_id"], "city": "Bengaluru",
}).json()
inventory = requests.get(f"{BASE}/campuses/12/inventory", headers=H).json()
# 3. Check availability before showing a price
avail = requests.get(f"{BASE}/availability", headers=H, params={
"inventory_id": 9, "date": "2026-07-01", "seats": 2,
}).json()
if not avail["available"]:
raise SystemExit(f"Not bookable: {avail['reason']}")
price = avail["price"]["amount"]
# 4. Book a day pass
booking = requests.post(f"{BASE}/bookings", headers=H, json={
"booking_type": "day_pass",
"campus_id": 12,
"resource_id": 9,
"date": "2026-07-01",
"seats": 2,
"guest": {"name": "Amit Verma", "email": "amit@example.com"},
"external_ref": "GF-98232",
"idempotency_key": "GF-98232",
}).json()
# 5. Poll status (for approval-mode operators)
status = requests.get(f"{BASE}/bookings/GF-98232", headers=H).json()["status"]
# 6. Month-end reconciliation
statement = requests.get(f"{BASE}/bookings", headers=H, params={
"status": "confirmed", "date_from": "2026-07-01", "date_to": "2026-07-31",
"page_size": 200,
}).json()
for total in statement["totals"]:
print(total["currency"], total["gross_amount"], total["net_payable"])
Node.js
const BASE = "https://<your-server-url>/api/v1/integrations/aggregator/v1";
const form = new URLSearchParams({
grant_type: "client_credentials",
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
});
const { access_token } = await (await fetch(`${BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
})).json();
const H = { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" };
const { operators } = await (await fetch(`${BASE}/operators`, { headers: H })).json();
const inventory = await (await fetch(`${BASE}/campuses/12/inventory`, { headers: H })).json();
const avail = await (await fetch(
`${BASE}/availability?inventory_id=5&date=2026-07-01&time=10:00&duration_minutes=60`,
{ headers: H },
)).json();
if (!avail.available) throw new Error(`Not bookable: ${avail.reason}`);
const booking = await (await fetch(`${BASE}/bookings`, {
method: "POST",
headers: H,
body: JSON.stringify({
booking_type: "meeting_room",
campus_id: 12,
resource_id: 5,
start_time: "2026-07-01T10:00:00+05:30",
end_time: "2026-07-01T11:00:00+05:30",
guest: { name: "Riya Sharma", email: "riya@example.com" },
external_ref: "GF-98231",
idempotency_key: "GF-98231",
}),
})).json();
Best practices
- Cache your access token. Request one token and reuse it until shortly before
expires_in. Don't fetch a token per request. - Always send
idempotency_keyandexternal_ref. Network retries are inevitable; idempotency keys prevent duplicate bookings, andexternal_refties everything back to your system. - Call availability before checkout, not instead of booking. Use Check availability to build
your date/time picker and to validate the customer's selection, then re-check right before you
collect payment. Availability is a snapshot; the create call is authoritative, so still handle
400 not availablegracefully. - Walk operators to campuses. Start from List operators rather than pinning a single operator
ID. Operators join and leave, and their
campus_countchanges as they expose more campuses. - Implement the callback handler. It's the cleanest way to learn approval outcomes. Verify the signature and make the handler idempotent.
- Have a polling fallback. If you can't guarantee inbound webhooks, poll Get a booking for
pending_approvalbookings on a sensible interval. - Respect rate limits. Cache reads (operators/cities/campuses/inventory) for a few minutes. Availability is deliberately cheap to call, but don't cache it: a stale yes is worse than an extra request.
- Reconcile from
totals, not by adding up rows. Booking history already returns per-currency gross, commission and net for your filtered set. - Send the customer's email. It links the booking to a guest profile on the operator side and improves the on-site check-in experience.
- Test on staging first. Validate the full loop (token, operators, inventory, availability, auto-confirm booking, approval-mode booking, cancellation, history, and a callback) before going to production.
Frequently asked questions
Q: Do we get separate credentials for each coworking operator? A: No. You receive one set of credentials and automatically see every operator who has enabled your integration. Operator opt-in (not your keys) controls what you can see.
Q: How do we handle spaces across different operators?
A: Call GET /operators. It returns every operator who has enabled your integration, and the
operator_id it gives you filters GET /campuses?operator_id=. There is no default operator to
hardcode.
Q: Can we check availability without creating a booking?
A: Yes, that's GET /availability. It reads only, holds nothing, and returns both a yes/no and the
price the booking would cost. The create endpoint still re-checks at booking time, so treat
availability as a snapshot for your UI rather than a reservation.
Q: How do we tell a meeting room apart from a desk area in inventory_id?
A: You don't have to. Meeting-room IDs and space IDs come from a single ID space and never collide,
so GET /availability?inventory_id=… resolves the right kind on its own and tells you which it was
in inventory_type.
Q: How do we pull past bookings for reporting or a customer-service query?
A: GET /bookings, filtered by date range, status, operator or campus, or by external_ref for a
single lookup. It is scoped to your own bookings and carries per-currency totals for reconciliation.
Q: Do we need new credentials for the operators, availability, or history endpoints?
A: No. They map onto scopes you already hold (campuses:read, inventory:read, bookings:write).
bookings:read is only needed if you want a separate read-only credential for a reporting system.
Q: Why does a campus return 404 even though I know it exists?
A: Because that operator hasn't enabled your integration, or hasn't exposed that campus to you. We
return 404 rather than 403 so the existence of non-participating operators isn't leaked.
Q: What's the difference between auto-confirm and approval mode?
A: It's the operator's choice, set per booking type. Auto-confirm returns confirmed immediately.
Approval mode returns pending_approval; staff then approve or reject, and you get a callback.
Q: How do we handle approval-mode bookings if we can't receive webhooks?
A: Poll GET /bookings/{external_ref} until the status is no longer pending_approval.
Q: Are virtual offices bookable through the API?
A: Not currently. They appear in inventory as listing-only with pricing: "on_request" so you can
display them and route enquiries, but bookings are arranged directly with the operator.
Q: In what currency are amounts?
A: The campus currency, returned on every campus and inventory response (e.g. INR, USD, GBP,
AED, SGD). Booking amount is inclusive of tax.
Q: How does payment work?
A: Through this API, DeskOS records the booking and bills the operator's "<Your Platform>
Bookings" account on a B2B basis; it does not charge the end customer. You collect from the customer
on your side and settle with the operator using the reported amount and commission_amount.
Q: What happens if I send the same idempotency_key twice?
A: You get back the original booking (200 OK) instead of a duplicate. Safe to retry.
Q: Can a confirmed booking be cancelled? A: Yes, via the cancel endpoint. The operator's cancellation policy may still apply, and the commission is reversed.
Q: We lost our client secret. Can DeskOS resend it? A: No. Secrets are stored hashed. The DeskOS team will rotate your credentials and issue a new one.
Quick reference
| Action | Method | Endpoint | Scope |
|---|---|---|---|
| Get access token | POST | /api/v1/integrations/aggregator/v1/oauth/token | n/a |
| List operators | GET | /api/v1/integrations/aggregator/v1/operators | campuses:read |
| List cities | GET | /api/v1/integrations/aggregator/v1/cities | cities:read |
| List campuses | GET | /api/v1/integrations/aggregator/v1/campuses | campuses:read |
| Get inventory | GET | /api/v1/integrations/aggregator/v1/campuses/{campus_id}/inventory | inventory:read |
| Check availability | GET | /api/v1/integrations/aggregator/v1/availability | inventory:read |
| Create booking | POST | /api/v1/integrations/aggregator/v1/bookings | bookings:write |
| Booking history | GET | /api/v1/integrations/aggregator/v1/bookings | bookings:read or bookings:write |
| Get booking | GET | /api/v1/integrations/aggregator/v1/bookings/{external_ref} | bookings:read or bookings:write |
| Cancel booking | POST | /api/v1/integrations/aggregator/v1/bookings/{external_ref}/cancel | bookings:cancel |
| Booking status | Meaning |
|---|---|
confirmed | Live on the operator side |
pending_approval | Awaiting staff approval |
rejected | Declined or expired |
cancelled | Cancelled |
failed | Could not be created |
| Status code | Meaning |
|---|---|
200 | OK (read, cancel, idempotent re-create) |
201 | Booking created |
400 | Validation / business error ({"error": ...}) |
401 | Missing/invalid/expired token |
403 | Missing required scope |
404 | Not found or operator not opted in |
429 | Rate limit exceeded |
| Callback event | Header X-DeskOS-Event | Sent when |
|---|---|---|
| Confirmed | confirmed | Auto-confirmed or staff-approved |
| Rejected | rejected | Staff rejected or approval window expired |
| Cancelled | cancelled | Booking cancelled |
Getting help
For credentials, scope changes, callback configuration, rate-limit increases, or help testing your integration, reach out to the DeskOS team:
- Email: sales@deskos.net
- Phone: +91 742810 9069