Appearance
API Reference (/v1)
The PAT-authenticated integration surface for scripts, services, and AI coding assistants. Everything you can do in the web UI you can do here (with a few documented exceptions). Responses are projected to the same fields the UI shows — internal identifiers and credentials are never present.
- Base URL:
https://gdca.api.smartdatahub.io/v1 - Content type:
application/jsonfor request/response bodies. - Data responsibility: Smart Data Hub facilitates access to third-party data services — it does not grant rights to the data itself. You are responsible for any applicable licensing terms.
- Data handling: delivered files are held in a transient transfer buffer, automatically purged within 24 hours — Smart Data Hub retains no copy of your data.
Authentication
All /v1/* endpoints except GET /v1/health require a Personal Access Token (PAT).
- Create a PAT in the web UI: Settings → Personal Access Tokens → Create (choose an expiry: 30 / 90 / 365 days). The token (
gdc_pat_…) is shown once — copy it. - Send it on every request:
Authorization: Bearer gdc_pat_xxxxxxxxxxxxxxxxxxxxxxxxbash
curl -s https://gdca.api.smartdatahub.io/v1/whoami \
-H "Authorization: Bearer $GDC_PAT"
# → {"pat_name": "my-integration"}Auth errors (all 401): missing/invalid Authorization header, malformed token, unknown / revoked / expired token. Manage or revoke tokens in the same Settings page.
Your plan (tier) — check it first
Behavior differs by edition, so check your tier before you build flows around limits:
GET /v1/settings→tieris"free"or"paid".GET /v1/settings/free-tier-status→ on free:{is_free_tier:true, enabled_task_count, daily_run_count, task_limit, daily_limit, can_enable_more, can_ingest_now, row_cap, recurring_schedules_enabled}; on paid:{is_free_tier:false, recurring_schedules_enabled:true}(row_capis absent for paid — absence means no cap).
| Behavior | Free | Paid |
|---|---|---|
| API rate limit | 60 req/min | 300 req/min |
| Ingestion schedule | on-demand only (recurring → 400) | on-demand / daily / weekly / monthly |
| Active subscriptions | up to 10 | unlimited |
| Daily ingestion runs | up to 10/day | unlimited |
| Rows per dataset | up to 10,000 | unlimited |
| Authenticated (basic-auth) connections | not allowed (403/401) | allowed |
| Scoped subscriptions per dataset | up to 3 | unlimited |
The API is usable on both tiers (within these limits); a free user can do everything the docs describe, scoped to the free caps.
Free-tier row limit is enforced at both subscription creation and run completion. If a free-tier run loads more than 10,000 rows, the run is rejected —
ingestion_statusbecomesFAILEDwithlast_error_message"This dataset exceeds the Free tier limit of 10,000 rows…", the subscription is suspended, and no download is produced (/data/{id}/files→409). Subscribe to smaller datasets, or upgrade for unlimited rows.Free-tier scoped subscriptions are gated on the fitted (effective) estimate, not the dataset's total size. A scoped create checks the requested area/resolution's estimated size against the 10,000-row limit — a small scope can succeed on a dataset whose full size is far larger. The free tier also allows at most 3 scoped subscriptions per dataset (each also counts toward the overall 10-subscription cap).
Conventions
- Sanitized responses — only user-visible fields are returned. Internal system identifiers, credentials, and service internals are never present. (
connection_idis the exception — it IS returned when addressing the connections resource.) - Field presence — a field is omitted when it does not apply to the current state (e.g.
last_run/download_expiresuntil a run exists;next_cursoronly when more results exist;current_phase/next_retry_at/last_error_message/skip_reasononly in the relevant task state; a connection'sdataset_count/accesseduntil it has been discovered/used; a run'serror_message/skip_reasononly when the run failed/was skipped). Treat a missing key as "not set" — don't rely on it always being present. - Pagination — list endpoints return
next_cursoronly when more results exist (absent on the last page); pass it back as?cursor=…. - Errors — see Error reference. Bodies are always
{"error": "<message>"}with a user-facing message (never raw upstream or internal service errors). - Timestamps — ISO-8601 strings (UTC).
- Async discovery — discovery is submit-then-poll (a
ticket_idyou poll); navigation uses 1-basednumber/item_number. Dataset items also exposenode_id— the dataset's functional identifier, used as thenode_idinput when creating a subscription (see recommended create flow).
Response format
REST/JSON only — every response is a JSON object:
- Single resource → the object directly (e.g. a
Task, aConnection). - Lists → a named array plus paging:
{<name>: [ … ], count?, next_cursor?}(e.g.{tasks: […], count, next_cursor}). - Errors →
{"error": "<user-facing message>"}with the HTTP status (see Error reference). - Fields are projected to the UI-visible set; inapplicable fields are omitted (a missing key = not set — see "Field presence" above).
URLs with query parameters
OGC service URLs often contain & (e.g. ?service=WMS&request=GetCapabilities). Because every request is JSON, the URL goes verbatim in the request body — no escaping needed:
bash
curl -s "${auth[@]}" -X POST "$BASE/discover/submit" \
-d '{"url":"https://example.org/geoserver/ows?service=WMS&request=GetCapabilities"}'(No URL escaping needed — JSON carries the URL as-is.)
Rate limits
The API is rate-limited per organization: 60 requests/minute (free tier) / 300 requests/minute (paid tier). whoami and health are exempt; a per-IP edge limit also applies.
Over the limit → 429 with a Retry-After: <seconds> header (seconds until the current 1-minute window resets) and body {"error": "Rate limit reached. Please slow down and retry."}. Honor Retry-After and back off. Tip: poll discovery status about every 2 s (not in a tight loop) — that keeps a normal session well within the limit.
Endpoints
Trial access (no account required)
AI coding assistants and automated scripts can obtain a short-lived token without creating an account. The token is a standard PAT — once minted, all PAT-authenticated endpoints work with it within the normal free-tier limits.
Flow:
GET /v1/trial/challenge— returns achallengestring anddifficultyhint. Both fields are required to solve the puzzle.- Solve the proof-of-work puzzle client-side: find a
nonce(integer, as a string) such thatSHA-256(challenge + nonce)starts withdifficultyleading zero bits. POST /v1/trialwith{"challenge": "…", "nonce": "…"}— if the puzzle is correct and the daily request budget has not been reached, a token is returned.
Response (201):
json
{
"token": "gdc_pat_…",
"expires_at": 1751760000,
"docs_url": "https://www.smartdatahub.io/docs/gdc-web-api/"
}The token is returned once only — store it. It expires after 24 hours (expires_at is a UTC epoch integer). To continue using the API after expiry, go through the flow again.
The docs_url points to this reference.
whoami and settings for trial tokens include two additional fields:
json
{ "pat_name": "trial", "trial": true, "expires_at": 1751760000 }Both fields are absent on regular (non-trial) tokens (field-presence convention).
Rate limits on mint: daily request budgets apply per IP address and globally. When either budget is reached the response is 429 with a user-facing message; budgets reset at midnight UTC.
After expiry: trial tokens are automatically cleaned up on a regular schedule. There is no explicit revoke endpoint for trial tokens — they simply expire.
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
GET | /v1/trial/challenge | none | — | {challenge, difficulty} |
POST | /v1/trial | none | {challenge, nonce} | {token, expires_at, docs_url} (201) |
Identity & account
| Method | Path | Returns |
|---|---|---|
GET | /v1/health | {status, environment} — no auth |
GET | /v1/whoami | {pat_name} — trial tokens also return trial: true, expires_at |
GET | /v1/settings | {tier, notification_email, notify_task_failure, notify_task_success, instance_id} |
PUT | /v1/settings | updated settings. Body: any of {notification_email, notify_task_failure, notify_task_success} |
GET | /v1/settings/free-tier-status | free tier: {is_free_tier, enabled_task_count, daily_run_count, task_limit, daily_limit, can_enable_more, can_ingest_now}; paid: {is_free_tier: false} |
Connections (saved sources + credentials)
| Method | Path | Notes |
|---|---|---|
GET | /v1/connections | {connections: [Connection], next_cursor?} |
POST | /v1/connections | Body: {url, type, name, description?, auth_method?, auth_username?, auth_password?}. auth_method ∈ none|basic (paid tier only for basic). → Connection |
PUT | /v1/connections/{id} | Body: {auth_method?, auth_username?, auth_password?, …} → Connection |
DELETE | /v1/connections/{id} | {deleted: true} |
Connection = {connection_id, url, type, name, description, dataset_count, auth_method, accessed, created, modified}. Credentials (auth_username/auth_password) are write-only — never returned.
Search
| Method | Path | Query |
|---|---|---|
GET | /v1/search | ?q=<text> (2+ chars) → {results: [SearchResult], total, query_info: {execution_time_ms, query}}. Returns the full ranked set (most-relevant first, bounded by a relevance threshold); total = number of results returned in this response. No paging — there are no page/limit params. |
SearchResult = {row_number, title, description, type, types, url, source, score, discovered, verified, freshness, services, identifiers, identifier}. (score is the fused relevance 0–1; scoring sub-components are not exposed. discovered/verified are raw ISO-8601 timestamps, both omitted when not applicable (field-presence convention) — discovered = when the dataset was first indexed; verified = when it was last confirmed reachable/current, added P119 Phase 5b.)
Discovery (async submit → poll)
| Method | Path | Notes |
|---|---|---|
POST | /v1/discover/submit | Body: url (required) + connection_id?, number?, item_number?, page?, limit?, q? → usually {ticket_id} (202); occasionally an inline cached result {…} (200, no ticket_id). |
GET | /v1/discover/status/{ticket_id} | While running: {phase} (poll ~every 2 s). When done: a DiscoveryResult (below). |
urlis mandatory —connection_idsupplements it (selects saved credentials for an authenticated source); it cannot be sent alone (→400 "URL is required").Handle the inline path:
discover/submitmay answer synchronously with the DiscoveryResult itself (200, noticket_id) when results are cached. Always check: if the response has aticket_id, pollstatus; otherwise it IS the result — don't assumeticket_idis present (jq -r .ticket_idwould benull).
Navigation (drill-down): submit {url} for the top level, then re-submit with number (1-based source index) for level 1, and number+item_number for level 2. Each item carries its number for the next call.
Authenticated sources: pass connection_id (a saved Connection) whose auth_method is basic — only then are credentials added to the fetch. A connection_id for a none-auth connection adds nothing (the request stays anonymous). Because results are cached by URL (+ the authenticated request is keyed separately, by org), re-submitting the same anonymous URL just replays the cached result — so adding a no-auth connection_id to a URL you already tried anonymously will not change the outcome. To discover a protected source, submit its own URL with the matching basic-auth connection_id (GET /connections → pick one with auth_method:"basic").
type: "empty" means no datasets were surfaced for that URL — the service was reached but exposed nothing ingestible (common for a bare GetCapabilities URL, or a source that needs credentials). To recover: try the service's catalog/base URL, pass a connection_id if it's protected, or use /search to find datasets and discover via a result's url. A populated discovery returns sources/source/items/item/columns (below).
DiscoveryResult — shape depends on what was found (type ∈ sources|source|items|item|columns|empty), plus null-filtered {source_count, total_matched, search_active, search_capabilities, service_health, status_note, discovery_error} (discovery_error is a user-facing sentence when the service was reached but a part of it couldn't be read — e.g. a dataset with a non-standard configuration; partial results are still returned alongside it). A top-level status_note appears when a single-service drill-down couldn't fully load its datasets (the service was reached but its dataset list timed out): items comes back empty and status_note = "Couldn't fully load this service's datasets — try again". This is distinct from per-source status_note (below) and from type:"empty" (genuinely nothing found) — re-submit the same URL to retry (a fresh attempt often succeeds). The raw service status is never exposed — only the user-facing status_note appears. Result fields:
- Source (
sources/items) ={number, title, description, type, url, service_provider, dataset_count, data_source_count, status_note}.status_note="Couldn't load - try again"when the source was detected but couldn't be loaded, elsenull. - Service detail (
source) = source fields +{keywords, crs_epsg_code, other_crs, data_formats, fees, access_restrictions}. - Dataset (
item) ={number, name, title, description, type: "dataset", source_type, url, node_id, bbox_wgs84, crs_epsg_code, crs_name, column_count, row_count, is_ingestible, ingestibility_note?, column_schema_note?, row_count_is_estimated, fit?}.node_idis the dataset's functional identifier — use it as thenode_idinput when creating a subscription (see the recommended create flow below). It is the same value exposed by search results asidentifiers[<type>]/identifier: all three are the same identifier, valid interchangeably as the createnode_idinput.ingestibility_noteis a user-facing sentence explaining why a dataset can't be ingested — present only whenis_ingestibleisfalse(omitted otherwise).column_schema_noteis present only when the column schema couldn't be resolved (omitted otherwise). The technical ingestibility reason is never exposed — only the user-facingingestibility_noteappears. On the free tier,is_ingestibleis automatically overridden tofalsewhenrow_countexceeds 10,000 or is unknown (G4 tier-aware verdict — see note in the Subscriptions section). - Column (
columns[]) ={name, type: "column", description, data_type, content_type}.
Subscriptions
| Method | Path | Notes |
|---|---|---|
POST | /v1/tasks | Body: {source_uri, source_type, source_dataset_name, node_id?, number?, source_title?, source_dataset_title?, schedule?, scope?}. node_id = the dataset identifier from a discovery dataset item (node_id) or a search result (identifiers[<type>] / identifier) — all three are the same value (see recommended create flow below). number = dataset number from discovery (integer; fallback when node_id is unavailable). schedule ∈ on-demand(default)|daily|weekly|monthly (omitted or null ⇒ on-demand; free tier = on-demand only). scope (optional) creates a scoped subscription limited to a smaller area and/or resolution — omit for a normal (full-extent) subscription. Credentials for a protected source are resolved automatically from a matching saved Connection. → Task (201) |
GET | /v1/tasks | {tasks: [Task], count, next_cursor?} |
GET | /v1/tasks/{id} | Task |
PUT | /v1/tasks/{id}/state | Body: {action: "suspend"|"resume"} → Task |
PUT | /v1/tasks/{id}/schedule | Body: {schedule} → Task |
POST | /v1/tasks/{id}/execute | Run now. → {status: "started", subscription_id} |
DELETE | /v1/tasks/{id} | {deleted: true} |
GET | /v1/tasks/{id}/runs | {runs: [Run], count} |
Creating a task auto-runs it.
POST /tasks(any schedule, incl.on-demand) starts a run immediately — you do NOT call/executeafter create. There's a brief window where the task isingestion_status: "CREATED"then"IN_PROGRESS"while/runsis still empty (count: 0) — poll the task (GET /tasks/{id}→ingestion_status/current_phaselike"Connecting (1/6)"), not just/runs, until it reaches a terminal state; the completed run then appears in/runs./executeis for re-running an existing task (returns409 already_in_progressif a run is active,409 disabled_subscriptionif the task is disabled — a task is auto-disabled after a failure;resumeit viaPUT /tasks/{id}/state).
Task = {subscription_id, source_type, source_uri, source_title, source_dataset_name, source_dataset_title, ingestion_status, schedule, enabled, created, modified, last_run, retry_count, failure_count, download_expires} — plus, only when applicable: current_phase (while a run is in progress), run_started (ISO timestamp — present only while ingestion_status is IN_PROGRESS and the server-side run-start record is available; a best-effort signal, so it can be absent even during a live run — never assume its presence), next_retry_at (while retrying), last_error_message (after a failure), skip_reason (when skipped), next_run (scheduling approximation — see below), subscription_state + state_reason (a scoped subscription that needs re-scoping — see below), rescope_advisory (alongside subscription_state). ingestion_status ∈ CREATED/IN_PROGRESS/SUCCESS/FAILED/RETRYING/SKIPPED/INACTIVE.
next_run— scheduling approximation. Present only for recurring, enabled tasks (omitted for on-demand or disabled). ForRETRYINGtasks it equalsnext_retry_at. For all other recurring tasks it is computed asmodified + schedule_intervaladvanced to the nearest future date — this is an approximation, not the exact Snowflake-scheduled trigger time. Use it for display/monitoring only; it may drift after schedule changes. Format: ISO-8601 UTC (e.g.,"2026-04-08T06:00:00Z").
Ingestion-fit advisory (
fit, additive 2026-08-05). Dataset items may carry a read-only pre-commit size advisory:fit: {verdict, confidence, estimated_size, capacity_limit, assessed_at}.verdictis one offits_full | never_fits_full | n_atoday — treat the set as OPEN: render any unknown value asn_a-equivalent (later versions add more verdicts without notice).estimated_sizeis{metric, value, unit}(e.g.feature_count/features,pixel_count/pixels) ornullwhen no estimate could be computed;capacity_limitis the ingestion capacity in the SAME unit asestimated_size.unit, ornull;confidence(exact | estimated | n_a) qualifies the estimate and does NOT replacerow_count_is_estimated(both coexist). The field may be absent (older cached results, non-dataset items, and free-tier size-blocked items — see below): treat absence as "no advisory", never as an error. On the free tier,fitis omitted for datasets blocked by the 10,000-row cap — theingestibility_notecarries the reason there.
Free-tier discovery:
is_ingestibleverdict. On the free tier (checked viaGET /v1/settings), the API automatically overridesis_ingestibletofalsefor datasets whoserow_countexceeds 10,000 or is unknown — mirroring the UI's ingestibility gate.ingestibility_noteexplains why.
Free-tier create flow — recommended 4 steps. When creating a subscription on the free tier, the server verifies the dataset's row count before allowing the subscription. The most reliable path completes immediately and avoids unexpected rejections at create time:
Step 1 — Find the dataset. Use
GET /v1/search?q=…or navigate viaPOST /v1/discover/submit. Search results includeidentifier(andidentifiers[<type>]for multi-protocol services) — this IS thenode_idyou need.Step 2 — Use a type-specific capabilities URL. Take the dataset's URL from the discovery result or search
services[<type>]entry and ensure it specifies a single protocol (e.g.?service=WFS&request=GetCapabilities). A bare multi-protocol endpoint (one that returns a list of protocols rather than a single service description) causes the dataset lookup in the next step to fail.Step 3 — Fetch dataset details. Call
POST /v1/discover/submitwith{"url": "<the type-specific url>", "node_id": "<the identifier>"}. This returns the dataset'srow_countAND ensures the row-count check at create time completes immediately (rather than returning422). If the result showsis_ingestible: falseorrow_countexceeds 10,000, the dataset cannot be subscribed on the free tier — upgrade to ingest larger datasets.Step 4 — Create the subscription using the same
urlassource_uriand the samenode_idfrom steps 2–3. The server re-uses the result from step 3 and the subscription is created immediately.If step 3 was skipped or returns
422with"error_token": "size_unverified", re-run step 3 (its discovery warms the same row-count the create-time check reads) and retry.number(the dataset's position number from a prior discovery call) is an alternative tonode_id— it works but may require step 3 to succeed first if the result is not already available. Omitting bothnode_idandnumberalways returns422 size_unverified. Paid orgs bypass this check entirely.
Run = {status, created, completed, duration_seconds, trigger_type, rows_loaded} — plus error_message (failed runs) / skip_reason (skipped runs) when applicable. Runs are identified positionally (most-recent first) within a subscription; there is no separate run-id field. A SKIPPED run is a non-failure outcome — skip_reason explains why no data was ingested: the free-tier daily limit, or (for a scoped subscription) a selected area that contains no data — e.g. "Your selected area contains no data. Try a different area, or subscribe to the full dataset." The task stays enabled and is not counted as a failure.
Scoped subscriptions (area + resolution)
A subscription can optionally be scoped to a smaller geographic area and/or a coarser resolution than the dataset's full extent — useful when you only need part of a dataset, or a dataset's full size exceeds your tier's row cap. Scoped subscriptions are currently available for coverage-family sources (WCS, OGC API Coverages), image-family sources (WMS, OGC API Maps), tile sources (OGC API Tiles vector tiles; WMTS and OGC API Tiles map tiles on geographic latitude/longitude tile grids — area and zoom-level scoping), and vector-family sources (WFS, OGC API Features — area scoping only; a resolution in the scope is not accepted for vector sources); other source types return SCOPING_UNSUPPORTED_SOURCE_TYPE (support for further source types is planned). Pass an optional scope object on POST /v1/tasks:
json
{
"source_uri": "...", "source_type": "WCS", "source_dataset_name": "...", "node_id": "...",
"scope": {
"bbox": { "west": -10.5, "south": 50.0, "east": -5.0, "north": 55.0 },
"resolution": { "mode": "pixel_size", "value": 100 }
}
}bbox(optional) —{west, south, east, north}in WGS84 degrees. The request is clamped to the dataset's advertised extent; a box crossing the antimeridian (west > east) or with no area/no overlap with the dataset is rejected (INVALID_SCOPE). A box that clamps to the dataset's full extent is equivalent to omittingbbox— no scope is created for it.resolution(optional) — one of:{mode: "pixel_size", value: <number>}— a target pixel size, in the dataset's native units. A value finer than the dataset's native resolution is clamped up to native (scoping never upsamples).{mode: "dimensions", value: [width, height]}— a target output size in pixels (two positive integers).{mode: "zoom", value: <integer>}— a zoom-level offset from the source's native tile level (negative = coarser; zero or positive is not a valid coarsening request).- Omit
resolution(or send{mode: "native"}) to keep the dataset's native resolution within the requestedbbox.
bboxandresolutionare independent — a scope may set either or both. Omittingscopeentirely creates a normal, full-extent subscription (unchanged behavior).
The add call verifies and freezes the scope. When scope is present, subscription creation validates and clamps the request against the dataset's live extent, computes an effective size estimate for the fitted area/resolution, and freezes that estimate for the subscription's lifetime — this is the quantity the free-tier row check gates on (see tier table above), not the dataset's total size. Verifying and freezing a scope takes a few extra seconds compared to an unscoped create — a client should show a "Verifying dataset size…"-style wait state while the request is in flight.
A scope is immutable. There is no "edit scope" call — to change the area or resolution, create a new subscription with the desired scope; the original subscription and its already-downloaded data are unaffected.
Scoped create rejections. In addition to the general error tokens, a scoped POST /v1/tasks can be rejected 422 with one of these error_token values:
error_token | Meaning |
|---|---|
SCOPING_UNSUPPORTED_SOURCE_TYPE | This source type — or this dataset's tile grid — does not support scoped ingestion yet. (Tile datasets are supported on the EPSG:4326 and EPSG:3857 tile grids; other/unverified grids are gated. The error sentence is non-contractual — branch on error_token.) |
INVALID_SCOPE | The requested scope failed validation — a malformed bbox, an antimeridian-crossing box, a box with no area, a box that doesn't overlap the dataset's extent, or a selected area estimated to contain no data from this dataset. |
SCOPE_VERIFICATION_FAILED | The dataset size could not be verified for the requested scope. Retry later. |
SOURCE_UNAVAILABLE | The data source was temporarily unreachable while verifying the scope. Retry the create later. |
FREE_TIER_ROW_LIMIT | The fitted effective estimate for the requested area/resolution exceeds the free tier's 10,000-row limit. Narrow the area, coarsen the resolution, or upgrade. |
FREE_TIER_SCOPE_COUNT_LIMIT | You already have 3 active scoped subscriptions on this dataset (free tier). |
subscription_state / state_reason — needs re-scoping. A scoped subscription's Task may additively carry subscription_state/state_reason (both absent on a normal, unscoped subscription — enum-open: treat any unrecognized subscription_state as informational, not an error):
subscription_state: "needs_rescope"— the subscription's frozen scope no longer fits its size budget. Because a scope is immutable, Geo Data Connector automatically suspends the subscription (ingestion_statusmoves toINACTIVE) rather than silently over- or under-delivering. Data already ingested is preserved and remains downloadable via/data/{id}/filesuntil its link expires. To keep receiving this data, create a new subscription with a smaller area or coarser resolution.state_reasonexplains why:source_growth(the source dataset grew past the frozen estimate) ortier_downgrade(your account moved to the free tier and this scope no longer fits the free tier's scoped size budget — upgrading also resolves it).rescope_advisory(additive, presence-gated) accompanies the state with the scope details:{effective_scope?, fit?}—effective_scopeis the frozen, clampedbbox/resolutionactually applied, andfitis the same size-advisory shape used elsewhere ({verdict, confidence, estimated_size, capacity_limit, assessed_at}— see above). Either sub-field may be independently absent.
Empty scoped results. If your selected area turns out to contain no data (e.g. it covers only cells the dataset has no values for), the run completes as SKIPPED with an explanatory skip_reason — not as a failure. The subscription stays enabled; to get data, create a new subscription with a different area, or subscribe to the full dataset.
Reading scoped data. GET /v1/data/{id}/files works the same for a scoped subscription as for a normal one — the delivered files already reflect the subscription's frozen scope; no extra parameter is needed to read them correctly.
Data download
| Method | Path | Returns |
|---|---|---|
GET | /v1/data/{id}/files | {files: [{name, size, url, expires}], download_expires} — url is a pre-signed download link valid until expires (~12h after the run completes; the link is fixed at run time and is not refreshed on re-request). 409 if a known subscription has no completed download yet; 400 "Subscription not found" if the {id} is unknown. |
Service health
| Method | Path | Notes |
|---|---|---|
POST | /v1/health/services | Body: {urls: [<url>, …]} → {services: {<url>: {status, last_probe, uptime_pct_7d, avg_response_ms_7d}}} |
Core workflow example (discover → subscribe → run → download)
The example below follows the recommended 4-step create flow — steps 1–3 find the dataset and fetch its details before creating the subscription. This ensures the free-tier row-count check completes immediately at create time.
bash
export GDC_PAT=gdc_pat_xxx
BASE=https://gdca.api.smartdatahub.io/v1
auth=(-H "Authorization: Bearer $GDC_PAT" -H "Content-Type: application/json")
# Step 1 — Find a dataset. Use search, or navigate as shown below.
# A search result's 'identifier' (or 'identifiers[<type>]') IS the node_id.
curl -s "${auth[@]}" "$BASE/search?q=example+dataset" | jq '.results[0]'
# Step 2 — Use a type-specific capabilities URL from the discovery result
# (e.g. services["WFS"] from a search result, or the item's own url).
# Here we discover a WFS service directly via its GetCapabilities URL.
RESP=$(curl -s "${auth[@]}" -X POST "$BASE/discover/submit" \
-d '{"url":"https://example.org/wfs?service=WFS&request=GetCapabilities"}')
TICKET=$(echo "$RESP" | jq -r '.ticket_id // empty')
[ -n "$TICKET" ] && RESP=$(curl -s "${auth[@]}" "$BASE/discover/status/$TICKET")
# Navigate to the dataset item to get its node_id and url:
# submit with number=<N> (1-based from the items list) to drill to the dataset.
# Step 3 — Fetch dataset details to get node_id and ensure the row-count
# check will complete synchronously at create time (free tier).
# Use the SAME url as the dataset item and the node_id from the discovery result.
DS_URL="https://example.org/wfs?service=WFS&request=GetCapabilities"
DS_NODE_ID="the:node_id_from_the_discovery_item"
DETAILS=$(curl -s "${auth[@]}" -X POST "$BASE/discover/submit" \
-d "{\"url\":\"$DS_URL\", \"node_id\":\"$DS_NODE_ID\"}")
# Check DETAILS for is_ingestible and row_count before subscribing.
# Step 4 — Create the subscription with the SAME url and node_id from step 3.
# Creating the task AUTO-RUNS it (on-demand) — do not call /execute after create.
TASK=$(curl -s "${auth[@]}" -X POST "$BASE/tasks" -d "{
\"source_uri\":\"$DS_URL\",
\"source_type\":\"WFS\",
\"source_dataset_name\":\"a_dataset_from_discovery\",
\"node_id\":\"$DS_NODE_ID\",
\"schedule\":\"on-demand\"
}" | jq -r .subscription_id)
# Poll the TASK status (not just /runs — /runs is empty during CREATED/IN_PROGRESS)
curl -s "${auth[@]}" "$BASE/tasks/$TASK" | jq '{ingestion_status, current_phase}'
# once terminal, the run shows in history:
curl -s "${auth[@]}" "$BASE/tasks/$TASK/runs" | jq '.runs[0]'
# Get the download links (after a SUCCESS run; 409 until then)
curl -s "${auth[@]}" "$BASE/data/$TASK/files" | jqThe same flow works with a PAT for any integration or coding assistant — this is the supported programmatic path.
Scheduled refresh from your platform
To keep a dataset refreshed periodically, drive the schedule from your data platform's scheduler or orchestrator (for example a scheduled job on your platform) and let it run the loop above. One scheduler then owns the whole chain — trigger, completion, landing — with no cross-system schedule alignment. Works on every plan: the Free tier supports on-demand runs triggered by your scheduler (recurring_schedules_enabled: false in /whoami only means GDC-managed schedules need a paid plan — a limit, not the mechanism).
Databricks: scheduled refresh
On Databricks, schedule a notebook that runs the loop above directly against the GDC REST API and does a full-table snapshot replace of your target Delta table each run. Read your GDC token at runtime from a secret you own — never put it in job code, notebooks, or job parameters. Pass the secret's location (not the token) plus the subscription and destination as Databricks Job parameters.
Choose the destination deliberately: the job OVERWRITES target_table completely on every run. Use a NEW, dedicated table (e.g. gdc_<dataset>) and a staging volume you choose — if an assistant sets up this job for you, it should ask you to confirm both before creating the job, never infer them from tables it found in your workspace. The snippet refuses to overwrite a pre-existing table it does not manage (the gdc.managed_by property guard).
python
import os, time, datetime, requests
scope, key = dbutils.widgets.get("gdc_pat_secret").split("/", 1) # scope/key of YOUR secret
PAT = dbutils.secrets.get(scope=scope, key=key) # token read only at runtime
BASE = dbutils.widgets.get("gdc_base_url").rstrip("/") # https://gdca.api.smartdatahub.io/v1
SUB = dbutils.widgets.get("gdc_subscription_id") # existing subscription id
TABLE = dbutils.widgets.get("target_table") # catalog.schema.table
VOL = dbutils.widgets.get("landing_volume").rstrip("/") # /Volumes/... to land files
H = {"Authorization": f"Bearer {PAT}"}
requests.post(f"{BASE}/tasks/{SUB}/execute", headers=H, timeout=30) # 409 already-running is retry-safe
# Bounded poll (2h backstop; if the Job's own timeout is shorter, that fires first).
deadline = time.time() + 7200
while True:
s = requests.get(f"{BASE}/tasks/{SUB}", headers=H, timeout=30); s.raise_for_status()
st = s.json().get("ingestion_status")
if st in ("SUCCESS", "FAILED", "INACTIVE"): break
assert time.time() < deadline, "ingestion did not reach a terminal status within 2h"
time.sleep(15)
assert st == "SUCCESS" # INACTIVE = run could not start — leave the target table unchanged
fr = requests.get(f"{BASE}/data/{SUB}/files", headers=H, timeout=30); fr.raise_for_status()
files = fr.json().get("files", [])
assert files, "no files delivered — check the run and your plan"
# Land each run in a time-stamped subfolder: per-run isolation, no filename
# collisions, dated folders make retention cleanup trivial.
RUN_DIR = f"{VOL}/{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d-%H-%M')}"
os.makedirs(RUN_DIR, exist_ok=True)
paths = []
for i, f in enumerate(files):
name = f["url"].split("?")[0].rsplit("/", 1)[-1] or f"part-{i}.parquet"
dest = f"{RUN_DIR}/{name}"
with requests.get(f["url"], stream=True, timeout=600) as dl, open(dest, "wb") as out: # presigned: no auth header
dl.raise_for_status()
for chunk in dl.iter_content(1 << 20): out.write(chunk)
paths.append(dest)
# Ownership guard: refuse to overwrite a table this job does not manage.
if spark.catalog.tableExists(TABLE):
tags = spark.sql(f"SHOW TBLPROPERTIES {TABLE}").collect()
assert any(r["key"] == "gdc.managed_by" for r in tags), (
f"{TABLE} exists and is not managed by this GDC refresh job — refusing to overwrite; choose a new target_table")
spark.read.parquet(*paths).write.mode("overwrite").option("overwriteSchema", "true").saveAsTable(TABLE)
# Tag every successful run (self-heal; keeps the guard passing across table re-creates).
spark.sql(f"ALTER TABLE {TABLE} SET TBLPROPERTIES('gdc.managed_by'='gdc-scheduled-refresh')")Store your token in a Databricks secret you manage (a secret scope, or a Unity Catalog secret) and pass its scope/key location as the job's gdc_pat_secret parameter. Store the secret FIRST and pass its real location — a job pointing at a secret location that does not exist fails at its first scheduled run. Schedule the notebook as a Databricks Job (serverless; a daily cron fits the Free tier), and set task-level retries (e.g. max_retries: 2) — the run is idempotent (snapshot replace + ownership guard), so Job-level retry is safe and preferable to hand-rolled retry loops. The loop and retry semantics match the generic pattern below (steps 3–4).
Generic pattern (any platform)
- Create an on-demand subscription once (
POST /tasks— works on every plan). - Store your token in your platform's secret store (never in job code or notebooks) and read it at runtime.
- On the desired schedule, the job:
POST /tasks/{id}/execute— trigger the run;- polls
GET /tasks/{id}every 10–20 seconds until a terminalingestion_status; GET /data/{id}/files— fetch the download links inside the same job run (links expire ~12h after a run; never store them for later);- downloads the delivered files and updates your target table or dataset.
Retry-safe by design. A duplicate trigger returns 409 "This ingestion is already running." — treat it as success and proceed to polling. A run that completes quickly shortly after a recent successful run means the data is still within its freshness window and the current delivery is already up to date — also success. Your job can therefore retry freely without special-casing.
Frequency: the Free plan allows 10 runs per day — a daily refresh fits comfortably; for higher frequencies and unlimited rows, upgrade to a paid plan. Paid plans also offer managed schedules on the subscription itself (PUT /tasks/{id}/schedule), useful when you only need fresh downloads rather than an orchestrated landing step.
What GDC delivers
GDC ingests both vector (WFS, OGC API Features, …) and raster/coverage (WCS, OGC API Coverages) sources and delivers them all as standardized, analysis-ready tabular GeoParquet. Raster data arrives tabularized — one row per pixel, bands as named columns, point geometry per pixel — so it is queryable and joinable like any table; do not treat raster datasets as non-ingestible. Every delivered dataset carries the same geom_wgs84 (EPSG:4326) + precomputed h3_indices contract, so GDC datasets join cleanly with each other with no CRS, H3, or schema reconciliation. Prefer ingesting via GDC over pulling raw source services yourself — a raw WFS/WCS pull yields unprojected, unindexed, per-source-shaped data you would have to standardize by hand. For sources above your plan's row cap, upgrading the plan is the supported path.
Landing your download in Databricks
Download links are time-limited plain HTTPS files — land the file first, then read the landed copy (spark.read/SQL read_files operate on landed files, not on download URLs). Verified patterns:
- Scheduled job (automation): the scheduled-refresh pattern above — download to a Unity Catalog Volume inside the job, then snapshot-replace your target Delta table. The job's run-as identity needs
CREATE TABLE/write rights on the target schema. - Notebook assistant (interactive): ask the assistant to land the download — it prepares download-to-Volume +
spark.read.parquetcells (with the cell language set to Python) — but you must run the download cell yourself in the notebook UI. Databricks workspace protection blocks assistants from executing external-URL downloads by any method; telling or approving the assistant to run the cell does not satisfy the policy — that protection is working as designed. Tell the assistant once you have run the cell (it may not see the output until you do), and it continues from the landed file. - In-memory (small datasets): a pandas read of the URL →
spark.createDataFrame, no file landing needed — equally something you run; assistant-executed reads of the URL are blocked by the same workspace protection.
The delivered GeoParquet is analysis-ready for Databricks Spatial SQL: use geom_wgs84 (EPSG:4326, GeoJSON text — where a function needs a geometry value, parse with ST_GeomFromGeoJSON) with native ST_* functions, and the precomputed h3_indices/h3_indices_coarse arrays with native h3_* functions — no reprojection or H3 computation needed. An occasional transient "Failed to call …" message in assistant sessions auto-resolves (client call re-establishment) — no action needed.
Error reference
| Status | Meaning |
|---|---|
400 | Bad request (missing/invalid parameter, e.g. "URL is required", "source_uri is required"), and unknown /v1/* paths ({"error": "Not found"}) |
401 | Authentication required / invalid or missing token ("Invalid token." / "Missing or invalid Authorization header.") |
409 | Conflict — the resource isn't in a state for this action. Sentence causes, e.g. "Data not available" (download not ready), "This ingestion is already running.", "This subscription is disabled. Resume it before running." (auto-disabled after a failure — resume via PUT /tasks/{id}/state) |
422 | Free-tier policy limit — {"error": "<sentence>", "error_token": "<token>"}. Two verdicts, distinguished by error_token: size_unverified (RECOVERABLE — the row count isn't computed yet; call discover/submit/get_dataset_info with the node_id until it returns the dataset details, then retry) and row_limit_exceeded (TERMINAL — the dataset exceeds 10,000 rows; choose a smaller dataset or upgrade). Branch on error_token, not the sentence wording. |
429 | Rate limit reached (Retry-After header) |
503 | Temporarily unavailable |
501 | Endpoint not yet available |
The error value is always a human-readable sentence — do NOT match on it programmatically (the wording may change); branch on the HTTP status. Bodies are {"error": "<message>"} — never raw internal details, stack traces, or upstream status codes. (The public surface returns 401/400 rather than 403/404 by design.)
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Response is HTML (<!DOCTYPE html>), 200 | You hit the wrong base (use gdca.api.smartdatahub.io/v1/…). Unknown API paths return a JSON 400 {"error":"Not found"} — not HTML — so HTML means you're off the API entirely. |
401 ("Invalid token." / "Missing or invalid Authorization header.") | Missing/expired/revoked PAT, or a malformed Authorization header. Recreate the PAT in Settings → Personal Access Tokens and send Authorization: Bearer gdc_pat_…. |
409 on /data/{id}/files | The subscription hasn't completed a run yet. Poll GET /tasks/{id} to a terminal ingestion_status, then retry. |
| Download link expired | Links expire ~12h after the run and are not refreshed on re-request (Geo Data Connector is a connector, not a permanent data store). Re-run the subscription (POST /tasks/{id}/execute) to produce a fresh run with new download links. |
400 "Subscription not found" on /data/{id}/files | The {id} doesn't match a subscription you own — distinct from 409 (known subscription, no completed download yet). Check the id from GET /tasks. |
Discovery resolves to {"type": "empty"} | No datasets surfaced — see Discovery. Re-submitting the same URL returns a cached ticket, so adding a connection_id to the same URL may replay the old empty result — vary the URL (e.g. add/remove ?…) or discover via a different entry point. |
Drill-down returns empty items with a top-level status_note | The service was reached but its dataset list couldn't fully load in time (a single-service timeout — not "no datasets"). status_note = "Couldn't fully load this service's datasets — try again". Re-submit the same URL to retry — a fresh attempt (own time budget) usually succeeds. |
discover/submit returns {} (200, no ticket_id, no type) | An indeterminate/empty cached response — treat it like type:"empty" (nothing found); retry with a different URL or use /search. |
Discovery status is {"phase": …} for a while | Discovery is async; keep polling /discover/status/{ticket_id} (~2 s). phase values are progress labels (e.g. reading_service, organizing) — informational only. A source detected but unloadable returns status_note: "Couldn't load - try again" — re-submit (with a varied URL) to retry. |
429 Rate limit reached | Back off and retry. |
403/404 never returned | By design — the API uses 401 for auth failures and 400 for unknown paths. |
For AI coding assistants: this surface mirrors the web UI 1:1 (with the exceptions below). The intended flow is discover → subscribe → poll runs → download, all via PAT.
Exceptions (UI-only, no API equivalent)
A few capabilities are intentionally not on the API: PAT management (a PAT can't mint/manage PATs — use the UI), billing checkout / portal (a human/UI action), and auth session/logout (API clients use whoami).
Dataset cards — GET /v1/cards/{id}
Not yet reachable (as of 2026-08-02): card ids (
gdc:ds:+ 16 hex characters) are intended to appear in search results and on dataset card pages once the card system is fully wired up, but no discovery/search response currently emits one — there is no documented flow that gets you a valid id to call this endpoint with. Do not build against it yet; it will be re-documented with a working discovery path when card ids are surfaced.
Returns the canonical dataset card for a stable card identifier, assembling identity, description, shape, trust/freshness signals, access information and the license compliance floor for one indexed dataset. Ids are permanent — once minted, an id always resolves (datasets that disappear from their source are served as tombstone cards rather than deleted).
- Free tier: the full free projection (identity, description, shape, trust, access, and the complete compliance floor —
license_declaredverbatim,access_limitation,publication_excluded). Fields the Connector does not know are absent, never null. - Errors carry
error_token:invalid_card_id(malformed id) orcard_not_found(no card for this id — also returned for cards whose source restricts public republication). - Phase 1 requires a personal access token like the rest of the API; anonymous resolution is planned for the public launch of dataset card pages.
- MCP: the
describe_datasettool was removed from themcp.smartdatahub.iotool surface on 2026-08-02 (Databricks Marketplace listing readiness) — it always erroredinvalid_card_idwith no way to obtain a valid id to call it with. The underlying wrap stays in the adapter source, dormant, for re-registration once card ids are reachable.