Skip to main content

Plans, Passes & Credits API

This section of the DeskOS API lets an outside system sell to members and manage their meeting room credits: read what is on sale, price it, sell a day pass, a visit pass or a plan, cancel it, and read, add, remove and allocate meeting room credits.

It is built for a tenant that runs its own member app or website on top of DeskOS, and equally for an automation tool, a CRM or an AI agent.

Everything sold here appears in the DeskOS admin console exactly as if a staff member had sold it, with the same prices, the same tax, the same inclusions (credits, door access, WiFi and so on) and the same admin notifications.

Read Authentication first

Getting a key, sending it, campus rules, the response shape and the errors every section shares are all on the Authentication page. This page only covers what is specific to plans, passes and credits.


How payment works​

Nothing sold through this API takes a card payment. A sale is active immediately, and its price is recorded as a bill-to-account charge on the member's company. The operator invoices that charge with the rest of the company's charges, which is how rooms booked through the Meeting Rooms API are already billed.

If you take payment in your own app, you can still use this API: sell through it once your payment succeeds, and tell the operator so they can mark the charge paid when they invoice it.

Online payment through DeskOS's own checkout is not part of this API yet.


Base path​

All paths on this page are relative to:

https://oscar.nova.deskos.net/api/v1/plans/external/

Your Developer Settings page shows the exact base URL for your workspace.


Permissions​

These permissions all start with plans:. An admin ticks them per key on the Developer Settings page. A key can only get them if the admin creating it holds Subscriptions: manage. The write permissions also need Billing: create (selling) or Meeting Rooms: settings (credits).

PermissionUnlocks
plans:catalog:readGET plans/, GET plans/<id>/, GET plans/<id>/quote/, GET pass-plans/, GET pass-plans/<id>/, GET pass-plans/<id>/quote/
plans:passes:readGET passes/, GET passes/<id>/
plans:passes:writePOST passes/, POST passes/<id>/cancel/
plans:subscriptions:readGET subscriptions/, GET subscriptions/<id>/
plans:subscriptions:writePOST subscriptions/, POST subscriptions/<id>/cancel/
plans:credits:readGET credits/, GET credits/history/
plans:credits:writePOST credits/adjust/, POST credits/allocate/

Common combinations

  • A shop page that only shows prices: plans:catalog:read.
  • A member app that sells day passes: plans:catalog:read, plans:passes:read, plans:passes:write.
  • A credits top-up flow: plans:credits:read, plans:credits:write.

GET health/ needs no permission and works with any key.


Identifiers and types​

ThingFieldTypeExample
Membermember_idUUID string"8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c"
Companycompany_idinteger42
Campuscampus_idinteger3
Plan (catalog)plan_idinteger17
Pass plan (catalog)pass_plan_idinteger9
A pass that was soldpass.idUUID string"c2a9…"
A plan that was soldsubscription.idUUID string"5e71…"
Bill-to-account chargecharge.idinteger881
  • member_id is the same UUID the Members API returns. Anywhere a member is needed you can send member_email instead.
  • Money is a string with two decimals ("400.00"), always next to a currency code ("INR", "AED", "SGD"...). The currency is the campus's. Never assume a symbol.
  • Credits are strings with two decimals too ("10.00"). Credits can be fractional.
  • Dates are YYYY-MM-DD and are read on the campus calendar. Datetimes are ISO 8601 with an offset.

Campuses​

The general rules are on the Authentication page. For this section:

  • A plan, pass or company on a campus the key cannot reach answers 404 (catalog and detail) or 400 naming the field (in a request body).
  • A campus-restricted key reaches a member whose home campus, or whose company's campus, is one it can reach.
  • List endpoints accept ?campus_id=.

Idempotency​

POST passes/, POST subscriptions/ and POST credits/adjust/ accept an Idempotency-Key header (any string up to 200 characters, such as your own order id).

If the same key repeats the same request within 24 hours, DeskOS returns the first response again with "idempotent_replay": true and does nothing new. If the first request is still running, the repeat answers 409: retry a moment later.

Always send one when you sell. A network timeout then cannot sell a member two passes.


Reference values​

Pass kinds: day_pass (one visit, valid on its start day), visit_pass (several visits within validity_days).

Pass statuses: active, pending, expired, cancelled. Passes sold here start active.

Plan kinds: general (no desks: credits, virtual office, parking, a WiFi policy and so on) and seat (includes desks). Only general plans can be sold through this API. Desk plans need a seat chosen on the floor plan, which happens in the member checkout or the admin console.

Plan holder types: company (the company holds it; send company_id or a member_id of that company) and member (one person holds it; send member_id or member_email).

Plan pricing modes: per_seat (price times quantity, at least minimum_quantity) and flat (one price, quantity is always 1).

Plan purchase statuses: pending, active, expired, cancelled.

Grant types (what a plan or pass includes): seats, meeting_room_credits, cafe_credits, printer_credits, virtual_office, parking, wifi_policy, custom.

Credit log reasons: company ledger subscription_allocation, monthly_reset, booking_deduction, booking_refund, manual_adjustment, subscription_termination; member ledger allocation, booking_deduction, manual_adjustment.

charge_outcome on cancel: cancelled (the charge was voided), already_invoiced (it reached an invoice; the operator issues a credit note), none (the sale was free).


API endpoints​

1. List plans​

GET plans/ (plans:catalog:read)

This one endpoint serves two jobs:

  • The storefront for one member. Pass member_id (or member_email, or company_id) and you get exactly what the DeskOS member app would show that person: every plan that is active, open to purchase and not a desk plan, at every campus that member is allowed to use. This is the call to build your "Plans" screen on.
  • The full catalog. Without a buyer you get every plan the key can reach, including desk plans and plans closed to purchase, so you can mirror the catalog.
QueryTypeNotes
member_id / member_emailUUID / stringStorefront mode for this member. The member must belong to a company (that is who is billed), otherwise 400.
company_idintegerStorefront mode for this company, at the campuses the company may use. With a member_id too, it must be the member's company.
purchasabletrueWithout a buyer: only plans this API can sell right now (active, open to purchase, not desks). Always on in storefront mode.
campus_idintegerOnly this campus.
plan_type_idintegerOnly this plan type (the tab the plan sits under in the member app).
categorystringOnly this category, case insensitive.
kindgeneral | seat | allDefault all, or general in storefront mode.
include_inactivetrueCatalog mode only: include plans switched off.
page, page_sizeintegerDefault 50, at most 100.

Which campuses count in storefront mode: a company with no campus restriction may use every campus in your organisation, and a restricted company only its home campus and the campuses ticked on it. This is the same rule the member app and the member checkout apply.

{
"success": true,
"count": 2, "page": 1, "page_size": 50, "has_next": false,
"plans": [
{
"id": 17,
"name": "Room Credits 20",
"description": "20 meeting room credits, valid a month",
"campus": {"id": 3, "name": "Indiranagar"},
"plan_type": {"id": 4, "name": "Add-ons"},
"category": "",
"kind": "general",
"holder_type": "company",
"pricing_mode": "flat",
"price": "1000.00",
"per_seat_price": "1000.00",
"flat_price": "1000.00",
"minimum_quantity": 1,
"currency": "INR",
"billing_period": {"unit": "month", "count": 1},
"validity_days": null,
"renews": false,
"shift": null,
"grants": [
{
"type": "meeting_room_credits",
"label": "Meeting room credits",
"description": "20 credits",
"quantity": "20.00",
"validity_days": 30,
"recurs": false,
"amenity": null
}
],
"perks": [{"label": "Book any room on campus", "icon": "mdi:calendar"}],
"is_active": true,
"allow_purchase": true,
"purchasable_via_api": true,
"updated_at": "2026-10-01T09:12:44.120931+00:00"
},
{
"id": 31,
"name": "Gym Monthly",
"description": "Unlimited gym on level 2",
"campus": {"id": 3, "name": "Indiranagar"},
"plan_type": {"id": 6, "name": "Wellness"},
"category": "Wellness",
"kind": "general",
"holder_type": "member",
"pricing_mode": "flat",
"price": "1500.00",
"per_seat_price": "1500.00",
"flat_price": "1500.00",
"minimum_quantity": 1,
"currency": "INR",
"billing_period": {"unit": "month", "count": 1},
"validity_days": null,
"renews": true,
"shift": null,
"grants": [
{
"type": "custom",
"label": "Custom amenity",
"description": "Gym access",
"quantity": "1.00",
"validity_days": null,
"recurs": false,
"amenity": {
"id": 5,
"slug": "gym",
"name": "Gym access",
"description": "Level 2 gym, 6am to 10pm",
"icon": "mdi:dumbbell",
"fulfilment": "automatic",
"requires_verification": true
}
}
],
"perks": [],
"is_active": true,
"allow_purchase": true,
"purchasable_via_api": true,
"updated_at": "2026-10-03T11:40:02.004113+00:00"
}
]
}

purchasable_via_api is the field to trust before showing a Buy button: it is true only for an active, on-sale general plan. In storefront mode every plan returned is purchasable.

Plan fields​

FieldTypeMeaning
idintegerPass it as plan_id to quote and sell.
kindgeneral | seatseat plans include desks and are never sold through this API.
holder_typemember | companyWho holds it once bought. A member plan needs a member_id when selling; a company plan needs a company_id or a member of that company.
pricing_modeflat | per_seatA flat plan has one price whatever the quantity. A per-seat plan costs per_seat_price x quantity, at least minimum_quantity.
pricedecimal stringThe price of one unit, before tax. Show the quote for the real total.
billing_periodobjectHow long one period lasts, for example {"unit": "month", "count": 1}.
validity_daysinteger or nullWhen set, the period is this many days instead of billing_period.
renewsbooleanWhether the plan renews in the member app. A sale through this API is always one period (see Sell a plan).
grantsarrayWhat the buyer receives. See below.
perksarrayDisplay copy only (for example "Free coffee"). Nothing is provisioned for a perk.
plan_type, categoryobject / stringGrouping for your storefront tabs.

Grant fields​

A plan can grant any mix of the types in Reference values. Show description as the line on your plan card: it is the same text the member app shows ("20 credits", "Gym access", "2 x Locker").

FieldTypeMeaning
typestringThe grant type, for example meeting_room_credits or custom.
labelstringHuman name of the type.
descriptionstringOne line describing this grant.
quantitydecimal stringHow many. For credits, multiplied by the quantity bought on per-seat plans.
validity_daysinteger or nullWhen set, the grant lasts this many days from purchase instead of the plan period.
recursbooleanRefills each period while the plan runs.
amenityobject or nullOnly on custom grants: the operator's own amenity (gym, locker, mail handling). fulfilment is automatic (granted at once) or manual (staff hand it over, so it starts as pending_fulfilment). slug is stable: use it, not name, as a key in your app.

2. Retrieve a plan​

GET plans/<plan_id>/ returns {"plan": {...}} in the same shape.

3. Quote a plan​

GET plans/<plan_id>/quote/ (plans:catalog:read) prices a purchase without making it. It runs the same checks as the purchase, so a quote that succeeds means the purchase will too (barring changes in between).

QueryTypeNotes
company_id or member_id / member_emailWho would hold it. Tax can depend on the company.
quantityintegerDefault 1. Ignored for flat plans.
start_dateYYYY-MM-DDDefault today on the campus calendar. Not in the past, at most a year ahead.
{
"success": true,
"quote": {
"plan_id": 17,
"quantity": 1,
"start_date": "2026-10-08",
"end_date": "2026-11-07",
"subtotal": "1000.00",
"pre_tax_amount": "1000.00",
"tax_amount": "180.00",
"tax_lines": [{"percent": "9.00", "amount": "90.00"}, {"percent": "9.00", "amount": "90.00"}],
"total_amount": "1180.00",
"currency": "INR"
}
}

4. List pass plans​

GET pass-plans/ (plans:catalog:read) lists day passes and visit passes on sale. Accepts campus_id, kind (day_pass | visit_pass), include_inactive, page, page_size.

{
"success": true,
"count": 1, "page": 1, "page_size": 50, "has_next": false,
"pass_plans": [
{
"id": 9,
"name": "Day Pass",
"description": "",
"campus": {"id": 3, "name": "Indiranagar"},
"plan_type": null,
"kind": "day_pass",
"visits": 1,
"validity_days": 1,
"price_per_visit": "400.00",
"list_price_per_visit": "500.00",
"price": "400.00",
"currency": "INR",
"entry": {"kind": "day", "hours": null, "days": null, "label": "Full day"},
"shift": null,
"grants": [
{"type": "meeting_room_credits", "label": "Meeting room credits", "description": "2 credits", "quantity": "2.00", "validity_days": null, "recurs": false, "amenity": null}
],
"perks": [],
"is_default": true,
"is_active": true
}
]
}
  • price_per_visit is what the member pays. list_price_per_visit is the undiscounted price, for showing a strike-through.
  • price is one pass (price_per_visit times visits), before tax.
  • entry says how long one visit lasts (day, a number of hours, or several days).

5. Retrieve a pass plan​

GET pass-plans/<pass_plan_id>/ returns {"pass_plan": {...}}.

6. Quote a pass​

GET pass-plans/<pass_plan_id>/quote/ (plans:catalog:read)

QueryTypeNotes
member_id / member_emailOptional. Tax can depend on the member's company.
quantityinteger, 1 to 50Default 1. More than one is a team purchase: the visits are pooled for the company.
start_dateYYYY-MM-DDDefault today (campus calendar).
{
"success": true,
"quote": {
"pass_plan_id": 9,
"quantity": 1,
"total_visits": 1,
"valid_from": "2026-10-09",
"valid_until": "2026-10-09",
"price_per_visit": "400.00",
"pre_tax_amount": "400.00",
"tax_amount": "72.00",
"tax_lines": [{"percent": "18.00", "amount": "72.00"}],
"total_amount": "472.00",
"currency": "INR"
}
}

A day pass is valid on its start day only. A visit pass is valid from start_date for validity_days days, inclusive.

7. Sell a pass​

POST passes/ (plans:passes:write). Send an Idempotency-Key.

FieldTypeRequiredNotes
pass_plan_idintegeryesFrom GET pass-plans/.
member_id or member_emailUUID / stringyesThe member must belong to a company: that is who is billed.
start_dateYYYY-MM-DDnoDefault today (campus calendar).
quantityintegerno1 to 50, default 1.
notesstringnoUp to 1000 characters, added to the charge for the operator.
curl -X POST "$BASE/passes/" \
-H "Authorization: Api-Key $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: app-order-10233" \
-d '{"pass_plan_id": 9, "member_id": "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c", "start_date": "2026-10-09"}'

Response 201:

{
"success": true,
"pass": {
"id": "c2a9d1e4-7b3f-4c55-8e0a-91f2b6d4a7c3",
"pass_plan": {"id": 9, "name": "Day Pass"},
"kind": "day_pass",
"status": "active",
"member": {"id": "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c", "email": "priya@tenant.example", "name": "Priya Rao"},
"company": {"id": 42, "name": "Tenant Co"},
"campus_id": 3,
"valid_from": "2026-10-09",
"valid_until": "2026-10-09",
"starts_at": "2026-10-08T18:30:00+00:00",
"ends_at": "2026-10-09T18:29:59+00:00",
"total_visits": 1,
"remaining_visits": 1,
"shared_with_team": false,
"pre_tax_amount": "400.00",
"tax_amount": "72.00",
"total_amount": "472.00",
"currency": "INR",
"source": "direct",
"cancelled_at": null,
"created_at": "2026-10-08T07:41:02.551210+00:00",
"updated_at": "2026-10-08T07:41:02.551210+00:00",
"charge": {
"id": 881,
"status": "approved",
"description": "Day pass: Day Pass",
"pre_tax_amount": "400.00",
"tax_amount": "72.00",
"total_amount": "472.00",
"invoiced": false
}
}
}

What happens on a sale:

  • The pass is created active, and everything it includes (credits and so on) is granted to the member at once.
  • The member can check in from valid_from with the member app, the kiosk or the door, exactly as with a pass bought in the member app.
  • A bill-to-account charge for total_amount is added to the company. A free pass creates no charge ("charge": null).
  • The operator's admins get a notification.

charge always reports the amounts on the charge itself. They match the quote, with one exception. If a campus uses a tax rate that bill-to-account charges cannot carry (anything other than 0, 5, 12, 18 or 28 percent), the charge is raised at 18 percent. Compare charge.total_amount with pass.total_amount if you operate outside India.

8. List passes​

GET passes/ (plans:passes:read)

QueryTypeNotes
member_id / member_emailOne member's passes.
company_idintegerOne company's passes.
campus_idinteger
statusstringOne status, or several separated by commas: active,pending.
active_onYYYY-MM-DDOnly passes usable that day: active, in date and with visits left.
page, page_sizeinteger

Newest first. Each row is a pass as above, without charge.

9. Retrieve a pass​

GET passes/<pass_id>/ returns {"pass": {...}} including charge. Use remaining_visits to show how many visits are left.

10. Cancel a pass​

POST passes/<pass_id>/cancel/ (plans:passes:write)

{"reason": "Member booked the wrong day"}

Only an unused pass can be cancelled here (remaining_visits equal to total_visits). A used pass answers 409: the operator cancels it in the console, where the refund is decided.

Response 200:

{
"success": true,
"pass": {"id": "c2a9…", "status": "cancelled", "cancelled_at": "2026-10-08T08:02:11+00:00", "charge": {"id": 881, "status": "cancelled", "invoiced": false, "...": "..."}, "...": "..."},
"charge_outcome": "cancelled"
}

Inclusions are taken back. If the charge was not yet invoiced it is voided (charge_outcome: "cancelled"). If it was already invoiced, charge_outcome is "already_invoiced": the operator issues a credit note. A pass that is already cancelled answers 409.

11. Sell a plan​

POST subscriptions/ (plans:subscriptions:write). Send an Idempotency-Key.

FieldTypeRequiredNotes
plan_idintegeryesA plan with purchasable_via_api: true.
company_idintegerfor company plansOr send a member_id and their company is used.
member_id or member_emailUUID / stringfor member plansThe member must belong to a company: that is who is billed.
quantityintegernoDefault 1. Per-seat plans only, at least minimum_quantity, at most 500.
start_dateYYYY-MM-DDnoDefault today (campus calendar).
notesstringnoAdded to the charge.

Response 201:

{
"success": true,
"subscription": {
"id": "5e71a0c3-2f4b-4d8e-a1c9-77b0e3d2f6a1",
"plan": {"id": 17, "name": "Room Credits 20"},
"status": "active",
"holder_type": "company",
"member": null,
"company": {"id": 42, "name": "Tenant Co"},
"campus_id": 3,
"start_date": "2026-10-08",
"end_date": "2026-11-07",
"quantity": 1,
"pre_tax_amount": "1000.00",
"currency": "INR",
"grants": [{"type": "meeting_room_credits", "quantity": "20", "validity_days": 30}],
"entitlements": [{"type": "meeting_room_credits", "quantity": "20.00", "status": "active"}],
"entitlements_granted_at": "2026-10-08T07:45:10.020113+00:00",
"cancelled_at": null,
"cancellation_reason": null,
"created_at": "2026-10-08T07:45:09.981002+00:00",
"updated_at": "2026-10-08T07:45:10.020500+00:00",
"charge": {"id": 882, "status": "approved", "description": "Plan: Room Credits 20", "pre_tax_amount": "1000.00", "tax_amount": "180.00", "total_amount": "1180.00", "invoiced": false}
}
}
  • The plan is active at once and everything it includes is provisioned: credits land in the company's pool, a virtual office is set up, and so on. entitlements lists what was granted.
  • A plan sold here runs for one period (start_date to end_date) and does not renew by itself. To extend, sell it again from the day after end_date.
  • A desk plan answers 400 with a message on plan_id.
  • A plan at a campus the buyer may not use (a campus-restricted company buying elsewhere) answers 400 on plan_id, as it would in the member app.
  • A custom grant with manual fulfilment shows "status": "pending_fulfilment" in entitlements until staff mark it handed over in the console.

Walkthrough: a member buys any plan​

This is the whole member flow your app needs, for any plan the operator has built (credits, a virtual office, parking, a gym, a locker, anything custom):

  1. Show the plans. GET plans/?member_id=<id> returns everything that member can buy. Group by plan_type and show each plan's grants[].description and perks.
  2. Show the price. When the member picks a plan, GET plans/<plan_id>/quote/?member_id=<id> (add quantity for a per-seat plan). Show total_amount and tax_lines.
  3. Buy it. POST subscriptions/ with plan_id and member_id, plus an Idempotency-Key. Use member_id whatever the holder_type: for a company plan the member's company becomes the holder.
  4. Show what they own. GET subscriptions/?member_id=<id>&status=active for the member's own plans, and company_id=<id> for their company's.
# 1. What can Priya buy?
curl "$BASE/plans/?member_id=8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c" -H "Authorization: Api-Key $KEY"

# 2. Price the gym plan for her
curl "$BASE/plans/31/quote/?member_id=8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c" -H "Authorization: Api-Key $KEY"

# 3. Sell it
curl -X POST "$BASE/subscriptions/" \
-H "Authorization: Api-Key $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2d4e-gym-priya-2026-10" \
-d '{"plan_id": 31, "member_id": "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c"}'

Plans themselves are created and priced by the operator in the admin console (Plans). This API lists and sells them; it does not create or edit them, so a new plan appears in step 1 as soon as the operator switches it on and opens it to purchase.

12. List and retrieve plan purchases​

GET subscriptions/ (plans:subscriptions:read) accepts member_id / member_email, company_id, plan_id, campus_id, status (comma separated), page, page_size. It lists plan purchases however they were made (API, member app or console), except desk plans, which belong to the subscription itself.

GET subscriptions/<id>/ returns {"subscription": {...}} including charge.

13. Cancel a plan purchase​

POST subscriptions/<id>/cancel/ (plans:subscriptions:write) with {"reason": "..."}.

Inclusions are taken back (unused credits are removed from the pool, a virtual office is ended, and so on). The charge is handled exactly as for a pass, and the response carries subscription and charge_outcome. An already cancelled purchase answers 409.


Meeting room credits​

How credits work​

  • A company has a credit pool: total_credits, used_credits, available_credits, and a validity window. Depending on the operator's setting (credit_scope), a company has one pool for the whole organisation ("org") or one per campus ("campus"). With per-campus pools, pass campus_id to pick one; the default is the company's home campus.
  • An operator can allocate part of the pool to individual members. A member's allocation is theirs to spend first.
  • When a member books a room with credits (in the member app or through the Meeting Rooms API with payment_method: "credits"), DeskOS spends the member's own allocation first, then the company pool. spendable_credits is that sum.
  • Credits are also granted automatically by plans and passes that include them. This API adjusts the same pool.

14. Read a balance​

GET credits/ (plans:credits:read)

QueryTypeNotes
company_id or member_id / member_emailWith a member, their company's pool plus the member's own figures.
campus_idintegerPer-campus pools only.
{
"success": true,
"company": {"id": 42, "name": "Tenant Co"},
"credit_scope": "org",
"pool": {
"id": 310,
"campus_id": 3,
"total_credits": "40.00",
"used_credits": "12.50",
"available_credits": "27.50",
"valid_from": "2026-10-01T00:00:00+05:30",
"valid_until": "2026-10-31T23:59:59+05:30",
"is_expired": false
},
"allocations": [
{"member": {"id": "8b1f…", "email": "priya@tenant.example", "name": "Priya Rao"}, "allocated_credits": "10.00", "used_credits": "2.00", "available_credits": "8.00"}
],
"member": {
"id": "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c",
"email": "priya@tenant.example",
"name": "Priya Rao",
"allocated_credits": "10.00",
"used_credits": "2.00",
"available_credits": "8.00",
"spendable_credits": "35.50"
}
}

pool is null when the company has never had credits. An expired pool reports available_credits: "0.00" and is_expired: true. member is only present when you asked by member.

15. Credit history​

GET credits/history/ (plans:credits:read). With company_id it returns the company ledger. With member_id it returns that member's ledger.

QueryTypeNotes
company_id or member_id / member_emailWhich ledger.
reasonstringOne of the reasons listed under Reference values.
sinceISO 8601 datetimeEntries at or after this time.
campus_idintegerPer-campus pools only.
page, page_sizeinteger
{
"success": true,
"ledger": "company",
"company": {"id": 42, "name": "Tenant Co"},
"member": null,
"summary": {"credits_added": "40.00", "credits_removed": "12.50"},
"count": 3, "page": 1, "page_size": 50, "has_next": false,
"entries": [
{"id": 9912, "at": "2026-10-07T10:01:13+00:00", "credits_changed": "-2.50", "balance_after": "27.50", "reason": "booking_deduction", "notes": "", "campus_id": 3, "booking_id": 5521},
{"id": 9890, "at": "2026-10-05T06:22:40+00:00", "credits_changed": "20.00", "balance_after": "40.00", "reason": "manual_adjustment", "notes": "[API] Goodwill credit, ticket 4411 | via API key 'member-app'", "campus_id": 3, "booking_id": null}
]
}

Newest first. summary covers every entry matching the filters, not just the page.

16. Add or remove credits​

POST credits/adjust/ (plans:credits:write). Send an Idempotency-Key.

FieldTypeRequiredNotes
company_id or member_id / member_emailyesA member means their company's pool.
creditsnumberyesPositive adds, negative removes. Up to two decimals, at most 100000 either way.
reasonstringyesUp to 500 characters. Shown in the credit history and to the operator.
valid_untilYYYY-MM-DD or datetimenoWhen adding: extends the pool's expiry to this date if later.
campus_idintegernoPer-campus pools only.
{"company_id": 42, "credits": 10, "reason": "Goodwill credit, ticket 4411"}

Response 200:

{
"success": true,
"company": {"id": 42, "name": "Tenant Co"},
"credits_changed": "10.00",
"pool": {"id": 310, "total_credits": "50.00", "used_credits": "12.50", "available_credits": "37.50", "...": "..."},
"log_id": 9931
}

Rules:

  • Adding to a company with no pool creates one, valid for a year unless you send valid_until. Adding to an expired pool starts it afresh: leftover credits expired with it.
  • Removing never goes below what is available. A credit someone already spent is never taken back. Asking to remove more answers 409 with available_credits, so you can retry with that number.
  • Every change is written to the credit history as manual_adjustment, with your reason and the key's name.
  • Adjusting credits is not a sale and creates no charge. To sell credits, sell a credit plan through POST subscriptions/.

17. Allocate credits to a member​

POST credits/allocate/ (plans:credits:write) sets a member's total allocation (not a change).

FieldTypeRequiredNotes
member_id or member_emailyes
allocated_creditsnumberyesThe new total. Not negative, not below what they already used.
reasonstringnoFor the history.
campus_idintegernoPer-campus pools only.
{"member_id": "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c", "allocated_credits": 10}

Response 200 carries allocation (allocated, used and available), credits_changed (the difference from before) and pool.

The total allocated across all members can never exceed the pool. Asking for more answers 409 with max_allocatable. The company must already have a pool: add credits first.


Errors specific to this section​

Authentication, missing permission, suspension and rate limit errors are the same in every section and are on the Authentication page.

400: Validation error​

{"success": false, "errors": {"member_id": "This member is not attached to a company, so the pass cannot be billed."}}
{"success": false, "errors": {"plan_id": "This plan includes desks. Desks are sold through the member checkout or the admin console, not this API."}}
{"success": false, "errors": {"start_date": "Start date cannot be in the past (campus time)."}}

404: Not found​

The plan, pass plan, pass or purchase does not exist, or is on a campus this key cannot reach.

409: Conflict​

WhenBody
Cancelling a pass that has been used{"detail": "This pass has been used. ..."}
Cancelling something already cancelled{"detail": "This pass is already cancelled."}
Removing more credits than are available{"credits": "Only 2.00 credits are available to remove.", "available_credits": "2.00"}
Allocating more than the pool holds{"allocated_credits": "...", "max_allocatable": "4.00"}
Same Idempotency-Key while the first request is still running{"Idempotency-Key": "A request with this key is still being processed. Retry shortly."}

Integration examples​

Sell a day pass: Python​

import os
import uuid
import requests

BASE = "https://oscar.nova.deskos.net/api/v1/plans/external/"
HEADERS = {"Authorization": f"Api-Key {os.environ['DESKOS_API_KEY']}"}

passes = requests.get(BASE + "pass-plans/", params={"kind": "day_pass", "campus_id": 3}, headers=HEADERS).json()
day_pass = passes["pass_plans"][0]

member_id = "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c"
quote = requests.get(
BASE + f"pass-plans/{day_pass['id']}/quote/",
params={"member_id": member_id, "start_date": "2026-10-09"},
headers=HEADERS,
).json()["quote"]
print("Pay", quote["total_amount"], quote["currency"])

order_ref = str(uuid.uuid4()) # store this with your order, reuse it on retry
sold = requests.post(
BASE + "passes/",
json={"pass_plan_id": day_pass["id"], "member_id": member_id, "start_date": "2026-10-09"},
headers={**HEADERS, "Idempotency-Key": order_ref},
).json()
print(sold["pass"]["id"], sold["pass"]["status"])

Top up credits: JavaScript / Node.js​

const BASE = 'https://oscar.nova.deskos.net/api/v1/plans/external/';
const headers = {
Authorization: `Api-Key ${process.env.DESKOS_API_KEY}`,
'Content-Type': 'application/json',
};

const res = await fetch(`${BASE}credits/adjust/`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': 'topup-ticket-4411' },
body: JSON.stringify({ company_id: 42, credits: 10, reason: 'Goodwill credit, ticket 4411' }),
});
const body = await res.json();
if (!body.success) throw new Error(JSON.stringify(body.errors));
console.log('Available now', body.pool.available_credits);

Instructions for AI agents​

Follow the general rules on the Authentication page, and these:

  1. Quote before you sell. Show the person total_amount and currency from the quote and get a clear yes before calling POST passes/ or POST subscriptions/. A sale is billed to a real company.
  2. Always send an Idempotency-Key on a sale or credit adjustment, and reuse the same key if you retry. Never retry a sale without one.
  3. Offer a member only what GET plans/?member_id= returns for them, and check purchasable_via_api on anything else. Never try to sell a seat plan.
  4. Never remove credits to "fix" a balance without a person asking for that exact change. Removing is permanent, and the reason you give is shown to the operator.
  5. Cancel only what you sold, and only when asked. If charge_outcome is already_invoiced, tell the person the operator will issue a credit note. Do not try to undo it any other way.
  6. Report money with its currency and the amounts DeskOS returned. Never compute tax yourself.

FAQ​

Can members pay by card through this API? Not yet. Sales are billed to the member's company account. If your app takes payment itself, sell through this API after the payment succeeds.

Can I create or edit plans through the API? No. Plans are the operator's catalog and are built in the admin console. Every plan the operator creates (credits, virtual office, parking, WiFi, or a custom amenity such as a gym or a locker) shows up in GET plans/ and can be sold here, once it is active and open to purchase.

Why can't I sell a desk plan? A desk is a particular seat on a floor plan, with availability, prorating and a membership agreement. That flow lives in the member checkout and the admin console. Everything else a plan can include is sold here.

Does a plan sold here renew? No. It runs for one period. Sell it again to extend. Plans the operator sells in the console keep their own renewal settings.

Can I apply a coupon? Not through this API. Coupons apply in the member checkout and the console.

Where does the operator see what I sold? Passes appear under the member's subscriptions, plan purchases on the Plans page, and each charge under the company's bill-to-account items. Each charge description ends with a reference such as [api-pass:<pass id>], so the two can always be matched.

Do credits I add expire? They live in the company's pool and follow its validity window. Send valid_until to extend it when adding.