The Data 360 Query API: running SQL against your unified data from outside Salesforce
Your unified profiles live in Data 360, but the app that needs them lives somewhere else. The Query API is the door — ANSI SQL over the Hyper engine, reachable by any external system. Here is the two-step auth that trips everyone up, the batch pagination model, the 49,999-row wall, and how to not melt your credit budget.
You spent two quarters unifying customer data into Data 360 — ingesting streams, resolving identities, building calculated insights. The profiles are clean, the metrics are governed, and now a system that isn’t Salesforce needs to read them: a pricing service, a data-science notebook, a customer portal, a reverse-ETL job feeding a warehouse. The Ingestion API is how data gets in. The question nobody documents as clearly is how it gets out — how an external process asks Data 360 a question in SQL and gets rows back.
That door is the Data 360 Query API, and it’s better than most people assume. It speaks ANSI SQL, runs on Salesforce’s Hyper engine — the same columnar engine under Tableau and CRM Analytics — and returns gigabyte-scale result sets to anything that can make an HTTPS call. It is also gated by an authentication dance that fails silently for almost everyone the first time, and metered in a way that can quietly run up a credit bill. This post is the working developer’s guide: the auth flow that actually works, the batch pagination model, the exact limits, and the query hygiene that keeps the API from becoming your most expensive integration.
One naming note before the code, because it shows up in every URL and error message. Salesforce rebranded Data Cloud to Data 360 in October 2025; the platform, the objects, and the API surface are the same, and older docs, endpoints, and SDK names still say “Data Cloud” or carry a c360a/cdp prefix. Read the two names as synonyms. We wrote the full rebrand explainer separately if you want the history.
What you can actually query
The Query API runs SQL over your Data 360 objects — the data lake objects (DLOs), data model objects (DMOs), calculated insight objects, and unified profile tables that hold your resolved data. If you’ve mapped the difference between data lake and data model objects, that mapping is exactly what determines what your FROM clause can name. DMOs carry the __dlm suffix; a query against unified individuals looks like ordinary SQL against a table called UnifiedIndividual__dlm.
This matters because the Query API is a read interface into the modeled, governed layer — not a raw dump of ingested files. You get the benefit of identity resolution and the semantic model on the way out. That’s the whole reason to query Data 360 rather than the source systems directly: the source has five duplicate records for one person; Data 360 has the one resolved profile.
The two-step authentication that trips everyone
Here is the single fact that saves you an afternoon: Data 360 does not accept your ordinary Salesforce access token. You authenticate twice. First you get a core Salesforce token the normal way, then you exchange it for a Data 360 token scoped to your Data 360 tenant, and it’s that second token — against a different host — that the Query API accepts.
Step one is a standard OAuth flow against your Salesforce org. For a server-to-server integration, the JWT bearer flow is the usual choice: a connected app with the right scopes, signed assertion, token back. The scope that matters is cdp_query_api (alongside api and, for the token exchange, refresh_token/offline_access depending on your flow). If that scope isn’t on the connected app, everything downstream 403s with a message that won’t mention the scope.
Step two exchanges that core token for a Data 360 token:
# Step 2: exchange the core Salesforce token for a Data 360 token
curl -X POST "https://MyDomain.my.salesforce.com/services/a360/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:salesforce:grant-type:external:cdp" \
-d "subject_token=${CORE_ACCESS_TOKEN}" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token"
The response hands you two things you must both keep: an access_token for Data 360, and an instance_url that is the Data 360 tenant host — a different domain from your my.salesforce.com org. Every Query API call goes to that tenant host with that token. Point your queries at your my.salesforce.com domain with your core token and you’ll get authentication errors that make no sense until you realize you skipped the exchange.
Both tokens are short-lived. A long-running job needs to detect expiry and re-run the exchange, not just retry — a common bug is refreshing the core token but reusing a stale Data 360 token.
Running a query
With the Data 360 token and tenant host in hand, the query itself is a plain POST. The current native endpoint is Query API V2 at /api/v2/query:
curl -X POST "https://${DATA360_INSTANCE_URL}/api/v2/query" \
-H "Authorization: Bearer ${DATA360_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT ssot__Id__c, ssot__FirstName__c, loyalty_tier__c FROM UnifiedIndividual__dlm WHERE loyalty_tier__c = '\''Platinum'\'' LIMIT 5000"
}'
The response body carries the rows plus the metadata you need to paginate:
{
"data": [ { "ssot__Id__c": "...", "ssot__FirstName__c": "Dana", "loyalty_tier__c": "Platinum" } ],
"startTime": "2026-07-23T14:02:11.004Z",
"endTime": "2026-07-23T14:02:11.921Z",
"rowCount": 5000,
"queryId": "01x…",
"nextBatchId": "8f2c…",
"done": false,
"metadata": { "ssot__Id__c": { "type": "VARCHAR", "placeInOrder": 0 } }
}
Read done on every response. It is the contract. When done is true, you have the whole result set. When it’s false, there’s more, and nextBatchId is your cursor to the next chunk. You fetch subsequent batches with a GET, not another POST:
curl "https://${DATA360_INSTANCE_URL}/api/v2/query/${NEXT_BATCH_ID}" \
-H "Authorization: Bearer ${DATA360_ACCESS_TOKEN}"
The 49,999-row wall, and designing around it
A single Query API response returns at most 49,999 rows. This is the limit people discover in production when a result that was 40,000 rows in the sandbox crosses 50,000 in prod and their integration silently starts truncating — because they trusted the row array instead of the done flag.
The design rule follows directly: never treat one response as the full answer. Loop on done.
import requests
def query_all(instance_url, token, sql):
headers = {"Authorization": f"Bearer {token}"}
rows, meta = [], None
resp = requests.post(
f"https://{instance_url}/api/v2/query",
headers={**headers, "Content-Type": "application/json"},
json={"sql": sql},
).json()
rows.extend(resp["data"])
meta = resp["metadata"]
while not resp["done"]:
batch_id = resp["nextBatchId"]
resp = requests.get(
f"https://{instance_url}/api/v2/query/{batch_id}",
headers=headers,
).json()
rows.extend(resp["data"])
return rows, meta
For genuinely large extracts — millions of rows for a warehouse load — the Query API’s batch-paginate model is the wrong tool, and the honest answer is to stop pulling row-by-row entirely. That’s what zero-copy federation and outbound data sharing are for: let Snowflake, BigQuery, or Databricks read Data 360’s tables directly instead of paging 50,000 rows at a time over HTTPS. The Query API is for transactional reads — a portal loading one customer’s profile, a service scoring a batch of a few thousand leads — not for bulk replication. Reaching for it to move ten million rows is how you get a slow job and a surprising bill.
Two flavors: native REST and the Connect API
There are two ways to reach the same engine, and it’s worth knowing which you’re looking at in the docs.
- The native Query API (
/api/v2/queryon the Data 360 tenant host, the calls above). This is the direct, high-throughput path, ideal for external systems and data pipelines. - The Query Connect API, part of the Salesforce Connect REST API, introduced in 2025 to fold Data 360 SQL access into the standard
/services/data/vXX.X/ssot/...surface alongside the rest of the platform’s REST. It supersedes the older V1/V2 Connect query resources for new build-outs, though V2 remains available. If you’re already inside a Salesforce context — an Apex callout, a platform integration that authenticates once — the Connect route can be simpler because it lives under the core API surface you already use.
From Apex specifically, you don’t hand-roll either HTTP flow: Salesforce ships Apex classes for querying Data 360 so an org can read its own unified data in a trigger or batch without managing tokens by hand. Pick the native API for external, high-volume, non-Salesforce callers; pick Connect/Apex when the caller already lives on the platform.
Where the credits go
Every Query API call is compute, and compute on Data 360 is metered. Queries consume credits against the same consumption model as the rest of the platform, and the cost is a function of how much data the engine has to scan to answer you — not how many rows you get back. A SELECT * with no WHERE clause against a billion-row DLO is expensive even if you LIMIT 10, because the engine still reasons over the table. The same trap sits under calculated versus streaming insights: the bill tracks work done, not results returned.
Practical hygiene that keeps the meter honest:
- Filter hard, and filter on indexed/partition fields where you can, so the engine scans less.
- Select only the columns you need. Wide
SELECT *on a table with hundreds of fields scans and serializes far more than a five-column projection. - Don’t poll. An external app that hammers the Query API every few seconds for “fresh” profiles is paying per poll. For change-driven needs, prefer Data 360-triggered flows or data actions that push on change instead of pulling on a timer.
- Cache aggressively on your side. If the same query serves many end users, run it once and cache; don’t federate the load back onto Data 360.
And, as always with a live API, respect the platform envelope: Query API calls count against your org’s broader API and rate limits, and a runaway pagination loop with no backoff is a good way to trip them.
Takeaways
- Authenticate twice. Get a core Salesforce token, then exchange it at
/services/a360/tokenfor a Data 360 token bound to a separate tenant host. Thecdp_query_apiscope on the connected app is non-optional, and every query goes to the tenantinstance_url, not yourmy.salesforce.comdomain. - Query V2 is a POST to
/api/v2/querywith a{"sql": "..."}body; paginate subsequent batches withGET /api/v2/query/{nextBatchId}. - Read
done, not the row array. A single response caps at 49,999 rows; loop untildoneis true or you will silently truncate large results. - Use the right tool for the size. Transactional reads of thousands of rows: Query API. Bulk replication of millions: zero-copy sharing, not pagination.
- The bill tracks scan, not return. Filter tightly, project narrow columns, cache on your side, and don’t poll — push on change instead.
Data 360’s value is that it holds the one resolved, governed version of your customer. The Query API is how the rest of your stack finally gets to read it — cleanly, in SQL, without copying it first. Get the two-token handshake right and the done-loop honest, and it’s one of the most useful doors in the platform.
Keep reading
All insights
Apache Iceberg in Data 360: file federation, the REST catalog, and the open-table bridge zero copy was missing
Keyword, vector, or hybrid: choosing the Data 360 search index that actually grounds your agent
Salesforce Data 360 vs. Microsoft Fabric: OneLake, the zero-copy bridge, and the job each one is actually for