OData feed reference
The sanda OData feed in full: addresses, tables, data types, query options, the filter grammar, paging, limits and every error it returns.
sanda serves your semantic fluid as an OData v4 service. This page is the exact contract: what to send, what comes back, and what sanda refuses. For a walk through, see Connect Excel or Connect Power BI.
The feed is read only and is on every edition.
Addresses
The service root is:
https://console.sanda-os.com.au/api/odata/Keep the trailing slash. Some clients resolve their own links against this string, and without it the service document is the last path segment rather than the root.
| Address | Returns |
|---|---|
/api/odata/ |
The service document: every table this workspace offers |
/api/odata/$metadata |
The CSDL document: every table's columns and their types, as XML |
/api/odata/<table> |
The rows of one table |
$metadata is also accepted percent-encoded as %24metadata.
Only GET and HEAD are accepted. Anything else is refused with 405 and Allow: GET, HEAD. Every response carries OData-Version: 4.0 and Cache-Control: no-store. Rows come back as application/json;odata.metadata=minimal;charset=utf-8, and $metadata as application/xml;charset=utf-8.
A browser request that carries an Origin other than sanda's own is refused with 403, so a web page on another site cannot read the feed.
Authentication
A feed takes a credential that was typed in. It never uses a signed-in browser session.
| Credential | How to send it | Use it for |
|---|---|---|
| Integration token as a Basic credential | Authorization: Basic <base64 of user:password>, with the token as the password |
Excel, Power BI, and anything that cannot set a bearer header |
| Integration token as a bearer | Authorization: Bearer bat_... |
Scripts and clients that can set a header |
| OAuth access token as a bearer | Authorization: Bearer <access token> |
A client that already signed a person in with OAuth |
Notes:
- Either half of a Basic credential is read. A token begins
bat_, and sanda picks it out of the pair, so a token pasted into the user name box works too. - An OAuth access token is refused as a Basic credential. It expires in an hour and is refreshed by a client that knows how, which a workbook does not.
- The token needs
query:run. Every integration token has it. Nothing else is asked for, and the feed cannot write, define or run SQL of its own. - A missing or wrong credential is a 401 with
WWW-Authenticate: Basic realm="sanda", charset="UTF-8", Bearer realm="sanda". That header tells a client to ask for a credential rather than treat the response as a failure.
Get a token under Settings · Integrations. See agent tokens.
Tables
For each table on the semantic map, the feed offers a rows table named for the table, and, when the table has at least one metric, a summary table.
| Table | Name | Properties |
|---|---|---|
| Rows | <table> |
Every column of the table the fluid can see, up to 200. A column that has a dimension takes the dimension's name |
| Summary | <table>_summary |
The table's dimensions, then its metrics. One row for each combination of dimension values |
Names in the fluid are lowercase prose. The feed folds each into an identifier: lowercase, every run of characters other than letters and digits becomes one underscore, underscores at either end are dropped, and a name that starts with a digit gets an n in front. order details is order_details, and on-time delivery % is on_time_delivery. If two names fold to the same identifier, the second gets _2, then _3, so both stay reachable.
Every table also has a _row property, typed Edm.Int64. It is the row's position in that read (a $skip of 1,000 makes the first row of the next page 1000). OData needs every row to have a key, and sanda has no honest business key to offer, because a declared primary key is missing on plenty of tables and a key that is not unique makes a client silently drop rows. _row is not a business key and it does not survive a refresh. You cannot filter or sort by it, and it is ignored in $select.
Summary tables are grouped by every dimension they carry, so $select on a summary is also a coarser grouping. Select only region and net_revenue, and you get one row for each region.
A table is only in the feed when it is on the map. If the map is empty, the service document lists no tables and reading any table returns 404.
Service document
{
"@odata.context": "https://console.sanda-os.com.au/api/odata/$metadata",
"value": [
{ "name": "invoices", "kind": "EntitySet", "url": "invoices" },
{ "name": "invoices_summary", "kind": "EntitySet", "url": "invoices_summary" }
]
}Metadata
$metadata is a CSDL 4.0 document in the namespace sanda, with the container fluid. Each table is an entity set, and its entity type is named <table>_row.
<EntityType Name="invoices_summary_row">
<Key><PropertyRef Name="_row"/></Key>
<Property Name="_row" Type="Edm.Int64" Nullable="false"/>
<Property Name="issue_date" Type="Edm.DateTimeOffset" Nullable="true"/>
<Property Name="region" Type="Edm.String" Nullable="true"/>
<Property Name="net_revenue" Type="Edm.Decimal" Scale="variable" Nullable="true"/>
</EntityType>- Every property is nullable except
_row, because a column you left out with$select, a join that matched nothing and an empty cell look the same to a client. - Decimals carry
Scale="variable". CSDL defaults an unstated scale to zero, which would have a client round every price and total. - A table's description rides as the annotation
Org.OData.Core.V1.Description, for clients that show a description beside a table. A summary table's description is generated: "invoices: its metrics, grouped by its dimensions."
Data types
Types are declared in $metadata and values are typed to match, so a number arrives as a number and Excel can add it up.
| Warehouse type | OData type | In JSON |
|---|---|---|
boolean |
Edm.Boolean |
true or false |
smallint |
Edm.Int16 |
A number |
integer |
Edm.Int32 |
A number |
bigint |
Edm.Int64 |
A number, or a string when it has more than 15 significant digits |
numeric, decimal |
Edm.Decimal |
A number, or a string when it has more than 15 significant digits |
money |
Edm.String |
The amount as the warehouse formats it, such as $1,234.50. Cast it to numeric in the model for a number |
real, double precision |
Edm.Double |
A number |
date |
Edm.Date |
2026-06-30 |
timestamp, timestamp with time zone |
Edm.DateTimeOffset |
2026-06-30T04:05:06.000Z, always UTC |
time, time with time zone |
Edm.TimeOfDay |
14:30:00. A time zone offset is dropped, because OData has no time of day with an offset |
interval |
Edm.String |
An ISO 8601 duration, such as P0Y1M3DT4H0M0S. OData's duration type cannot hold months or years, so an interval is sent as text |
uuid |
Edm.Guid |
A string |
text, character varying, jsonb, arrays, ranges and enums |
Edm.String |
A string. JSON and arrays are sent as JSON text. Nothing is truncated |
| A metric in a summary | Edm.Decimal |
A number |
Two things follow. A value too large to be exact as a JSON number arrives as a string rather than as a number that is close. And a client that would rather have every 64-bit integer and decimal as a string can say so by sending IEEE754Compatible=true in its Accept header:
Accept: application/json;IEEE754Compatible=trueQuery options
Options are applied in the warehouse and not after the rows arrive, so a $filter makes a refresh cheaper as well as smaller.
| Option | Supported | Behaviour |
|---|---|---|
$select |
Yes | A comma-separated list of property names, case insensitive. Fewer columns is a cheaper query. _row and * are ignored, and a $select that names nothing else is refused |
$filter |
Yes | See the filter grammar |
$orderby |
Yes | Comma-separated name, name asc or name desc. You can order only by properties you also select. _row is refused |
$top |
Yes | A whole number of rows, 0 or more. It bounds the whole read, not one page. $top=0 returns an empty page |
$skip |
Yes | A whole number of rows to skip, from 0 to 1,000,000 |
$format |
json only |
json or application/json. Any other value is refused |
$count |
$count=false only |
$count=false is accepted, and any other value is refused |
$expand, $apply, $search |
No | Refused by name, with a message saying what to do instead |
sanda does not implement any other system query option and does not read one. The four it refuses by name are refused because ignoring them would give you a bigger answer than you asked for, and nothing is dropped quietly:
$countwould need a second scan of the table, billed to the workspace, to fill in a number nothing on the sheet needs. Follow the pages instead.$expand: the tables in the feed have no links between them. sanda joins on the map instead, so ask for a summary, or model a view under Data · Modelling.$apply: sanda does its grouping in the fluid. Every table with metrics already has a summary that is grouped.$search: use$filterwithcontains, or sanda search in the console.
The filter grammar
A $filter is one or more conditions joined by and. Parentheses are accepted and change nothing, because every condition is combined with and.
region eq 'AU' and total ge 100 and contains(notes,'urgent')Comparisons have the form property operator value:
| Operator | Means | Applies to |
|---|---|---|
eq |
Equals | Text, number, boolean, date |
ne |
Does not equal | Text, number, boolean, date |
gt |
Greater than | Number |
ge |
Greater than or equal, or on or after | Number, date |
lt |
Less than, or before | Number, date |
le |
Less than or equal | Number |
Text functions take a text property and a quoted value:
| Function | Example |
|---|---|
contains(property,'value') |
contains(notes,'urgent') |
startswith(property,'value') |
startswith(region,'New') |
endswith(property,'value') |
endswith(region,'Wales') |
Null tests are property eq null (the value is empty) and property ne null (it has a value). Only eq and ne may be used with null.
Values:
- Text is in single quotes, and a quote inside text is doubled:
'O''Brien'. - Numbers are bare:
100,-2.5. - Dates and instants are bare, in ISO 8601:
2026-01-01or2026-01-01T00:00:00Z. - Booleans are bare:
trueorfalse.
Property names are case insensitive and are the names in $metadata. What kind of property it is decides which operators it accepts: a date is a property whose dimension is a time dimension or whose column is a date or timestamp, a number is a numeric column, a boolean is a boolean column, and everything else is text.
What sanda refuses, always with a sentence that says what to write instead:
orandnot. A query carries conditions that are all combined withand, so a disjunction has nowhere to go, and turning one into anandwould answer a narrower question than the one asked. Ask two questions, or usene.gtandleon a date. sanda compares dates as a window that includes its first day and excludes its end, which is why a month filter never loses its last day. Usegefor on or after andltfor before.- A metric. A metric in a summary is worked out from the rows a query reads, so it cannot decide which rows are read. Filter on a dimension.
- A text function on a property that is not text.
- More than 20 conditions.
- A property that is not in the table. The message suggests the nearest names.
A month, filtered correctly:
$filter=issue_date ge 2026-06-01 and issue_date lt 2026-07-01Paging
A page is at most 1,000 rows. When more rows remain, the response carries @odata.nextLink, an address that keeps every option you sent and moves $skip on. Its query string is percent-encoded (%24skip), which every client decodes. Excel and Power BI follow it without being asked, so a large table just takes a little longer.
{
"@odata.context": "https://console.sanda-os.com.au/api/odata/$metadata#invoices_summary(region,net_revenue)",
"value": [
{ "_row": 0, "region": "AU", "net_revenue": 128450.5 },
{ "_row": 1, "region": "NZ", "net_revenue": 41210 }
],
"@odata.nextLink": "https://console.sanda-os.com.au/api/odata/invoices_summary?%24select=region%2Cnet_revenue&%24skip=1000"
}The rows and numbers above are an illustration. @odata.context names the columns you selected in brackets, and leaves them out when you selected all of them.
- Every page is ordered. An
OFFSETover an unordered result is a different arbitrary slice on every page, and the symptom is a workbook missing rows. If you send no$orderby, sanda sorts ascending by the first four dimension or column properties it returns. Two rows that tie on every one of them may swap places, which a client cannot tell apart anyway. $topbounds the whole read. A$topof 50 returns 50 rows and nonextLink, however large the table is. A$topof 2,500 returns three pages, and thenextLinkcarries the rows still to come.$skipgoes up to 1,000,000. Past that, every page costs the warehouse the whole scan again, so sanda refuses withbecca.too_deep. Narrow the feed with$filter, read a summary instead, or build a view under Data · Modelling.
Limits
| Limit | Value |
|---|---|
| Rows in one page | 1,000 |
Deepest $skip |
1,000,000 |
| Time one read may take | 30 seconds |
| Columns in a rows table | 200 |
Conditions in one $filter |
20 |
A read runs through the warehouse's read-only role and is billed to the workspace like any other query. See usage.
The feed composes no SQL of its own. Every read goes through the composer that answers questions in sanda's workbench, so anything that surface refuses, the feed refuses for the same reason: a column whose name looks like it holds a secret, two tables that would fan out if joined, a table a join repeats that has no key columns. Reading a table that has a column named like a secret is refused whole. Leave the column out with $select, rename it if the name is wrong, or build a view without it.
Errors
A read sanda will not compose is a 4xx that carries sanda's own sentence. A 200 with no rows would look to Excel like an empty table, and nothing would say why, so a refusal is never a 200.
{
"error": {
"code": "becca.refused",
"message": "sanda can only combine filters with “and”. Ask the two questions separately, or narrow with eq on a list."
}
}code is a stable word a script can branch on, and message is the sentence a person sees in Excel's error pane.
| Status | code |
When |
|---|---|---|
| 400 | becca.refused |
A $select, $filter, $orderby, $top or $skip sanda cannot use, or a question the composer will not answer |
| 400 | becca.unsupported |
$count, $expand, $apply or $search, or a $format that is not JSON |
| 400 | becca.too_deep |
A $skip beyond 1,000,000 |
| 401 | becca.unauthorized |
No credential, or one sanda does not recognise. Carries WWW-Authenticate |
| 402 | becca.suspended |
The workspace is suspended |
| 402 | becca.budget_suspended |
The warehouse is suspended for its budget |
| 403 | becca.scope |
The token cannot ask questions |
| 403 | becca.origin |
A browser sent an Origin sanda does not answer |
| 404 | becca.no_such_set |
No table by that name. The message lists the tables there are |
| 405 | becca.method |
Not a GET or HEAD |
| 502 | becca.warehouse |
The read timed out, or the warehouse failed. A timeout says to narrow it with $filter or $select |
| 503 | becca.no_warehouse |
The workspace has no warehouse to read yet |
Try it from a terminal
-u ":$TOKEN" is the Basic form with an empty user name.
TOKEN=bat_your_token_here
# every table this workspace offers
curl -s https://console.sanda-os.com.au/api/odata/ -u ":$TOKEN"
# the columns and their types
curl -s 'https://console.sanda-os.com.au/api/odata/$metadata' -u ":$TOKEN"
# ten rows
curl -s 'https://console.sanda-os.com.au/api/odata/invoices?$top=10' -u ":$TOKEN"
# this year, by region
curl -s 'https://console.sanda-os.com.au/api/odata/invoices_summary?$select=region,net_revenue&$filter=issue_date%20ge%202026-01-01' \
-u ":$TOKEN"
# the same with a bearer header
curl -s https://console.sanda-os.com.au/api/odata/ -H "Authorization: Bearer $TOKEN"Replace invoices, invoices_summary and the column names with your own, which the service document and $metadata list. Put a space in a URL as %20.
Next steps
Something unclear or out of date? Tell us, and we will fix the page.