Skip to content
sandadocs

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:

Text
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

JSON
{
  "@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.

Abridged
<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:

Text
Accept: application/json;IEEE754Compatible=true

Query 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:

  • $count would 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 $filter with contains, 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.

Text
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-01 or 2026-01-01T00:00:00Z.
  • Booleans are bare: true or false.

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:

  • or and not. A query carries conditions that are all combined with and, so a disjunction has nowhere to go, and turning one into an and would answer a narrower question than the one asked. Ask two questions, or use ne.
  • gt and le on 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. Use ge for on or after and lt for 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:

Text
$filter=issue_date ge 2026-06-01 and issue_date lt 2026-07-01

Paging

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.

JSON
{
  "@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 OFFSET over 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.
  • $top bounds the whole read. A $top of 50 returns 50 rows and no nextLink, however large the table is. A $top of 2,500 returns three pages, and the nextLink carries the rows still to come.
  • $skip goes up to 1,000,000. Past that, every page costs the warehouse the whole scan again, so sanda refuses with becca.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.

JSON
{
  "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.

Shell
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.