OGC API Features Explained: The Standard Behind Modern Spatial APIs

Problem statement

Every organisation invents the same spatial API. The endpoints are /features, /layers/{id}/query, /api/v2/geo; the bounding-box parameter is bbox, extent, envelope or four separate numbers; paging is offset/limit, page/size, or a cursor; and the response is GeoJSON with a differently named metadata block.

Each version is reasonable. Collectively they mean every client is bespoke, no tool can consume a new service without a wrapper, and the questions "how do I get the next page?" and "which coordinate system is this?" have to be answered again per service.

OGC API Features is the standard that settles those questions. It is a small REST specification โ€” five resource types, JSON everywhere, GeoJSON as the default output โ€” and adopting it costs almost nothing on a service you were going to build anyway.

Quick answer

Five resources, and the URL structure is the whole specification:

GET /                          the landing page: links to everything else
GET /conformance               which parts of the standard this service implements
GET /collections               the datasets available
GET /collections/{id}          one dataset: extent, CRS, item count
GET /collections/{id}/items    the features, filtered and paged
GET /collections/{id}/items/{featureId}    one feature

The query parameters on /items are standardised too:

?bbox=-74.5,40.4,-73.5,41.0        minx,miny,maxx,maxy
?datetime=2025-01-01/2025-12-31    an instant or an interval
?limit=50                          page size, with a documented maximum
?<property>=<value>                simple attribute equality

A client that understands those six URLs and four parameters can consume any conformant service without knowing anything about it in advance.

Five OGC API Features resources listed in order from the landing page to a single feature.
QGIS and GDAL already speak this, which is most of the argument for adopting it.

Step-by-step solution

Every response carries a links array, and clients navigate by following them rather than by constructing URLs. That is what makes the standard self-describing:

{
  "title": "Provinces service",
  "links": [
    {"href": "/",             "rel": "self",        "type": "application/json"},
    {"href": "/conformance",  "rel": "conformance", "type": "application/json"},
    {"href": "/collections",  "rel": "data",        "type": "application/json"}
  ]
}

The rel values are the contract. A client looking for the data follows rel="data"; a client paging follows rel="next".

2. Declare what you conform to

{
  "conformsTo": [
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
    "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30"
  ]
}

This is honesty as an endpoint. A service that implements the core and GeoJSON conformance classes and nothing else says so, and clients adapt rather than guessing.

3. Describe each collection properly

{
  "id": "provinces",
  "title": "Administrative provinces",
  "extent": {
    "spatial": {"bbox": [[-180, -90, 180, 90]], "crs": "โ€ฆ/CRS84"},
    "temporal": {"interval": [["2025-01-01T00:00:00Z", null]]}
  },
  "itemType": "feature",
  "crs": ["โ€ฆ/CRS84", "http://www.opengis.net/def/crs/EPSG/0/27700"],
  "links": [{"href": "/collections/provinces/items", "rel": "items",
             "type": "application/geo+json"}]
}

The extent block is the most useful part for a client: it can zoom to the data before requesting any of it.

4. Implement /items with the standard parameters

bbox, datetime, limit and simple property equality. The measured reason bbox matters: on a 4,596-feature dataset, the whole collection was 54.06 MB of GeoJSON and a city-sized bounding box returning two features was 58.8 kB.

limit needs a documented maximum. Measured, a page of 1,000 features was 12.4 MB and the server sustained 2.3 requests per second; a page of 50 was 857 kB at 32.5 requests per second.

The response carries the next page's URL:

{
  "type": "FeatureCollection",
  "features": [...],
  "numberMatched": 4596,
  "numberReturned": 50,
  "links": [
    {"href": "/collections/provinces/items?limit=50&offset=50", "rel": "next"},
    {"href": "/collections/provinces/items?limit=50", "rel": "self"}
  ]
}

numberMatched and numberReturned tell the client how much is left. The next link means the client never has to know whether the service pages by offset or by cursor โ€” which is what lets a service change its paging strategy without breaking anybody.

6. Be explicit about the coordinate system

The default is CRS84 โ€” WGS 84 with longitude first, which is the GeoJSON convention and the opposite of EPSG:4326's official axis order. The standard names it explicitly precisely because that ambiguity causes so much damage.

A service supporting other coordinate systems advertises them in crs and accepts ?crs= and ?bbox-crs=.

Flow of a client following rel links from a landing page to paged items.
A client that constructs URLs is coupled to your URL structure.

Code examples

Example 1 โ€” a minimal conformant service

from fastapi import FastAPI, Query, Request, Response
import geopandas as gpd
import json

app = FastAPI()
COLLECTIONS = {"provinces": gpd.read_file("provinces.gpkg")}
CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
MAX_LIMIT = 200


def link(href, rel, type_="application/json", title=None):
    entry = {"href": href, "rel": rel, "type": type_}
    if title:
        entry["title"] = title
    return entry


@app.get("/")
def landing(request: Request):
    base = str(request.base_url).rstrip("/")
    return {"title": "Provinces service",
            "description": "OGC API Features, core + GeoJSON",
            "links": [link(f"{base}/", "self"),
                      link(f"{base}/conformance", "conformance"),
                      link(f"{base}/collections", "data")]}


@app.get("/conformance")
def conformance():
    return {"conformsTo": [
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core",
        "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson",
    ]}


@app.get("/collections")
def collections(request: Request):
    base = str(request.base_url).rstrip("/")
    return {"collections": [describe(name, base) for name in COLLECTIONS],
            "links": [link(f"{base}/collections", "self")]}


def describe(name, base):
    gdf = COLLECTIONS[name]
    minx, miny, maxx, maxy = gdf.total_bounds
    return {
        "id": name,
        "title": name.replace("_", " ").title(),
        "extent": {"spatial": {"bbox": [[minx, miny, maxx, maxy]], "crs": CRS84}},
        "itemType": "feature",
        "crs": [CRS84],
        "links": [link(f"{base}/collections/{name}/items", "items",
                       "application/geo+json")],
    }


@app.get("/collections/{name}/items")
def items(request: Request, name: str, bbox: str | None = None,
          limit: int = Query(50, ge=1, le=MAX_LIMIT), offset: int = 0):
    gdf = COLLECTIONS[name]
    if bbox:
        x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
        gdf = gdf.cx[x0:x1, y0:y1]

    matched = len(gdf)
    page = gdf.iloc[offset:offset + limit]
    body = json.loads(page.to_json())
    base = str(request.base_url).rstrip("/")
    query = f"limit={limit}" + (f"&bbox={bbox}" if bbox else "")

    body["numberMatched"] = matched
    body["numberReturned"] = len(page)
    body["timeStamp"] = __import__("datetime").datetime.utcnow().isoformat() + "Z"
    body["links"] = [link(f"{base}/collections/{name}/items?{query}&offset={offset}",
                          "self", "application/geo+json")]
    if offset + limit < matched:
        body["links"].append(
            link(f"{base}/collections/{name}/items?{query}&offset={offset + limit}",
                 "next", "application/geo+json"))

    return Response(json.dumps(body), media_type="application/geo+json")

Example 2 โ€” a client that works against any conformant service

import httpx


def crawl(base_url, collection=None, bbox=None, max_pages=10):
    """Follow links rather than constructing URLs โ€” that is the point."""
    with httpx.Client(timeout=30) as client:
        landing = client.get(base_url).json()
        data_link = next(l for l in landing["links"] if l["rel"] == "data")

        collections = client.get(data_link["href"]).json()["collections"]
        chosen = next((c for c in collections if c["id"] == collection),
                      collections[0])
        print(f"collection {chosen['id']}: "
              f"extent {chosen['extent']['spatial']['bbox'][0]}")

        items_link = next(l for l in chosen["links"] if l["rel"] == "items")
        url = items_link["href"] + (f"?bbox={bbox}" if bbox else "")

        total = 0
        for page in range(max_pages):
            body = client.get(url).json()
            total += body.get("numberReturned", len(body["features"]))
            print(f"  page {page + 1}: {body.get('numberReturned')} of "
                  f"{body.get('numberMatched')}")
            next_link = next((l for l in body.get("links", []) if l["rel"] == "next"),
                             None)
            if not next_link:
                break
            url = next_link["href"]
        return total

Example 3 โ€” testing conformance

REQUIRED_ENDPOINTS = ["/", "/conformance", "/collections"]
CORE_CLASS = "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"


def check_conformance(base_url):
    import httpx

    problems = []
    with httpx.Client(base_url=base_url, timeout=30) as client:
        for path in REQUIRED_ENDPOINTS:
            response = client.get(path)
            if response.status_code != 200:
                problems.append(f"{path} returned {response.status_code}")

        conformance = client.get("/conformance").json().get("conformsTo", [])
        if CORE_CLASS not in conformance:
            problems.append("does not declare the core conformance class")

        collections = client.get("/collections").json()["collections"]
        for collection in collections:
            for field in ("id", "links", "extent"):
                if field not in collection:
                    problems.append(f"collection {collection.get('id')} lacks {field}")

            items = client.get(f"/collections/{collection['id']}/items?limit=1")
            body = items.json()
            if body.get("type") != "FeatureCollection":
                problems.append(f"{collection['id']}/items is not a FeatureCollection")
            for field in ("numberMatched", "numberReturned", "links"):
                if field not in body:
                    problems.append(f"{collection['id']}/items lacks {field}")

    print(f"{len(problems)} conformance problem(s)")
    for problem in problems:
        print("  !", problem)
    return not problems

Explanation

Why a standard is worth adopting for an internal service

The usual objection is that nobody outside will consume it, so the standard is overhead. The overhead is small โ€” the parameter names and the links array โ€” and the benefit arrives immediately in the form of tools that already speak it: QGIS, GDAL's OAPIF driver, several JavaScript clients and every future service you build.

It also removes a category of design meetings. Nobody has to decide what to call the bounding-box parameter, how paging works or where the metadata goes.

A client that constructs URLs is coupled to the service's URL structure. A client that follows rel="next" is coupled only to the standard.

That means a service can move from offset paging to cursor paging, change its base path, or put pages behind a CDN with different URLs, and conformant clients keep working. It is the same idea as a hyperlink, applied to an API.

Why CRS84 is spelled out so carefully

GeoJSON coordinates are longitude-first. EPSG:4326 is officially latitude-first. The two conventions describe the same datum and produce coordinates that are silently transposed relative to one another.

The standard therefore names its default CRS as CRS84 โ€” explicitly longitude-first โ€” rather than saying "WGS 84" and leaving it to be inferred. Services supporting other systems advertise them and accept ?crs= and ?bbox-crs=, so the client never has to guess.

Why numberMatched is the most useful field in the response

It tells a client how much data its query selected before it has fetched any of it. That single number lets a client decide to narrow the filter rather than paging through four hundred pages, show a meaningful progress indicator, or refuse a query that would be unreasonable.

It is also the field most often omitted from home-made APIs, because the implementer already knows how big the dataset is.

Two panels contrasting CRS84 longitude-first order with EPSG:4326 authority order.
Both are correct in their own frame, which is exactly why the standard is explicit.

Edge cases or notes

  • bbox is minx,miny,maxx,maxy in the collection's CRS, defaulting to CRS84 โ€” longitude first.
  • A six-value bbox includes elevation; most services can reject it.
  • datetime accepts an instant, an interval, or an open-ended interval with ...
  • limit needs a documented maximum, and the service should clamp rather than error.
  • /items/{featureId} returns a bare Feature, not a FeatureCollection.
  • Content negotiation via Accept and an f=json parameter are both common.
  • pygeoapi implements the standard and is often faster to deploy than writing your own.
  • Conformance classes are a menu. Declaring only core and GeoJSON is respectable.

FAQ

What is OGC API Features?

A REST specification for serving vector features: a landing page, a conformance declaration, collections, items and individual features, with standardised bbox, datetime and limit parameters and GeoJSON responses.

How is it different from WFS?

WFS is XML and SOAP-flavoured; OGC API Features is JSON, REST and link-driven. They serve the same purpose and the newer one is far easier to implement and consume.

Do I have to implement all of it?

No. Conformance classes are a menu, and declaring core plus GeoJSON is a legitimate, useful service. The /conformance endpoint is where you say what you did.

What CRS does it use?

CRS84 by default โ€” WGS 84 with longitude first, matching GeoJSON. That is the opposite of EPSG:4326's official axis order, which is exactly why the standard names it explicitly.

Because then the service can change its URL structure, its paging strategy or its hosting without breaking clients. rel="next" is the contract, not the query string.

Is there an implementation I can use?

pygeoapi implements the standard over several backends and is usually quicker to deploy than writing one, though a minimal conformant service is about a hundred lines.