Skip to main content

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.

Postman collection

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:

  1. The DeskOS team issues your aggregator a single set of OAuth client credentials (a client_id and client_secret).
  2. 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.
  3. You call the API to discover operators, cities, campuses, and live inventory, check availability for a date or time slot, then push bookings.
  4. 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.
  5. 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.
Direction of the integration

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 typeExamples
On-demand workspace marketplacesGoFloaters, Deskpass, Upflex, LiquidSpace
Space discovery & booking platformsCoworker.com, Workfrom, Desana
Corporate flex-space programsUpflex 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​

TermMeaning
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.
CampusA physical location/center belonging to an organization. Inventory and bookings are per campus.
InventoryThe sellable assets at a campus, split into meeting rooms, seats / day passes, and virtual offices.
Booking modeEach operator chooses, per booking type, whether your bookings are auto-confirmed or require approval.
External referenceYour booking ID, sent on every booking so both sides can reconcile.
CommissionA percentage configured per operator, reported back to you on confirmed bookings for settlement.
CallbackA signed webhook DeskOS sends you when a booking is confirmed, rejected, or cancelled.

Onboarding flow​

  1. Get credentials. The DeskOS team creates your aggregator profile and issues your client_id and client_secret. The secret is shown once, so store it securely.
  2. 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.
  3. 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.
  4. Go live. Authenticate, read inventory for the operators who opted in, and start booking.
tip

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​

EnvironmentPurposeServer URL
Test / StagingDevelopment and testingProvided by the DeskOS team
ProductionLive inventory and bookingsProvided by the DeskOS team
note

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

ParameterRequiredDescription
grant_typeYesMust be client_credentials.
client_idYesYour client ID.
client_secretYesYour client secret.
scopeNoSpace-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_in seconds (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.

ScopeGrants access to
cities:readList cities
campuses:readList operators, list campuses
inventory:readRead campus inventory, check availability
bookings:writeCreate bookings (and read them back)
bookings:readRead bookings and booking history, without the ability to create
bookings:cancelCancel bookings
Reading 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.

Keep your secret safe

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​

TopicConvention
AuthAuthorization: Bearer <access_token> on every endpoint except the token endpoint.
Content typeSend application/json on POST bodies.
IDsoperator_id, campus_id and resource IDs are integers. booking_id is a UUID string.
Inventory IDsMeeting-room IDs and space IDs share one ID space and never collide, so inventory_id alone identifies which kind of inventory you mean.
TimesAvailability parameters (date, time) are campus-local. Responses return timezone-aware ISO-8601 with the campus offset.
MoneyDecimal amounts in the campus currency (see each campus/inventory response). Tax is included in booking amount.
CurrencyISO 4217 code (e.g. INR, USD, GBP, AED, SGD).
TimestampsISO-8601. Send timezone-aware datetimes (include the offset).
IdempotencyEvery booking write must carry an idempotency_key (see Bookings).
VisibilityOnly 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"]
}
]
}
FieldDescription
operator_idThe operator's ID. Pass it to GET /campuses?operator_id=.
organization_idThe same value under its original name. Both are returned so either naming works.
slugThe operator's DeskOS slug (their workspace lives at <slug>.deskos.net).
campus_countHow many campuses this operator has exposed to you.
cities / currenciesDistinct values across the campuses exposed to you, sorted.
Exposed campuses only

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

ParameterRequiredDescription
cityNoFilter to a single city (case/space-insensitive).
operator_idNoFilter to a single operator (from List operators).
organization_idNoAlias 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

SectionWhat it isBookable via this API?
meeting_roomsBookable rooms charged by the hour. Use id as the resource_id for a meeting-room booking.✅ Yes
seatsDay-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_officesVirtual-office packages. Listing-only: pricing is on_request and these are not booked through this API.❌ Listing only
Availability

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.

Inventory IDs

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

ParameterRequiredApplies toDescription
inventory_idYesBothMeeting-room id or seat space_id from the inventory response.
dateNoBothYYYY-MM-DD. Defaults to today in the campus's timezone.
timeNoMeeting roomsStart of the slot you want, HH:MM (campus-local). Omit for a whole-day view.
duration_minutesNoMeeting roomsLength of the slot. Defaults to the room's minimum booking time.
end_timeNoMeeting roomsExplicit slot end, HH:MM. Use instead of duration_minutes.
seatsNoDay passesHow many passes you want to sell. Defaults to 1.

Two ways to call it​

You sendYou get back
inventory_id + dateDay 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 + timeWindow 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.

reasonMeaning
slot_takenThe requested window is already held by another booking.
fully_bookedDay view only: no slot is left on that date.
insufficient_seatsFewer seats are free than the seats you asked for. Compare available_seats.
campus_closedThe campus is shut that day (weekly off or a holiday). Check campus_hours.
in_the_pastThe requested date or slot has already passed.
below_minimum_durationShorter than the room's minimum_booking_minutes.
above_maximum_durationLonger than the room's maximum_booking_minutes.
inventory_inactiveThe operator has deactivated this room or desk area.
day_pass_not_enabledThis desk area is not sold as day passes.
no_day_pass_planThe 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.

Availability and booking agree

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​

FieldTypeRequiredDescription
booking_typeenumYesmeeting_room or day_pass.
campus_idintegerYesTarget campus (from the campuses/inventory responses).
resource_idintegerYesMeeting room id (for meeting_room) or space space_id (for day_pass).
guestobjectYesThe end customer (see below).
external_refstringYesYour booking reference. Returned in responses and callbacks for reconciliation.
idempotency_keystringYesUnique per booking on your side. Re-sending the same key returns the original booking instead of creating a duplicate.

guest object

FieldTypeRequiredDescription
emailstringYesThe customer's email. Used to link/return the guest profile on the operator side.
namestringRecommendedFull name of the customer.
phonestringNoContact number.

Meeting-room fields​

FieldTypeRequiredDescription
start_timedatetime (ISO-8601)YesStart of the booking. Include timezone offset.
end_timedatetime (ISO-8601)YesEnd of the booking. Must be after start_time.

Day-pass fields​

FieldTypeRequiredDescription
datedate (YYYY-MM-DD)YesThe day the pass is for.
seatsintegerNo (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"
}
FieldDescription
booking_idDeskOS booking UUID. Use it together with external_ref to reconcile.
statusconfirmed (auto-confirm operators) or pending_approval (approval operators).
amountTotal amount including tax, in currency.
commission_amountYour commission on this booking, per the operator's configured rate.
Approval mode

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

Scoped to you

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

ParameterDescription
statusFilter by status. Repeat the parameter or pass a comma-separated list: ?status=confirmed,cancelled.
booking_typemeeting_room and/or day_pass. Same repeat/comma rules.
campus_idRestrict to one campus.
operator_idRestrict to one operator (organization_id also accepted).
external_refLook up one booking by your own reference.
created_from / created_toYYYY-MM-DD. When the booking was pushed to DeskOS. from / to are accepted as short aliases.
date_from / date_toYYYY-MM-DD. When the space is actually used (the day-pass date or the meeting-room date).
page1-based page number. Default 1.
page_sizeRows 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.

FieldDescription
gross_amountSum of amount (inclusive of tax) across confirmed bookings.
commission_amountSum of your commission.
net_payablegross_amount minus commission_amount: what settles to the operator.
internal_referenceThe operator-side record this booking became. Quote it when raising a support query with an operator.
dateSet for day passes. null for meeting rooms, which carry start_time / end_time instead.
Reconciliation recipe

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"
}
Additive change

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

FieldTypeRequiredDescription
reasonstringNoFree-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"
}
note

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
StatusMeaning
confirmedThe booking is live on the operator side.
pending_approvalAwaiting operator staff approval.
rejectedStaff declined, or the approval window expired.
cancelledCancelled (by you via the cancel endpoint, or by the operator).
failedThe 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.

Contract confirmation

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 2xx status 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 + event to 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_amount based on the rate the operator configured for your platform.
  • You and the operator settle periodically out-of-band. The amount (gross, incl. tax) and commission_amount on each confirmed booking are your source of truth for reconciliation.
tip

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​

BodyMeaning
{"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:

BucketDefault 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​

  1. Cache your access token. Request one token and reuse it until shortly before expires_in. Don't fetch a token per request.
  2. Always send idempotency_key and external_ref. Network retries are inevitable; idempotency keys prevent duplicate bookings, and external_ref ties everything back to your system.
  3. 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 available gracefully.
  4. Walk operators to campuses. Start from List operators rather than pinning a single operator ID. Operators join and leave, and their campus_count changes as they expose more campuses.
  5. Implement the callback handler. It's the cleanest way to learn approval outcomes. Verify the signature and make the handler idempotent.
  6. Have a polling fallback. If you can't guarantee inbound webhooks, poll Get a booking for pending_approval bookings on a sensible interval.
  7. 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.
  8. Reconcile from totals, not by adding up rows. Booking history already returns per-currency gross, commission and net for your filtered set.
  9. 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.
  10. 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​

ActionMethodEndpointScope
Get access tokenPOST/api/v1/integrations/aggregator/v1/oauth/tokenn/a
List operatorsGET/api/v1/integrations/aggregator/v1/operatorscampuses:read
List citiesGET/api/v1/integrations/aggregator/v1/citiescities:read
List campusesGET/api/v1/integrations/aggregator/v1/campusescampuses:read
Get inventoryGET/api/v1/integrations/aggregator/v1/campuses/{campus_id}/inventoryinventory:read
Check availabilityGET/api/v1/integrations/aggregator/v1/availabilityinventory:read
Create bookingPOST/api/v1/integrations/aggregator/v1/bookingsbookings:write
Booking historyGET/api/v1/integrations/aggregator/v1/bookingsbookings:read or bookings:write
Get bookingGET/api/v1/integrations/aggregator/v1/bookings/{external_ref}bookings:read or bookings:write
Cancel bookingPOST/api/v1/integrations/aggregator/v1/bookings/{external_ref}/cancelbookings:cancel
Booking statusMeaning
confirmedLive on the operator side
pending_approvalAwaiting staff approval
rejectedDeclined or expired
cancelledCancelled
failedCould not be created
Status codeMeaning
200OK (read, cancel, idempotent re-create)
201Booking created
400Validation / business error ({"error": ...})
401Missing/invalid/expired token
403Missing required scope
404Not found or operator not opted in
429Rate limit exceeded
Callback eventHeader X-DeskOS-EventSent when
ConfirmedconfirmedAuto-confirmed or staff-approved
RejectedrejectedStaff rejected or approval window expired
CancelledcancelledBooking cancelled

Getting help​

For credentials, scope changes, callback configuration, rate-limit increases, or help testing your integration, reach out to the DeskOS team: