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.
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).
| Permission | Unlocks |
|---|---|
plans:catalog:read | GET plans/, GET plans/<id>/, GET plans/<id>/quote/, GET pass-plans/, GET pass-plans/<id>/, GET pass-plans/<id>/quote/ |
plans:passes:read | GET passes/, GET passes/<id>/ |
plans:passes:write | POST passes/, POST passes/<id>/cancel/ |
plans:subscriptions:read | GET subscriptions/, GET subscriptions/<id>/ |
plans:subscriptions:write | POST subscriptions/, POST subscriptions/<id>/cancel/ |
plans:credits:read | GET credits/, GET credits/history/ |
plans:credits:write | POST 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
| Thing | Field | Type | Example |
|---|---|---|---|
| Member | member_id | UUID string | "8b1f3c2e-6a1d-4f0e-9c7a-2d5e8f1a3b4c" |
| Company | company_id | integer | 42 |
| Campus | campus_id | integer | 3 |
| Plan (catalog) | plan_id | integer | 17 |
| Pass plan (catalog) | pass_plan_id | integer | 9 |
| A pass that was sold | pass.id | UUID string | "c2a9…" |
| A plan that was sold | subscription.id | UUID string | "5e71…" |
| Bill-to-account charge | charge.id | integer | 881 |
member_idis the same UUID the Members API returns. Anywhere a member is needed you can sendmember_emailinstead.- Money is a string with two decimals (
"400.00"), always next to acurrencycode ("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-DDand 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(ormember_email, orcompany_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.
| Query | Type | Notes |
|---|---|---|
member_id / member_email | UUID / string | Storefront mode for this member. The member must belong to a company (that is who is billed), otherwise 400. |
company_id | integer | Storefront mode for this company, at the campuses the company may use. With a member_id too, it must be the member's company. |
purchasable | true | Without a buyer: only plans this API can sell right now (active, open to purchase, not desks). Always on in storefront mode. |
campus_id | integer | Only this campus. |
plan_type_id | integer | Only this plan type (the tab the plan sits under in the member app). |
category | string | Only this category, case insensitive. |
kind | general | seat | all | Default all, or general in storefront mode. |
include_inactive | true | Catalog mode only: include plans switched off. |
page, page_size | integer | Default 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
| Field | Type | Meaning |
|---|---|---|
id | integer | Pass it as plan_id to quote and sell. |
kind | general | seat | seat plans include desks and are never sold through this API. |
holder_type | member | company | Who 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_mode | flat | per_seat | A flat plan has one price whatever the quantity. A per-seat plan costs per_seat_price x quantity, at least minimum_quantity. |
price | decimal string | The price of one unit, before tax. Show the quote for the real total. |
billing_period | object | How long one period lasts, for example {"unit": "month", "count": 1}. |
validity_days | integer or null | When set, the period is this many days instead of billing_period. |
renews | boolean | Whether the plan renews in the member app. A sale through this API is always one period (see Sell a plan). |
grants | array | What the buyer receives. See below. |
perks | array | Display copy only (for example "Free coffee"). Nothing is provisioned for a perk. |
plan_type, category | object / string | Grouping 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").
| Field | Type | Meaning |
|---|---|---|
type | string | The grant type, for example meeting_room_credits or custom. |
label | string | Human name of the type. |
description | string | One line describing this grant. |
quantity | decimal string | How many. For credits, multiplied by the quantity bought on per-seat plans. |
validity_days | integer or null | When set, the grant lasts this many days from purchase instead of the plan period. |
recurs | boolean | Refills each period while the plan runs. |
amenity | object or null | Only 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).
| Query | Type | Notes |
|---|---|---|
company_id or member_id / member_email | Who would hold it. Tax can depend on the company. | |
quantity | integer | Default 1. Ignored for flat plans. |
start_date | YYYY-MM-DD | Default 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_visitis what the member pays.list_price_per_visitis the undiscounted price, for showing a strike-through.priceis one pass (price_per_visittimesvisits), before tax.entrysays how long one visit lasts (day, a number ofhours, or severaldays).
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)
| Query | Type | Notes |
|---|---|---|
member_id / member_email | Optional. Tax can depend on the member's company. | |
quantity | integer, 1 to 50 | Default 1. More than one is a team purchase: the visits are pooled for the company. |
start_date | YYYY-MM-DD | Default 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.
| Field | Type | Required | Notes |
|---|---|---|---|
pass_plan_id | integer | yes | From GET pass-plans/. |
member_id or member_email | UUID / string | yes | The member must belong to a company: that is who is billed. |
start_date | YYYY-MM-DD | no | Default today (campus calendar). |
quantity | integer | no | 1 to 50, default 1. |
notes | string | no | Up 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_fromwith 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_amountis 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)
| Query | Type | Notes |
|---|---|---|
member_id / member_email | One member's passes. | |
company_id | integer | One company's passes. |
campus_id | integer | |
status | string | One status, or several separated by commas: active,pending. |
active_on | YYYY-MM-DD | Only passes usable that day: active, in date and with visits left. |
page, page_size | integer |
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.
| Field | Type | Required | Notes |
|---|---|---|---|
plan_id | integer | yes | A plan with purchasable_via_api: true. |
company_id | integer | for company plans | Or send a member_id and their company is used. |
member_id or member_email | UUID / string | for member plans | The member must belong to a company: that is who is billed. |
quantity | integer | no | Default 1. Per-seat plans only, at least minimum_quantity, at most 500. |
start_date | YYYY-MM-DD | no | Default today (campus calendar). |
notes | string | no | Added 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.
entitlementslists what was granted. - A plan sold here runs for one period (
start_datetoend_date) and does not renew by itself. To extend, sell it again from the day afterend_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
customgrant with manual fulfilment shows"status": "pending_fulfilment"inentitlementsuntil 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):
- Show the plans.
GET plans/?member_id=<id>returns everything that member can buy. Group byplan_typeand show each plan'sgrants[].descriptionandperks. - Show the price. When the member picks a plan,
GET plans/<plan_id>/quote/?member_id=<id>(addquantityfor a per-seat plan). Showtotal_amountandtax_lines. - Buy it.
POST subscriptions/withplan_idandmember_id, plus anIdempotency-Key. Usemember_idwhatever theholder_type: for a company plan the member's company becomes the holder. - Show what they own.
GET subscriptions/?member_id=<id>&status=activefor the member's own plans, andcompany_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, passcampus_idto 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_creditsis 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)
| Query | Type | Notes |
|---|---|---|
company_id or member_id / member_email | With a member, their company's pool plus the member's own figures. | |
campus_id | integer | Per-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.
| Query | Type | Notes |
|---|---|---|
company_id or member_id / member_email | Which ledger. | |
reason | string | One of the reasons listed under Reference values. |
since | ISO 8601 datetime | Entries at or after this time. |
campus_id | integer | Per-campus pools only. |
page, page_size | integer |
{
"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.
| Field | Type | Required | Notes |
|---|---|---|---|
company_id or member_id / member_email | yes | A member means their company's pool. | |
credits | number | yes | Positive adds, negative removes. Up to two decimals, at most 100000 either way. |
reason | string | yes | Up to 500 characters. Shown in the credit history and to the operator. |
valid_until | YYYY-MM-DD or datetime | no | When adding: extends the pool's expiry to this date if later. |
campus_id | integer | no | Per-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).
| Field | Type | Required | Notes |
|---|---|---|---|
member_id or member_email | yes | ||
allocated_credits | number | yes | The new total. Not negative, not below what they already used. |
reason | string | no | For the history. |
campus_id | integer | no | Per-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
| When | Body |
|---|---|
| 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:
- Quote before you sell. Show the person
total_amountandcurrencyfrom the quote and get a clear yes before callingPOST passes/orPOST subscriptions/. A sale is billed to a real company. - Always send an
Idempotency-Keyon a sale or credit adjustment, and reuse the same key if you retry. Never retry a sale without one. - Offer a member only what
GET plans/?member_id=returns for them, and checkpurchasable_via_apion anything else. Never try to sell aseatplan. - 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.
- Cancel only what you sold, and only when asked. If
charge_outcomeisalready_invoiced, tell the person the operator will issue a credit note. Do not try to undo it any other way. - Report money with its
currencyand 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.