# Darly — full developer documentation > Base URL: https://api.darly.io · Human-readable docs: https://darly.io/docs · OpenAPI: https://darly.io/openapi.json --- # Overview > One API for public lidar elevation data — a STAC catalog plus an Elevation API, with a single key and one set of quotas. Darly indexes national lidar programs into one normalized catalog and serves the data two ways: - **STAC API** — a standards-compliant [STAC](https://stacspec.org) catalog of every dataset we index: collections, item footprints, projection metadata, and cloud-optimized GeoTIFF (COG) assets. Works with `pystac-client`, QGIS, GDAL, and any STAC browser. Browsable anonymously. - **Elevation API** — point lookups, batch lookups, path sampling, and raster extracts computed from the best available source at each location. Requires a free API key. All endpoints live on one host: ```text https://api.darly.io ``` ## The two APIs at a glance | | STAC API | Elevation API | |---|---|---| | Purpose | Find and download source data | Get elevations and DEM extracts | | Auth | Anonymous OK ([API key raises limits](/docs/authentication)) | API key required | | Formats | STAC JSON, COG assets | JSON, cloud-optimized GeoTIFF | | Endpoints | `/collections`, `/search`, … | `/v1/elevation/*` | | Docs | [STAC catalog](/docs/stac) | [Points](/docs/elevation) · [Raster](/docs/raster) | ## Typical first call ```bash cURL curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```python Python import requests resp = requests.get( "https://api.darly.io/v1/elevation/point", params={"lat": 45.51, "lon": -73.56}, headers={"X-API-Key": DARLY_API_KEY}, ) print(resp.json()["result"]["elevation"]) ``` ```javascript JavaScript const resp = await fetch( "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56", { headers: { "X-API-Key": process.env.DARLY_API_KEY } }, ) const { result } = await resp.json() console.log(result.elevation) ``` Head to the [quickstart](/docs/quickstart) for the full walkthrough, including creating a key. ## For LLMs and agents These docs are built to be machine-readable. Point your tools at: - [`/llms.txt`](/llms.txt) — index of all documentation in the [llms.txt](https://llmstxt.org) convention. - [`/llms-full.txt`](/llms-full.txt) — the complete documentation as a single Markdown file. - Every page is available as raw Markdown at `/docs/md/{slug}` (for example [`/docs/md/quickstart`](/docs/md/quickstart)). - [`/openapi.json`](/openapi.json) — OpenAPI 3.1 description of every public endpoint, including parameter schemas and error shapes. ## Support - Community and email support: [contact@darly.io](mailto:contact@darly.io) - Coverage questions: see [coverage & datasets](/docs/coverage) or the interactive [explorer](/explorer). - Plans and pricing: [darly.io/#pricing](/#pricing); limits are documented in [rate limits & quotas](/docs/limits). --- # Quickstart > Create an API key and make your first elevation lookup and STAC search in under five minutes. ## 1. Create an API key 1. [Sign up or sign in](/auth/sign-in) — the Free plan needs no card. 2. Open [Dashboard → API keys](/dashboard/keys) and create a key. 3. Copy it immediately — the full key is shown once. Keep the key out of source control. The examples below read it from the `DARLY_API_KEY` environment variable: ```bash export DARLY_API_KEY="dk_..." ``` ## 2. Look up an elevation Ground elevation (DTM) for a point in Montréal: ```bash cURL curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/v1/elevation/point", params={"lat": 45.51, "lon": -73.56}, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) resp.raise_for_status() print(resp.json()["result"]) ``` ```javascript JavaScript const resp = await fetch( "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56", { headers: { "X-API-Key": process.env.DARLY_API_KEY } }, ) const { result } = await resp.json() console.log(result) ``` ```json Response { "status": "OK", "result": { "lat": 45.51, "lon": -73.56, "elevation": 36.42, "collection": "can-nrcan-hrdem-1m", "vertical_datum": "egm2008", "surface": "dtm", "resolution": 1.0 } } ``` Darly picked the finest dataset covering the point — here 1 m lidar from NRCan HRDEM. Elevations are always meters. Batch lookups, path sampling, surface/vertical-datum options: see the [Elevation API reference](/docs/elevation). ## 3. Search the catalog The same host is a full STAC API. Find 1 m lidar items around Vancouver: ```bash cURL curl -X POST "https://api.darly.io/search" \ -H "Content-Type: application/json" \ -d '{ "collections": ["can-nrcan-hrdem-1m"], "bbox": [-123.3, 49.1, -122.9, 49.4], "limit": 5 }' ``` ```python Python from pystac_client import Client catalog = Client.open("https://api.darly.io") search = catalog.search( collections=["can-nrcan-hrdem-1m"], bbox=[-123.3, 49.1, -122.9, 49.4], max_items=5, ) for item in search.items(): print(item.id, item.assets["dtm"].href) ``` ```javascript JavaScript const resp = await fetch("https://api.darly.io/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collections: ["can-nrcan-hrdem-1m"], bbox: [-123.3, 49.1, -122.9, 49.4], limit: 5, }), }) const { features } = await resp.json() for (const item of features) console.log(item.id, item.assets.dtm?.href) ``` STAC search works without a key (30 requests/min per IP); sending your key raises the limit to your plan's. See the [STAC catalog docs](/docs/stac). ## 4. Extract a raster Clip a GeoTIFF DEM of downtown Montréal — a 512 px preview works on the Free plan: ```bash cURL curl -G "https://api.darly.io/v1/elevation/raster" \ --data-urlencode "bbox_left=-73.60" \ --data-urlencode "bbox_bottom=45.49" \ --data-urlencode "bbox_right=-73.54" \ --data-urlencode "bbox_top=45.53" \ --data-urlencode "width_px=512" \ --data-urlencode "height_px=512" \ -H "X-API-Key: $DARLY_API_KEY" \ -o montreal-dtm.tif ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/v1/elevation/raster", params={ "bbox_left": -73.60, "bbox_bottom": 45.49, "bbox_right": -73.54, "bbox_top": 45.53, "width_px": 512, "height_px": 512, }, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) resp.raise_for_status() with open("montreal-dtm.tif", "wb") as f: f.write(resp.content) ``` The response is a cloud-optimized GeoTIFF (float32 meters, `-9999` nodata) that opens directly in QGIS, GDAL, or rasterio. Full parameter reference: [raster extracts](/docs/raster). ## Next steps - [Authentication](/docs/authentication) — header options, key management, how quotas attach to your account. - [Rate limits & quotas](/docs/limits) — what each plan includes and every error shape. - [Coverage & datasets](/docs/coverage) — which data answers your queries, and where. - [COG downloads](/docs/downloads) — whole-file source downloads on paid plans. --- # Authentication > API keys, the three ways to send them, anonymous access, and how quotas attach to your account. ## What needs a key | Surface | Anonymous | With API key | |---|---|---| | STAC catalog (`/collections`, `/search`, …) | ✓ 30 req/min per IP | Higher per-plan limits | | Elevation API (`/v1/elevation/*`) | ✗ 401 | ✓ | | COG downloads (`/dl/...`) | ✗ 401 | Paid plans only | | `/v1/health` | ✓ | ✓ | Keys are free: [sign up](/auth/sign-in), then create one under [Dashboard → API keys](/dashboard/keys). The full key is displayed once at creation. ## Sending the key Three equivalent forms, in order of preference: ```bash X-API-Key header curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```bash Bearer token curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56" \ -H "Authorization: Bearer $DARLY_API_KEY" ``` ```bash Query parameter # For tools that can't set headers (e.g. GIS software URL fields). curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56&api_key=$DARLY_API_KEY" ``` Prefer the headers. The gateway strips `api_key` from the query string before forwarding requests upstream, but a key sent in a URL can still land in intermediary and access logs along the way. ## Keys, users, and quotas - **Quotas are per account, not per key.** All keys you create draw from the same monthly budgets and per-minute limits. Create as many keys as you want to separate environments or teammates' tools — revoking one never affects the others. - Revoke keys anytime in [Dashboard → API keys](/dashboard/keys); revocation is immediate. - Your plan's limits are listed in [rate limits & quotas](/docs/limits). ## Auth errors | Case | Response | |---|---| | Elevation call without authentication | `401` `{"detail": "Authentication is required for the elevation API"}` | | Download without authentication | `401` `{"detail": "Authentication is required to download assets"}` | | Invalid or revoked key | Treated as anonymous: STAC keeps working at the anonymous limit; elevation calls get the `401`s above | | Plan doesn't include the endpoint | `403` `{"detail": "The current plan does not include this endpoint"}` | | Plan doesn't include downloads | `403` `{"detail": "The current plan does not include downloads"}` | A silent fallback to anonymous access on a bad key can be surprising: if STAC calls work but elevation calls return `401`, check the key value first. --- # Elevation API: points > Point lookups, batch lookups, and path sampling — parameters, response shapes, and source-selection behavior. Elevations computed from the best available source at each location, returned in meters. All endpoints require an [API key](/docs/authentication). | Endpoint | Methods | Input | |---|---|---| | `/v1/elevation/point` | `GET` | `lat`, `lon` | | `/v1/elevation/points` | `GET`, `POST` | `latlons` — pipe-separated `lat,lon\|lat,lon\|…` | | `/v1/elevation/sample` | `GET`, `POST` | `path` (same syntax, ≥ 2 vertices) + `samples=N` | `POST` variants accept a JSON body with the same field names as the query parameters (the body wins on conflict). In a body, `latlons`/`path` may be a pipe-separated string, a list of `"lat,lon"` strings, or a list of `[lat, lon]` pairs. `sample` computes N evenly spaced points along the geodesic polyline (endpoints included), then behaves exactly like `points` — useful for elevation profiles along a route. ## Examples Single point: ```bash cURL curl "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/v1/elevation/point", params={"lat": 45.51, "lon": -73.56}, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) print(resp.json()["result"]["elevation"]) ``` ```javascript JavaScript const resp = await fetch( "https://api.darly.io/v1/elevation/point?lat=45.51&lon=-73.56", { headers: { "X-API-Key": process.env.DARLY_API_KEY } }, ) const { result } = await resp.json() console.log(result.elevation) ``` Batch (each point in a batch counts as one lookup against your [limits](/docs/limits)): ```bash cURL curl -X POST "https://api.darly.io/v1/elevation/points" \ -H "X-API-Key: $DARLY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"latlons": "45.51,-73.56|49.28,-123.12|19.43,-99.13"}' ``` ```python Python import os import requests resp = requests.post( "https://api.darly.io/v1/elevation/points", json={"latlons": [[45.51, -73.56], [49.28, -123.12], [19.43, -99.13]]}, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) for r in resp.json()["results"]: print(r["lat"], r["lon"], r["elevation"]) ``` ```javascript JavaScript const resp = await fetch("https://api.darly.io/v1/elevation/points", { method: "POST", headers: { "X-API-Key": process.env.DARLY_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ latlons: [[45.51, -73.56], [49.28, -123.12], [19.43, -99.13]], }), }) const { results } = await resp.json() ``` Elevation profile — 50 samples along a leg of Mount Rainier: ```bash cURL curl -G "https://api.darly.io/v1/elevation/sample" \ --data-urlencode "path=46.85,-121.76|46.79,-121.74" \ --data-urlencode "samples=50" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/v1/elevation/sample", params={"path": "46.85,-121.76|46.79,-121.74", "samples": 50}, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) profile = [r["elevation"] for r in resp.json()["results"]] ``` ## Shared options All point endpoints accept: | Param | Values | Default | Behavior | |---|---|---|---| | `surface` | `dtm`, `dsm` | `dtm` | Terrain (bare earth) or surface (includes buildings/canopy). Only collections carrying the requested asset are considered. | | `vertical_datum` | `egm2008`, `wgs84_ellipsoid` | `egm2008` | `egm2008` = orthometric height above the EGM2008 geoid (what most people mean by "elevation above sea level"). `wgs84_ellipsoid` = height above the WGS84 ellipsoid (what raw GPS reports). Converted from each source's native datum via geoid grids. | | `interpolation` | `bilinear`, `nearest` | `bilinear` | Ignored when `radius` > 0. Bilinear degrades to nearest where a full 2×2 neighborhood is unavailable (raster edges, nodata neighbors). | | `radius` | meters, 0–100 | `0` | `0` samples at the point. Otherwise: mean of valid cells whose center falls within the circle (nodata excluded). A radius smaller than the pixel degrades to the containing cell. | | `collections` | ordered STAC collection IDs | omitted | Comma-separated (JSON array on POST). IDs are the STAC collection IDs from the [catalog](/docs/stac) — list them via `GET /collections`. Only these collections are considered, and **order is priority: the first collection answers wherever it has valid data; each later one is only consulted where every earlier one came up empty** (nodata or no coverage). Useful where overlapping collections exist and you know which you want. Omitted: all collections your plan allows, ranked finest resolution first — lidar naturally beats the 30 m globals. Unknown and not-allowed IDs return the same error; so does an ID without the requested `surface`. | ## How the source is chosen For each point: 1. Find candidate items intersecting the point (filtered by your allowed collections and the requested `surface`). 2. Rank by collection resolution (finest first), then item date (newest first) within a collection. An explicit `collections` list replaces the resolution ranking with your order — first in the list is tried first (item date still breaks ties within a collection). 3. Read the best candidate. Nodata falls through to the next candidate (voids, coastal edges, tile overlap) — and to the global 30 m datasets last. 4. Nothing valid anywhere → `elevation: null`, `collection: null`; the request still succeeds. There is no fake bathymetry: open ocean is `null`, not `0`. ## Response shape `point` returns `result` (single object); `points`/`sample` return `results` (array, same order as the input): ```json Response { "status": "OK", "result": { "lat": 45.51, "lon": -73.56, "elevation": 36.42, "collection": "can-nrcan-hrdem-1m", "vertical_datum": "egm2008", "surface": "dtm", "resolution": 1.0 } } ``` `resolution` is the source dataset's ground sampling distance in meters — use it to judge how much to trust the value. ## Caps and errors - Up to **512 points per request** on paid plans, 100 on Free (`latlons` entries or `samples`), up to **25 `path` vertices**, `radius` up to **100 m**. - Validation failures, including unknown query/body fields, return `400` `{"status": "INVALID_REQUEST", "error": "…"}`. - Missing key → `401`; per-minute and monthly limit errors → `429`. See [rate limits & quotas](/docs/limits) for bodies and headers. --- # Elevation API: raster extracts > Clip a DEM as a cloud-optimized GeoTIFF — elevation or hillshade, with output size or resolution, projection, and provenance options. `GET /v1/elevation/raster` clips a DEM over your bounding box and returns a **cloud-optimized GeoTIFF** — either float32 elevation in meters (`-9999` nodata, the default) or, with `render=hillshade`, a uint8 shaded-relief image — composited from the best available sources exactly like [point lookups](/docs/elevation). Requires an [API key](/docs/authentication); response bytes count against your monthly [data-out quota](/docs/limits). ```bash cURL curl -G "https://api.darly.io/v1/elevation/raster" \ --data-urlencode "bbox_left=-121.82" \ --data-urlencode "bbox_bottom=46.75" \ --data-urlencode "bbox_right=-121.65" \ --data-urlencode "bbox_top=46.90" \ --data-urlencode "width_px=1024" \ --data-urlencode "height_px=1024" \ --data-urlencode "projection=utm" \ -H "X-API-Key: $DARLY_API_KEY" \ -o rainier-dtm.tif ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/v1/elevation/raster", params={ "bbox_left": -121.82, "bbox_bottom": 46.75, "bbox_right": -121.65, "bbox_top": 46.90, "width_px": 1024, "height_px": 1024, "projection": "utm", }, headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, ) resp.raise_for_status() print("sources:", resp.headers["x-data-sources"]) with open("rainier-dtm.tif", "wb") as f: f.write(resp.content) ``` ```javascript JavaScript const params = new URLSearchParams({ bbox_left: "-121.82", bbox_bottom: "46.75", bbox_right: "-121.65", bbox_top: "46.90", width_px: "1024", height_px: "1024", projection: "utm", }) const resp = await fetch( `https://api.darly.io/v1/elevation/raster?${params}`, { headers: { "X-API-Key": process.env.DARLY_API_KEY } }, ) const tiff = Buffer.from(await resp.arrayBuffer()) ``` The result opens directly in QGIS, GDAL, and rasterio. ## Parameters | Param | Values | Default | Behavior | |---|---|---|---| | `bbox_left`, `bbox_bottom`, `bbox_right`, `bbox_top` | numbers | required | Extract bounds, in `bbox_projection` coordinates. Antimeridian-crossing bboxes are rejected. | | `bbox_projection` | `epsg:NNNN` | `epsg:4326` | CRS of the bbox parameters. | | `projection` | `epsg:NNNN`, `utm` | `epsg:4326` | Output raster CRS. `utm` picks the UTM zone of the bbox center. | | `width_px` + `height_px` | 10–2,500 each | — | Exact output size; output bounds match the bbox exactly. | | `resolution_m` | 0.5–1,000 | — | Pixel size in meters (converted to degrees at the bbox center latitude for geographic output CRS). Bounds are buffered right/down to whole pixels. | | `resolution_x` + `resolution_y` | > 0 | — | Pixel size in output-projection units. Bounds buffered like `resolution_m`. | | `source_mask` | `true`, `false` | `false` | Adds band 2 (`source_index`): per-pixel source collection, mapped by the `darly-source-map` tag (0 = nodata). Not valid with `render=hillshade`. | | `output_profile` | `default`, `zstd` | `default` | COG compression: DEFLATE, or ZSTD (smaller and faster, needs newer readers). | | `render` | `elevation`, `hillshade` | `elevation` | Output type. `hillshade` returns a single uint8 shaded-relief band (1–255, `0` = nodata) instead of elevation values. Mutually exclusive with `source_mask`. | | `azimuth`, `altitude`, `z_factor` | 0–360, 0–90, >0–100 | 315, 45, 1 | Hillshade sun direction (°), sun angle above horizon (°), and vertical exaggeration. Only apply when `render=hillshade`. | | `surface` | `dtm`, `dsm` | `dtm` | Terrain (bare earth) or surface (includes buildings/canopy). Only collections carrying the requested asset are composited. | | `vertical_datum` | `egm2008`, `wgs84_ellipsoid` | `egm2008` | Output vertical datum. `egm2008` = orthometric height above the EGM2008 geoid (what most people mean by "elevation above sea level"). `wgs84_ellipsoid` = height above the WGS84 ellipsoid (what raw GPS reports). Every source is converted **before** compositing. | | `interpolation` | `bilinear`, `nearest` | `bilinear` | Resampling used to warp each source onto the output grid. Bilinear for smooth terrain; nearest preserves original cell values. | | `collections` | ordered STAC collection IDs | omitted | Comma-separated, priority order: **the first collection ends up on top of the mosaic**; later ones only fill its gaps. IDs are the STAC collection IDs from the [catalog](/docs/stac) — list them via `GET /collections`. Omitted: all collections your plan allows, finest resolution first. Unknown and not-allowed IDs return a `400`. | Provide **exactly one** sizing spec: `width_px`+`height_px`, `resolution_m`, or `resolution_x`+`resolution_y`. The point-lookup `radius` parameter does not apply to rasters. ## How the mosaic is built 1. Collections are ranked like point lookups: your allowed collections, filtered by `surface`, finest resolution first — or exactly the `collections` you listed, in your order. **The first collection in the list ends up on top of the mosaic.** 2. Sources are composited onto the output grid in rank order (newest item first within a collection); each later source only fills pixels the earlier ones left as nodata — it never overwrites. 3. Each source is converted to the requested `vertical_datum` **before** compositing, which removes the systematic vertical offset between datasets. Pixels a source can't datum-shift (e.g. NAVD88 outside CONUS — see [coverage](/docs/coverage)) fall through to the next source. 4. There is **no blending or feathering** at dataset seams — remaining seams are real differences between surveys, not artifacts we hide. Provenance is exposed instead: the `x-data-sources` response header, the `darly-collections` / `darly-source-map` GeoTIFF tags, and the optional `source_mask` band. 5. A very large bbox over a dense lidar collection automatically falls through to the next-ranked collection instead of opening thousands of tiles. If you pinned a single collection explicitly, there is nothing to fall through to and you get a `400` asking for a smaller bbox instead. A bbox no collection covers still returns `200` with an all-nodata raster — the raster equivalent of `elevation: null`. ## Hillshade `render=hillshade` shades the composited terrain into a single **uint8** band (1–255, `0` = nodata) instead of returning elevation values — a ready-to-map shaded relief. The mosaic is built identically (same ranking, datum normalization, and `surface` selection); the shading is computed from the result with correct metric ground spacing, so it is accurate even for the default WGS84 output. Tune the light with `azimuth`, `altitude`, and `z_factor`; the defaults (315° / 45° / 1) match the Explorer's shaded-relief backdrop. `source_mask` is not available in this mode. ```bash cURL curl -G "https://api.darly.io/v1/elevation/raster" \ --data-urlencode "bbox_left=-121.82" \ --data-urlencode "bbox_bottom=46.75" \ --data-urlencode "bbox_right=-121.65" \ --data-urlencode "bbox_top=46.90" \ --data-urlencode "resolution_m=10" \ --data-urlencode "render=hillshade" \ -H "X-API-Key: $DARLY_API_KEY" \ -o rainier-hillshade.tif ``` Because uint8 is a quarter the size of float32, a hillshade costs correspondingly less against your data-out quota than the same extract in elevation mode. ## Caps - Output size: **2,500 px per side** (Pro/Advanced), **512 px** on Free. - Bbox extent: **100 km** per side (geodesic). - Per-minute raster calls are limited separately from point lookups — see [rate limits & quotas](/docs/limits). Worst case (2,500², float32) is ~25 MB before compression; responses are returned directly, no signed-URL indirection. ## Raster vs. downloads The raster endpoint reprojects, mosaics, and normalizes datums for you, and meters the actual response bytes. If you want the **original source files** untouched, use the STAC catalog: source-agency assets are direct public URLs, and Darly-hosted assets are downloadable on paid plans via [COG downloads](/docs/downloads). --- # STAC catalog API > Browse and search the full catalog with any STAC client — collections, item search, pagination, and asset access. The catalog is a standards-compliant **STAC API** (SpatioTemporal Asset Catalog). Anything that speaks STAC works against it out of the box: `pystac-client`, [STAC Browser](https://radiantearth.github.io/stac-browser/#/external/api.darly.io/), [STAC Map](https://developmentseed.org/stac-map/?href=https%3A%2F%2Fapi.darly.io), the QGIS STAC plugin, GDAL. No API key needed to browse — anonymous callers get 30 requests/min per IP, keys raise that to your plan's limit. ```text https://api.darly.io ``` ## Collections and Items A **Collection** is one coherent elevation dataset in Darly. It is either a global product, such as Copernicus DEM, or a specific selected dataset from a national (or other regional) lidar programme — for example, the USGS 3DEP seamless 1 m DTM. It is not a catch-all for every dataset an agency publishes. Collection metadata describes the source, coverage, resolution, and available surface models. An **Item** is one spatial piece of that Collection, usually a source raster tile. Its geometry and bounding box show the area it covers; its properties carry the relevant acquisition and projection metadata; and its assets link to the terrain (`dtm`) and/or surface (`dsm`) raster for that area. Search for Items when you need data covering a place or time; use Collection metadata to choose the dataset first. ## Endpoints | Endpoint | Methods | Purpose | |---|---|---| | `/` | `GET` | Landing page + conformance links | | `/collections` | `GET` | List all collections | | `/collections/{id}` | `GET` | One collection's metadata | | `/collections/{id}/items` | `GET` | Items in a collection (paginated) | | `/collections/{id}/items/{item_id}` | `GET` | One item with full asset links | | `/search` | `GET`, `POST` | Cross-collection spatial/temporal search | `POST` is accepted only on `/search`. Collection IDs and coverage are listed in [coverage & datasets](/docs/coverage). ## Using an API key Browsing works without a key, so attaching one is optional — but it does two things for STAC: - **Raises your rate limit** from the anonymous 30 req/min per IP to your plan's limit. - **Unlocks download links.** On paid plans, the single-item endpoint replaces Darly-hosted asset `href`s with `/dl/...` links (see [items and assets](#items-and-assets)); anonymous callers never see them. Send the key the same way as everywhere else — the `X-API-Key` header is preferred. It attaches to *any* STAC call; the search below just shows the common case: ```bash cURL curl -X POST "https://api.darly.io/search" \ -H "Content-Type: application/json" \ -H "X-API-Key: $DARLY_API_KEY" \ -d '{"collections": ["usa-usgs-3dep-1m-seamless"], "limit": 10}' ``` ```python pystac-client from pystac_client import Client # headers ride on every request the client makes — search, item fetch, paging. catalog = Client.open( "https://api.darly.io", headers={"X-API-Key": DARLY_API_KEY}, ) ``` ```javascript JavaScript const resp = await fetch("https://api.darly.io/search", { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": DARLY_API_KEY, }, body: JSON.stringify({ collections: ["usa-usgs-3dep-1m-seamless"], limit: 10 }), }) ``` For tools whose STAC field only takes a URL (some GIS clients, `/vsicurl`), append `?api_key=…` to the request instead of setting a header — but a key in a URL can leak into logs, and it won't ride along as clients follow `next` links. `Authorization: Bearer` works too. All three forms, the security trade-offs, and how quotas attach to your account are covered in [authentication](/docs/authentication). ## Searching ```bash cURL curl -X POST "https://api.darly.io/search" \ -H "Content-Type: application/json" \ -d '{ "collections": ["usa-usgs-3dep-1m-seamless"], "bbox": [-122.52, 37.70, -122.35, 37.83], "limit": 10 }' ``` ```python Python from pystac_client import Client catalog = Client.open("https://api.darly.io") search = catalog.search( collections=["usa-usgs-3dep-1m-seamless"], bbox=[-122.52, 37.70, -122.35, 37.83], max_items=10, ) for item in search.items(): print(item.id, item.assets["dtm"].href) ``` ```javascript JavaScript const resp = await fetch("https://api.darly.io/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ collections: ["usa-usgs-3dep-1m-seamless"], bbox: [-122.52, 37.70, -122.35, 37.83], limit: 10, }), }) const { features, links } = await resp.json() ``` Standard STAC search parameters are supported: `collections`, `bbox`, `intersects` (GeoJSON geometry), `datetime` ranges, `ids`, and `limit`. ## Pagination Page size defaults to **10 items** and is capped at **100**. Responses carry a `rel: next` link; follow it until absent (`pystac-client` does this for you): ```json Response { "type": "FeatureCollection", "features": ["…"], "links": [ { "rel": "next", "href": "https://api.darly.io/search?token=…" } ] } ``` Prefer narrow `bbox`/`datetime` filters over crawling: search and item-list requests consume rate-limit budget proportional to their page size (one unit per 10 items requested — see [limits](/docs/limits)), so whole-catalog dumps are slow by design. ## Items and assets ### Item properties Darly uses the following properties consistently across elevation Items: | Property | Meaning | |---|---| | `datetime` | Representative acquisition timestamp. For an Item with a temporal interval, this is always the elapsed-time midpoint between `start_datetime` and `end_datetime`. | | `start_datetime`, `end_datetime` | Inclusive source acquisition bounds. Both are present together or both are absent. | | `created`, `updated` | Creation and last-update timestamps for the STAC metadata. | | `vertical_datum` | Canonical vertical reference for elevation values, such as `NAVD88`, `CGVD2013`, or `EGM2008`. | | `vertical_datum_epsg` | EPSG identifier for the vertical CRS when one is available. | | `elevation_unit` | Elevation unit; currently `m` for every collection. | | `checksum:sha256` | Checksum used to track changes to the upstream source record. | For interval Items, the midpoint helps clients that display or sort Items by one date. It does not mean that the whole raster was acquired at that instant: use `start_datetime` and `end_datetime` when the complete acquisition period matters. An Item that represents a true instant keeps its source-provided `datetime` and has no interval fields. Elevation rasters use consistent asset keys across all collections — `dtm` (terrain) and/or `dsm` (surface) — with projection metadata (`proj:code`, `proj:transform`, `proj:shape`) on each asset, so GDAL and rasterio can read windows without opening the file blind. Asset `href`s come in two flavors: - **Source-hosted** (USGS, NRCan, AWS Open Data, OpenGeoHub): direct public HTTPS URLs — fetch them however you like, they never touch your Darly quotas. - **Darly-hosted** (Mexico COGs re-hosted on our storage): on paid plans, the single-item endpoint (`/collections/{id}/items/{item_id}`) replaces these with `/dl/...` download links — see [COG downloads](/docs/downloads). Multi-item responses (search, item lists) omit Darly-hosted assets; fetch the individual item to get its download link. ## Errors - Unknown collections — and collections your plan doesn't include — return the same `404` `{"detail": "Not found"}`. - `POST` anywhere but `/search` → `405`. - Over the per-minute limit → `429` with `X-RateLimit-*` headers; see [rate limits & quotas](/docs/limits). --- # COG downloads > Whole-file downloads of Darly-hosted source COGs on paid plans — how /dl links work and how they're metered. Some collections' source files are hosted on Darly's own storage (currently the Mexico lidar COGs — agency-hosted collections like 3DEP and HRDEM have direct public URLs instead; see [coverage](/docs/coverage)). On plans with downloads enabled (Pro and up), you can download these files whole through `/dl` links. ## Getting a download link Fetch the **single-item** endpoint — Darly-hosted assets are rewritten to `/dl` links: ```bash cURL curl "https://api.darly.io/collections/mex-inegi-lidar-5m/items/{item_id}" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```json Response (asset excerpt) { "assets": { "dtm": { "href": "https://api.darly.io/dl/mex-inegi-lidar-5m/{item_id}/dtm", "type": "image/tiff; application=geotiff; profile=cloud-optimized", "roles": ["data"] } } } ``` Search and item-list responses omit Darly-hosted assets — always drill into the item. The `/dl` link itself is stable and never expires. ## Downloading `GET /dl/{collection}/{item}/{asset}` authenticates you, charges the file's size to your monthly data-out quota, and answers `302` with a short-lived signed URL. Any HTTP client that follows redirects just works: ```bash cURL curl -L -o tile.tif \ "https://api.darly.io/dl/mex-inegi-lidar-5m/{item_id}/dtm" \ -H "X-API-Key: $DARLY_API_KEY" ``` ```python Python import os import requests resp = requests.get( "https://api.darly.io/dl/mex-inegi-lidar-5m/{item_id}/dtm", headers={"X-API-Key": os.environ["DARLY_API_KEY"]}, allow_redirects=True, ) resp.raise_for_status() with open("tile.tif", "wb") as f: f.write(resp.content) ``` ```javascript JavaScript // fetch follows redirects by default const resp = await fetch( "https://api.darly.io/dl/mex-inegi-lidar-5m/{item_id}/dtm", { headers: { "X-API-Key": process.env.DARLY_API_KEY } }, ) const buf = Buffer.from(await resp.arrayBuffer()) ``` ## Metering rules - **Charged at full file size, once per download** — against the same monthly data-out quota as [raster extracts](/docs/raster). - The signed URL in the redirect lives **5 minutes** — start the transfer promptly; re-hit the `/dl` link for a fresh one. - **15-minute grace window**: repeat requests for the same asset within 15 minutes redirect again without a second charge. This covers multi-open readers (GDAL/QGIS open files more than once) and HEAD-then-GET clients. After the window, a new request is a new charge. - `HEAD` charges like `GET` (its redirect already exposes a live signed URL). - Partial reads still pay the full file: `/dl` is for whole files. If you want a window, a mosaic, or a different projection, the [raster endpoint](/docs/raster) meters actual bytes instead. ## Errors | Case | Response | |---|---| | No authentication | `401` `{"detail": "Authentication is required to download assets"}` | | Plan without downloads (Free) | `403` `{"detail": "The current plan does not include downloads"}` | | Monthly data-out quota would be exceeded | `429` `{"detail": "Monthly data-out quota exceeded"}` | | Unknown collection/item/asset, or a non-Darly-hosted asset | `404` `{"detail": "Not found"}` | --- # Rate limits, quotas & errors > Per-plan monthly quotas, per-minute burst limits, rate-limit headers, and every error shape the API returns. Plans are compared on [the pricing page](/#pricing); this page is the full fine print. Limits are enforced at the API gateway, per account — all your keys share the same budgets. ## Monthly quotas | | Free | Pro | Advanced | |---|---|---|---| | Elevation lookups / month | 10,000 | 1,000,000 | 5,000,000 | | Data out / month | 500 MB | 50 GB | 250 GB | - **Lookups are counted per point, not per request.** A `points` call with 512 coordinates, or a `sample` call with `samples=512`, costs 512 lookups. Raster calls don't consume lookups. - **Data out** covers raster-extract response bytes and [COG downloads](/docs/downloads) (charged at full file size). Point lookup responses are tiny JSON and don't count. - Quotas are charged only on successful (2xx) responses and reset at the start of each calendar month (UTC). - Need more? [contact@darly.io](mailto:contact@darly.io). ## Per-minute burst limits | | Anonymous | Free | Pro | Advanced | |---|---|---|---|---| | STAC requests / min | 30 (per IP) | 300 | 1,000 | 1,000 | | Elevation points / min | — | 100 | 3,000 | 12,000 | | Raster calls / min | — | 2 | 20 | 60 | | Max points per request | — | 100 | 512 | 512 | | Max raster size (px per side) | — | 512 | 2,500 | 2,500 | | STAC page size (default / max) | 10 / 100 | 10 / 100 | 10 / 100 | 10 / 100 | Elevation points/min is point-weighted like the monthly quota: one 512-point batch consumes 512 units of a Pro plan's 3,000/min budget. Burst capacity is reserved before a request is processed, so requests that finish with a `4xx` or `5xx` still consume per-minute capacity. They are never charged to monthly quotas. A request rejected by the burst limiter does not consume additional capacity; wait until `X-RateLimit-Reset` before retrying. There are no overage bills: past a monthly quota the API returns `429` until the month rolls over or you upgrade. STAC requests/min is page-weighted: `/search` and `/collections/{id}/items` calls consume one unit per 10 items requested (a `limit=100` page costs 10 units; everything else, including metadata routes, costs 1). Small pages and narrow filters stretch your budget further than crawling at `limit=100`. ## Rate-limit headers Every response carries the state of the limiter that applied to it: ```text X-RateLimit-Limit: 300 X-RateLimit-Remaining: 297 X-RateLimit-Reset: 1783041000 ``` `X-RateLimit-Reset` is a Unix timestamp (seconds). On `429`, wait until the reset before retrying — a simple sleep-until-reset loop is more effective than exponential backoff here, because windows are fixed, not sliding. ## Error reference Key off the **HTTP status code** (and `X-RateLimit-*` headers), not the body text. | Status | When | Body | |---|---|---| | `400` | Invalid elevation parameters | `{"status": "INVALID_REQUEST", "error": "…"}` | | `401` | Elevation or download call without authentication | `{"detail": "Authentication is required for the elevation API"}` / `…to download assets"}` | | `403` | Plan doesn't include the endpoint (e.g. downloads on Free) | `{"detail": "The current plan does not include this endpoint"}` / `…downloads"}` | | `404` | Unknown route, collection, item, or asset — including collections outside your plan | `{"detail": "Not found"}` | | `405` | `POST` to a STAC route other than `/search` | `{"detail": "Method not allowed"}` | | `429` | Per-minute limit hit | `{"detail": "Rate limit exceeded"}` + `X-RateLimit-*` headers | | `429` | Monthly quota exhausted | `{"detail": "Monthly lookup quota exceeded"}` / `{"detail": "Monthly data-out quota exceeded"}` | | `500` | Elevation backend failure | `{"status": "SERVER_ERROR", "error": "…"}` | | `502` | Upstream service unavailable | `{"detail": "…"}` | An invalid or revoked key is **not** an error on STAC routes — it falls back to anonymous access silently. See [authentication](/docs/authentication#auth-errors). ## Playing nice - Batch point lookups into `points` calls instead of many `point` calls — same lookup cost, far fewer requests against the per-minute window. - Cache responses on your side; elevation for a fixed location doesn't change between data releases. - Use `bbox`/`datetime` filters and reasonable `limit`s on STAC search instead of paginating the world. --- # Coverage & datasets > Every collection Darly serves — region, resolution, products, license — plus source ranking and vertical-datum caveats. Browse coverage interactively in the [explorer](/explorer), or query it from the [STAC API](/docs/stac) (`GET /collections`). Collection IDs follow `{country}-{agency}-{program}-{resolution}`. ## Collections | Collection ID | Region | Source | Resolution | Products | License | |---|---|---|---|---|---| | `usa-usgs-3dep-1m-seamless` | United States | USGS 3DEP (lidar) | 1 m | DTM | Public domain (CC0) | | `usa-usgs-3dep-1m` | United States | USGS 3DEP per-project DEMs (lidar) | 1 m | DTM | Public domain (CC0) | | `can-nrcan-hrdem-1m` | Canada (southern) | NRCan HRDEM (lidar) | 1 m | DTM + DSM | Open Government Licence – Canada | | `can-nrcan-hrdem-2m` | Canada (northern) | NRCan HRDEM (ArcticDEM) | 2 m | DTM + DSM | Open Government Licence – Canada | | `mex-inegi-lidar-1-5m` | Mexico (urban areas) | INEGI lidar | 1.5 m | DTM + DSM | INEGI Terms of Free Use | | `mex-inegi-lidar-5m` | Mexico | INEGI lidar | 5 m | DTM + DSM | INEGI Terms of Free Use | | `glo-esa-copdem-30m` | Global | ESA Copernicus DEM (GLO-30) | 30 m | DSM | ESA/Airbus free license | | `glo-ogh-gedtm-30m` | Global | OpenGeoHub GEDTM | 30 m | DTM | CC-BY 4.0 | | `sam-ufrgs-anadem-30m` | South America | UFRGS/ANA ANADEM | 30 m | DTM | CC-BY 4.0 | Lidar collections cover their programs' published extents, not every square meter of the country — check the [explorer](/explorer) or search the catalog with your AOI to see what's indexed where. ## How coverage affects elevation queries The [Elevation API](/docs/elevation) ranks candidate collections finest-first at every location, falling through on nodata: 1. Lidar first — 1 m before 1.5 m before 2 m before 5 m. 2. The 30 m datasets last, regional before global: for `surface=dtm`, `sam-ufrgs-anadem-30m` answers in South America (its vegetation-bias correction is calibrated for the continent) and `glo-ogh-gedtm-30m` everywhere else; `glo-esa-copdem-30m` for `surface=dsm`. Equal-resolution ties break to the collection with the smaller coverage extent, so regional products are preferred over global fallbacks wherever they apply — pass `collections` explicitly to override. The two USA 1 m collections are the exception: the seamless mosaic always answers before the per-project tiles it was built from, and the per-project collection fills in where the mosaic has no coverage yet. Queries on land within a dataset's published extent normally fall back to a 30 m global product, and queries in covered lidar areas get the good stuff automatically. Open ocean and locations outside the selected product's extent (including DTM queries south of GEDTM's roughly 65°S limit) return `null`. The response's `collection` and `resolution` fields tell you which source answered. Note the surface split: 3DEP publishes DTM only, Copernicus is DSM only. A `surface=dsm` query in the USA falls to Copernicus 30 m; a `surface=dtm` query almost anywhere in CONUS gets 1 m lidar. ## Vertical datums Output datums are `egm2008` (default) and `wgs84_ellipsoid` — see the [`vertical_datum` option](/docs/elevation#shared-options). Conversions from each source's native datum use geoid grids with limited footprints: - **NAVD88 grids cover CONUS only.** Sources published on NAVD88 can't be datum-shifted in Alaska, Hawaii, or Mexico; those pixels fall through to the next collection (typically the global 30 m datasets) rather than returning a wrong height. - WGS84 is a datum ensemble: `wgs84_ellipsoid` heights carry roughly ±2 m of realization ambiguity and are not survey-grade. ## Coming next France (IGN LiDAR HD), Germany (state programs), and New Zealand (LINZ) are queued — the same collection contract, so `pystac-client` code and elevation queries pick them up without changes. Want a dataset prioritized? Tell us: [contact@darly.io](mailto:contact@darly.io).