Reference
Every route, from the catalog.
This page is rendered from /openapi.json, which the Worker generates on every request from the one price table it charges from. The example on each route is a request the endpoint really answers, and the response shown is what it really said. Base URL: https://api.geoprimitives.dev.
Mainnet. Routes settle on Base in USDC. A free-to-break twin runs on Base Sepolia at sandbox.geoprimitives.dev.
Paying
Any priced route answers an unpaid request with 402, an empty body and a PAYMENT-REQUIRED header holding base64 JSON: accepts[] carries the scheme (exact), the network (eip155:8453, Base), the amount in atomic USDC (six decimals), the asset, and payTo (0x76A51b5Dd3729950B3b60c17f19252d221968FCe). Sign an EIP-3009 transferWithAuthorization for that amount and retry the same request with a PAYMENT-SIGNATURE header. The response carries PAYMENT-RESPONSE; check its success field rather than its presence, because a failed settlement sends the header too. The facilitator (https://api.cdp.coinbase.com/platform/v2/x402) submits the transaction, so a caller needs USDC and no gas. Authorization nonces are single-use at the token contract, so a captured signature is rejected on replay.
Refusals
Requests are checked before they are priced. A body over its cap, one that is not JSON, a Content-Type that cannot carry JSON, or a missing required top-level field is answered 400 with a machine code and no 402 at all, so there is nothing to sign. Anything that requires looking at a coordinate is checked after payment, and settlement happens only on a 2xx. Every 400 has the same shape: { error, code, ... }.
Service catalog: every route, its price, and what it does.
The catalog. Lists the settling network, the address payments go to, the facilitator, the free routes, and every paid route with its price, one-line summary and full description. Free, unauthenticated, and safe to poll: start here, then read GET /openapi.json for the request and response schemas.
Price: free. This route is never gated: an agent has to be able to read the catalog and the schemas before it can decide to pay for anything.
Response
| Field | Type | Meaning |
namerequired | string | |
descriptionrequired | string | |
networkrequired | string | CAIP-2 chain id payments settle on, e.g. eip155:84532 for Base Sepolia. |
payTorequired | string | Address that receives USDC. |
facilitatorrequired | string | Facilitator URL that verifies and settles. |
freerequired | array | Routes that are never gated, as "<METHOD> <path>". |
paidrequired | array | Every priced route. |
This OpenAPI 3.1 document. Free, and generated from the price table.
The OpenAPI 3.1 description of every route, generated at request time from the same price table the payment middleware charges from, so a price or a cap in this document cannot disagree with what is enforced. Each paid operation carries its request schema, response schema, a working request example, the real response to that example, its price in dollars and in atomic USDC, and every cap the handler enforces under x-limits. Free and unauthenticated.
Price: free. This route is never gated: an agent has to be able to read the catalog and the schemas before it can decide to pay for anything.
Response
| Field | Type | Meaning |
openapirequired | string | |
inforequired | object | |
servers | array | |
pathsrequired | object | |
components | object | |
Watch a real paid call happen, without a wallet. GeoPrimitives pays for this one.
A worked example of the whole exchange, run for you. It relays a real request to a real priced route, pays the 402 with a wallet this service funds, and answers with the terms it was quoted, the transaction hash the settlement produced, and the answer that payment bought. Open the hash on a block explorer; the money moved. WHO PAYS: we do, and the response says so under disclosure. The buying wallet and the receiving wallet both belong to this service, so the USDC makes a round trip and the real cost is the settlement fee.
Nothing here is charged to you and no wallet of yours is involved, which is the entire point of the route. WHEN IT DOES NOT BUY: the paid leg is capped per caller, capped overall, and stops when the demo wallet runs low, so a busy moment answers with live: false. That answer still carries terms read live from the route a moment ago and a settlement that really happened, and it still returns 200, because a reader who wanted to understand the exchange learns as much from it. reason says which cap applied.
PROVING IT WITHOUT US: doItYourself carries the same request as a curl you can run, which gets you the identical 402 that this route was quoted. That is the check worth making, because it needs nothing from us but the URL.
Price: free. This route is never gated: an agent has to be able to read the catalog and the schemas before it can decide to pay for anything.
Response
| Field | Type | Meaning |
demorequired | boolean | Constant true. Marks this as the worked example rather than a priced answer. |
liverequired | boolean | Whether the payment below was made for this request, or is a recorded earlier one. |
reason | string | Why the paid leg did not run, present only when live is false. One of rate_limited, budget_low, unavailable, disabled. |
routerequired | string | The priced route that was relayed. |
request | object | The parameters sent to it, taken from that route's own documented example. |
quoterequired | object | The terms read from the 402, live on every response including a degraded one. |
paymentrequired | object | The settlement. Fresh when live is true, a real earlier one when it is false. |
answer | object | What the payment bought, exactly as the priced route returned it. Null when the paid leg did not run. |
disclosurerequired | string | States that this service paid for the call itself. Present on every response. |
doItYourselfrequired | object | The same request as commands you can run. |
note | string | Present when live is false, saying which half is live and which is recorded. |
POST /v1/hello
$0.001 1000 atomic USDC
Echo a string back. The cheapest way to test your payment client.
Echo endpoint. Returns the name you send, the network the payment settled on, and the price you paid. Use it to prove your x402 client works end to end – discover, read the 402, sign, retry, get a body – before you spend anything on a real endpoint. It is the cheapest route in the catalog and it computes nothing, so a failure here is a payment problem and never a geospatial one. Do not use it for anything else: it has no geospatial behaviour at all. It is priced at exactly what it costs us to settle one onchain transaction, so the whole of what you pay here goes to the facilitator and none of it to us.
That is deliberate: this route is worth having only because it is genuinely paid, since a free echo would prove nothing about whether your client can pay.
Price: $0.001 per call (0.001 USD, 1000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
namerequired | string | Any string. Echoed back verbatim in the hello field. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/hello \
-H 'content-type: application/json' \
-d '{"name":"world"}'
Response
| Field | Type | Meaning |
hellorequired | string | The name you sent. |
networkrequired | string | CAIP-2 id of the chain the payment settled on. |
pricerequired | string | What this call cost, from the price table. |
The real answer to that request
Generated by running the example above.
Response body
{
"hello": "world",
"network": "eip155:84532",
"price": "$0.001"
}
Settles as exact on eip155:8453, 1000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geojson/validate
$0.005 5000 atomic USDC
Check GeoJSON against RFC 7946 and say exactly what is wrong and where.
Validate GeoJSON against RFC 7946 and get back a structured list of problems, not a bare boolean. Checks structure, coordinate range, ring closure, ring winding order, consecutive duplicate vertices, self-intersections, and hole nesting. Every issue carries the feature, geometry, ring and position index it sits on, so a 500-feature collection is actionable rather than merely condemned. Reach for it before paying for an operation on data you did not author, and to find out why /v1/geometry, /v1/h3/polyfill or /v1/pip refused a document: those endpoints reject unclosed rings and out-of-range coordinates rather than guessing.
Errors make a document invalid; warnings – duplicate vertices, the crs member RFC 7946 removed – do not, and are reported separately. One price per call whatever the document: a single Point costs exactly what a 1 MiB FeatureCollection costs, so send the whole collection rather than a document at a time. That is measured rather than assumed – a Point is about 0.0015 ms of compute on this runtime and a 1 MiB collection about 10 ms, a spread far below what it costs to settle one payment – which is why there is no size tier here to choose between and no bulk sibling to look for.
Nothing is modified here: POST /v1/geojson/repair is the endpoint that rewrites. Limits: 1 MiB request body, and a self-intersection budget of 2000000 segment-pair comparisons per request, enough for any ring under about 1400 vertices. Rings past the budget are listed in checksSkipped with the reason, never silently passed as clean.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- bodyBytes: at most 1048576 bytes. 1 MiB request body, checked against Content-Length and again against the body itself. Roughly 20k-40k positions of real GeoJSON. Split the collection and post it in parts.
- selfIntersectionBudget: at most 2000000 segment-pair comparisons. The self-intersection pass is quadratic in a ring's segments, so it runs cheapest ring first until this budget is spent.
Anything left over is reported in checksSkipped with its path. Covers any ring under about 1400 vertices outright.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
typerequired | string | GeoJSON type. Post the document itself: a Feature, a FeatureCollection, or a bare Geometry. |
features | array | Present when type is FeatureCollection. |
geometry | object | Present when type is Feature. May be null. |
coordinates | array | Present on a bare Geometry other than GeometryCollection. |
geometries | array | Present when type is GeometryCollection. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
bodyBytes | 1,048,576 bytes | 1 MiB request body, checked against Content-Length and again against the body itself. Roughly 20k-40k positions of real GeoJSON. Split the collection and post it in parts. |
selfIntersectionBudget | 2,000,000 segment-pair comparisons | The self-intersection pass is quadratic in a ring's segments, so it runs cheapest ring first until this budget is spent. Anything left over is reported in checksSkipped with its path. Covers any ring under about 1400 vertices outright. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geojson/validate \
-H 'content-type: application/json' \
-d '{"type":"Polygon","coordinates":[[[0,0],[0,1],[0,1],[1,1],[1,0],[0,0]]]}'
Response
| Field | Type | Meaning |
validrequired | boolean | False when at least one error was found. Warnings alone leave this true. |
errorCountrequired | integer | Issues of severity error. |
warningCountrequired | integer | Issues of severity warning. These do not make the document invalid. |
errorsrequired | array | Every issue found, errors and warnings alike, in document order. |
checksSkippedrequired | array | Checks that were not run, and why. A ring past the self-intersection budget lands here rather than being reported clean. |
The real answer to that request
Generated by running the example above.
Response body
{
"valid": false,
"errorCount": 1,
"warningCount": 1,
"errors": [
{
"code": "ring_winding",
"severity": "error",
"message": "Exterior ring is clockwise. RFC 7946 requires exterior rings to be counterclockwise.",
"path": "$.coordinates[0]",
"ringIndex": 0
},
{
"code": "duplicate_vertex",
"severity": "warning",
"message": "Position 2 repeats position 1.",
"path": "$.coordinates[0][2]",
"ringIndex": 0,
"positionIndex": 2
}
],
"checksSkipped": []
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geojson/repair
$0.01 10000 atomic USDC
Rewrite broken GeoJSON into RFC 7946 form and log every change.
Repair GeoJSON and get the fixed document back with a log of every alteration: rings rewound to RFC 7946 winding order, consecutive duplicate vertices dropped, unclosed rings closed, and polygon nesting fixed (mis-ordered exterior rings, holes that fall outside their exterior, MultiPolygon members that are really holes). Reach for it when POST /v1/geojson/validate reported errors, or when /v1/geometry, /v1/h3/polyfill or /v1/pip refused your document – unclosed rings are the usual reason, and this is the cheapest way to clear them.
Conservative by design: it only rewrites the classes above, and anything else it found is returned in unrepaired rather than guessed at, so it will not invent geometry or move a coordinate. Idempotent – a conformant document comes back untouched with changed:false, so a second call costs money and changes nothing. Your input object is never mutated. Coordinate values are preserved exactly; nothing is rounded or reprojected. Limit: 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- bodyBytes: at most 1048576 bytes. 1 MiB request body, checked against Content-Length and again against the body itself. Split the collection and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
typerequired | string | GeoJSON type. Post the document itself: a Feature, a FeatureCollection, or a bare Geometry. |
features | array | Present when type is FeatureCollection. |
geometry | object | Present when type is Feature. May be null. |
coordinates | array | Present on a bare Geometry other than GeometryCollection. |
geometries | array | Present when type is GeometryCollection. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
bodyBytes | 1,048,576 bytes | 1 MiB request body, checked against Content-Length and again against the body itself. Split the collection and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geojson/repair \
-H 'content-type: application/json' \
-d '{"type":"Polygon","coordinates":[[[0,0],[0,1],[0,1],[1,1],[1,0],[0,0]]]}'
Response
| Field | Type | Meaning |
geojsonrequired | object | The repaired document, in the same shape as the one you posted: Feature for Feature, FeatureCollection for FeatureCollection, bare Geometry for bare Geometry. Properties, id and foreign members are preserved. |
changedrequired | boolean | False when the input was already conformant and nothing was rewritten. |
changeCountrequired | integer | Number of entries in changes. |
changesrequired | array | Every alteration, in document order. |
unrepairedrequired | array | Problems found that this endpoint deliberately does not rewrite, in the same shape /v1/geojson/validate reports. Self-intersections are the usual member: fixing one means choosing a geometry, and that is not a choice to make on a caller's behalf. |
The real answer to that request
Generated by running the example above.
Response body
{
"geojson": {
"type": "Polygon",
"coordinates": [
[
[
0,
0
],
[
1,
0
],
[
1,
1
],
[
0,
1
],
[
0,
0
]
]
]
},
"changed": true,
"changeCount": 2,
"changes": [
{
"code": "deduped_vertices",
"message": "Removed 1 consecutive duplicate position.",
"path": "$.coordinates[0]",
"ringIndex": 0
},
{
"code": "rewound_ring",
"message": "Reversed exterior ring to counterclockwise.",
"path": "$.coordinates[0]",
"ringIndex": 0
}
],
"unrepaired": []
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/transform
$0.005 5000 atomic USDC
Reproject coordinates or GeoJSON between 1380 EPSG codes, datum shifts included.
Reproject coordinates between EPSG coordinate reference systems, with the datum shift applied. Accepts a bare position, an array of positions at any nesting depth, or a GeoJSON Feature, FeatureCollection or Geometry, and answers in whichever shape it was given, preserving properties, ids and foreign members. Covers 1380 codes: WGS 84, ITRF2020, NAD27 and every NAD83 realization as geographic, Web Mercator, all 120 WGS 84 UTM zones north and south, the NAD27 and NAD83 UTM zones, and the NAD27 and NAD83 State Plane and low-distortion zones, Oregon and Alaska included.
Reach for it before calling /v1/geometry, /v1/h3/index, /v1/h3/polyfill or /v1/pip, which take EPSG:4326 only and refuse anything outside -180..180 and -90..90. One price per call whatever the payload: 10000 positions cost exactly what one position costs, so batch rather than sending a position at a time. Horizontal only: elevations pass through untouched. Datum shifts, and read this before buying: two mechanisms run between the two sides and the response names every step. Between NAD27 and the NAD83 realizations (NAD83, HARN, NSRS2007, 2011, PA11) the NOAA NGS NADCON 5.0 grids are applied, 2016-09-01 release, public domain, biquadratic interpolation as NGS specifies, covering the conterminous United States, Alaska, Hawaii and Puerto Rico with the US Virgin Islands; a position outside that coverage is refused with outside_datum_grid rather than answered without the shift.
Between WGS 84 and NAD83(2011) or NAD83(PA11) the EPSG time-dependent Helmert transformations 10334 and 10336 are applied with WGS 84 read as ITRF2020, which the current WGS 84 (G2296) realizes to a few centimetres. That transformation depends on when the coordinates were observed, because the North American plate moves about 2 cm a year against ITRF2020: send "epoch" as a decimal year (2024.5) to say when, or it is evaluated at 2010.0, the NAD83(2011) reference epoch, and the response says which.
Every response carries datumShiftApplied and a datumShift object listing the grids or Helmert that ran, the region, the epoch and the data provenance; when both sides share a datum it says so and applies nothing. Accuracy was measured against PROJ 9.5.1 running the same grids and the same EPSG operations: the projection arithmetic agrees to under 1e-8 m and the shifted result to under 1e-6 m, so the 1.29 m gap this route carried before the grids is closed to the limit of the source data, which NGS states as a few centimetres for the realization grids and decimetres for NAD27.
If a grid cannot be read the request is answered 503 and not charged; it is never answered without the shift. A position that lands outside the target projection's valid domain is rejected with its index, never returned as a null or infinite coordinate. Limits: 10000 positions per call across the whole request, 512 datum-grid tiles per call (about one degree square per grid step; a state-sized batch is well under), and a 1 MiB request body.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 10000 positions. Counted across the whole request, coordinates and geojson alike, at every nesting depth. Split the batch and post it in parts.
- gridTiles: at most 512 tiles. Distinct NADCON5 grid tiles a request may touch, summed over every grid step. A tile is 64 by 64 grid cells, about one degree square on the finest grids; a batch of any size within one state stays well under.
Group positions by area and post them in parts.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, checked against Content-Length and again against the body itself.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
fromrequired | string | Source CRS as an EPSG reference, e.g. "EPSG:4326". A bare code such as "4326" is also accepted. |
torequired | string | Target CRS as an EPSG reference, e.g. "EPSG:3857". A bare code such as "3857" is also accepted. |
epoch | number | Decimal year the coordinates were observed, e.g. 2024.5. Used by the time-dependent Helmert between WGS 84 (read as ITRF2020) and NAD83(2011) or NAD83(PA11); about 2 cm a year of difference. Defaults to 2010.0, the NAD83(2011) reference epoch, and the response states the epoch used either way. Ignored, and said to be, when no Helmert runs. |
coordinates | array | A single position [x, y] or [x, y, z], or an array of positions at any nesting depth. The response mirrors the nesting exactly. Send this or geojson, never both. Across the whole request at most 10000 positions; a larger batch is rejected. |
geojson | object | A GeoJSON Feature, FeatureCollection or Geometry. Returned as GeoJSON with the same structure, properties and foreign members, with coordinates reprojected. bbox members are dropped because they describe the source CRS. Send this or coordinates, never both. At most 10000 positions across the whole document. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 10,000 positions | Counted across the whole request, coordinates and geojson alike, at every nesting depth. Split the batch and post it in parts. |
gridTiles | 512 tiles | Distinct NADCON5 grid tiles a request may touch, summed over every grid step. A tile is 64 by 64 grid cells, about one degree square on the finest grids; a batch of any size within one state stays well under. Group positions by area and post them in parts. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, checked against Content-Length and again against the body itself. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/transform \
-H 'content-type: application/json' \
-d '{"from":"EPSG:4326","to":"EPSG:3645","coordinates":[[-122.6765,45.5231],[-123.0351,44.9429]]}'
Response
| Field | Type | Meaning |
fromrequired | object | The source CRS as resolved, so you can check it was read the way you meant. |
torequired | object | The target CRS as resolved. |
countrequired | integer | Positions actually transformed. |
datumShiftAppliedrequired | boolean | True when a datum shift was applied between the two sides, false when they share a datum and there was nothing to apply. Never false because a shift was skipped: a request whose shift cannot be applied is refused, not approximated. |
datumShiftrequired | object | What ran between the two datums, step by step, and where the data came from. |
notesrequired | array | Anything worth knowing about this particular answer: the output unit, a dropped bbox, elevations passed through untouched, how WGS 84 and the epoch were read. |
coordinates | array | Present when you sent coordinates. Mirrors the nesting depth of the input exactly. |
geojson | object | Present when you sent geojson. Same structure, properties and foreign members, coordinates reprojected, bbox dropped because it described the source CRS. |
The real answer to that request
Generated by running the example above.
Response body
{
"from": {
"id": "EPSG:4326",
"code": 4326,
"name": "WGS 84",
"unit": "degree",
"datum": "WGS 84",
"ellipsoid": "WGS84"
},
"to": {
"id": "EPSG:3645",
"code": 3645,
"name": "NAD83(NSRS2007) / Oregon North",
"unit": "m",
"datum": "NAD83(NSRS2007)",
"ellipsoid": "GRS80"
},
"count": 2,
"datumShiftApplied": true,
"datumShift": {
"applied": true,
"from": "WGS 84",
"to": "NAD83(NSRS2007)",
"paths": [
{
"region": "conus",
"positions": 2,
"steps": [
{
"method": "helmert",
"from": "WGS 84",
"to": "NAD83(2011)",
"direction": "forward",
"operation": "EPSG:10334",
"name": "ITRF2020 to NAD83(2011) (1)",
"epoch": 2010
},
{
"method": "nadcon5",
"from": "NAD83(2011)",
"to": "NAD83(NSRS2007)",
"direction": "inverse",
"grid": "nad83_2007_nad83_2011_conus"
}
]
}
],
"epoch": 2010,
"epochSource": "default",
"grids": {
"source": "NOAA National Geodetic Survey, NADCON 5.0",
"release": "20160901",
"url": "https://geodesy.noaa.gov/pub/nadcon5/20160901release/",
"licence": "Public domain (US government work, 17 U.S.C. 105)",
"interpolation": "biquadratic"
}
},
"notes": [
"WGS 84 was treated as ITRF2020, which the current realization WGS 84 (G2296) agrees with to a few centimetres. EPSG:4326 is an ensemble whose older realizations differ by up to about a metre; send EPSG:9990 to say ITRF2020 explicitly.",
"The time-dependent Helmert was evaluated at epoch 2010, the NAD83(2011) reference epoch, because no \"epoch\" was sent. The North American plate moves about 2 cm a year relative to ITRF2020, so coordinates observed at another epoch want that epoch here.",
"Output coordinates are in m."
],
"coordinates": [
[
2329995.078037299,
208581.08988551795
],
[
2299970.59809049,
144954.7501244871
]
]
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/buffer
$0.01 10000 atomic USDC
Offset a geometry by a distance in metres, corrected for the ellipsoid.
Buffer a geometry by a distance in metres and get the offset polygon back. Use it for service areas, setbacks, and proximity envelopes you will later test with POST /v1/pip. Accepts any geometry – point, line, polygon, or a collection of them, each buffered about its own centre latitude – and answers with a Feature, or a FeatureCollection when you sent several. A buffer that collapses (a negative distance larger than the shape, or any negative distance on a point or a line) answers geojson:null with empty:true, which is an answer and not an error.
Geodesic-aware: turf buffers in an azimuthal equidistant plane scaled by a sphere, which is a systematic -0.56% error at the equator running to +0.44% at 80 deg latitude, and this endpoint cancels that by scaling the distance to the Gaussian radius of curvature at the feature's own centre latitude. Measured against GeographicLib the result is then within +/-0.34% of the requested distance at the equator, +/-0.08% at 60 deg, +/-0.04% at 70 deg and +/-0.01% at 80 deg; the residual is the ellipsoid's north-south versus east-west curvature, which no single sphere can represent, and it shrinks towards the poles.
Between vertices the polygon edge cuts a further 1 - cos(pi/(4*steps)) inside the true offset curve, 0.054% at the default steps=24. Input is EPSG:4326 only. Limits: 2000 positions across the whole document, 1 MiB request body, distance within +/-1000000 m, steps 1 to 128.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 2000 positions. Counted across the whole document. This cap is CPU, not bandwidth: jsts BufferOp is quadratic in practice, and 2000 positions of pathological input is already about 250 ms.
- distanceMeters: at most 1000000 metres. 1000 km either way. Past that the azimuthal equidistant plane distorts by more than every other error here combined: 0.004% at 100 km, 0.41% at 1000 km, 1.7% at 2000 km.
- steps: at most 128 segments per quadrant.
Minimum 1, default 24. Higher costs almost nothing; the cost is in the noding pass.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON Feature, FeatureCollection or Geometry in EPSG:4326, longitude then latitude in degrees. At most 2000 positions across the whole document. Reproject with POST /v1/transform first if your data is in anything else. |
distancerequired | number | Buffer distance in metres. Negative erodes a polygon inwards, and collapses a point or a line to nothing. At most 1000000 m either way, because past about 1000 km the azimuthal equidistant plane distorts more than every other error here combined. |
steps | integer | Segments per quadrant, so four times this many vertices per full circle. Default 24, maximum 128. The polygon edge cuts 1 - cos(pi/(4*steps)) inside the true offset curve: 0.48% at turf's own default of 8, 0.054% at 24, 0.008% at 64. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 2,000 positions | Counted across the whole document. This cap is CPU, not bandwidth: jsts BufferOp is quadratic in practice, and 2000 positions of pathological input is already about 250 ms. |
distanceMeters | 1,000,000 metres | 1000 km either way. Past that the azimuthal equidistant plane distorts by more than every other error here combined: 0.004% at 100 km, 0.41% at 1000 km, 1.7% at 2000 km. |
steps | 128 segments per quadrant | Minimum 1, default 24. Higher costs almost nothing; the cost is in the noding pass. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/buffer \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"Point","coordinates":[-149.9003,61.2181]},"distance":500,"steps":2}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
geojsonrequired | object,null | The offset shape: a Feature when you sent one geometry, a FeatureCollection when you sent several, null when the buffer collapsed to nothing. |
emptyrequired | boolean | True when the buffer collapsed and geojson is null. |
distanceMetersrequired | number | The distance you asked for. |
stepsrequired | integer | Segments per quadrant actually used, 24 unless you set it. |
radiusScalerequired | number,null | The factor the requested distance was multiplied by to cancel turf's sphere, near 1. Null when you sent several geometries, because each was buffered about its own centre latitude and there is no single factor to report. |
accuracyrequired | string | The measured error characteristics of this answer, in one sentence. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "buffer",
"inputVertices": 1,
"notes": [],
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-149.8909880026692,
61.218099680669816
],
[
-149.89371476021455,
61.2212701612782
],
[
-149.9003,
61.2225835109001
],
[
-149.90688523978542,
61.2212701612782
],
[
-149.90961199733076,
61.218099680669816
],
[
-149.90688391332412,
61.214929519391646
],
[
-149.9003,
61.213616489099905
],
[
-149.89371608667585,
61.214929519391646
],
[
-149.8909880026692,
61.218099680669816
]
]
]
}
},
"empty": false,
"distanceMeters": 500,
"steps": 2,
"radiusScale": 0.9970887085271185,
"accuracy": "Buffer distance is accurate to +/-0.34% at the equator falling to +/-0.01% at 80 deg latitude, plus 1 - cos(pi/(4*steps)) of inward chord error between vertices."
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/simplify
$0.01 10000 atomic USDC
Thin out vertices with Douglas-Peucker, in metres or in degrees.
Drop vertices from a geometry while keeping its shape. Use it to cut a boundary down before a call whose cost is vertex-driven – POST /v1/pip caps on points times polygon vertices, and POST /v1/geometry/buffer and the overlay ops cap at 2000 positions – and to shrink what you store or draw. The response reports outputVertices next to inputVertices, so the saving is measurable before you spend again. The algorithm is Douglas-Peucker. Send toleranceMeters for a ground tolerance applied isotropically: both axes are scaled to metres at the input's centre latitude first, so at 70 deg N the simplification is not three times as aggressive east-west as north-south, which a raw degree tolerance makes it.
Send tolerance instead for the raw degree tolerance if you want turf's behaviour unchanged. Retained vertices come back exactly as sent rather than round-tripped through the scaling. Not topology-preserving: adjacent polygons simplified separately can gap or overlap along a shared edge. Input is EPSG:4326 only. Limits: 100000 positions across the whole document, 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 100000 positions. Counted across the whole document. Douglas-Peucker is linear, so in any realistic request the 1 MiB body cap binds first: this is the backstop against a body of repeated [0,0].
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON Feature, FeatureCollection or Geometry in EPSG:4326, longitude then latitude in degrees. At most 100000 positions across the whole document. |
toleranceMeters | number | Ground tolerance in metres. A vertex is dropped when removing it moves the line by less than this. Send this or tolerance, never both. |
tolerance | number | Raw Douglas-Peucker tolerance in degrees, applied to longitude and latitude alike and therefore anisotropic away from the equator. Send this or toleranceMeters, never both. |
highQuality | boolean | Skip the radial-distance pre-pass. Slower, and keeps more of the original shape. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 100,000 positions | Counted across the whole document. Douglas-Peucker is linear, so in any realistic request the 1 MiB body cap binds first: this is the backstop against a body of repeated [0,0]. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/simplify \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"LineString","coordinates":[[-150,61],[-149.98,61.001],[-149.96,61.0005],[-149.94,61.0015],[-149.9,61.002]]},"toleranceMeters":100}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
geojsonrequired | object | The simplified geometry: a Feature when you sent one, a FeatureCollection when you sent several. Retained vertices are byte-for-byte the ones you sent, not round-tripped through the metre scaling. |
outputVerticesrequired | integer | Positions left after simplification. Compare with inputVertices. |
toleranceMeters | number | Present only when you sent toleranceMeters. The ground tolerance used. |
toleranceDegreesrequired | one of | The tolerance Douglas-Peucker actually ran with. An object with longitude and latitude members when you sent toleranceMeters (longitude is null at the poles, where a degree of longitude is zero metres); the bare number you sent when you sent tolerance. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "simplify",
"inputVertices": 5,
"notes": [
"Tolerance was applied isotropically on the ground by scaling both axes to metres at latitude 61.0010, so 100 m means the same east-west as north-south.",
"Simplification is per-geometry Douglas-Peucker. It does not preserve topology: adjacent polygons simplified separately can gap or overlap along a shared edge."
],
"toleranceMeters": 100,
"toleranceDegrees": {
"longitude": 0.0018482314669043616,
"latitude": 0.00089743142433687
},
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "LineString",
"coordinates": [
[
-150,
61
],
[
-149.9,
61.002
]
]
}
},
"outputVertices": 2
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/union
$0.01 10000 atomic USDC
Merge two or more polygons into one shape.
Dissolve overlapping or adjacent polygons into a single shape: merging parcels, coverage areas or service zones into one boundary before measuring or testing it. Send a FeatureCollection of Polygons or MultiPolygons; the answer is a single Polygon or MultiPolygon Feature, or null with empty:true when nothing is left. Clipping is planar in longitude/latitude, the way every polygon-clipping library does it: edges are straight in degrees rather than geodesics, and the two differ on edges spanning more than about a degree.
The 2000-position cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms. Input is EPSG:4326 only. Limits: 2000 positions across all members, 1 MiB request body, and at least two polygonal geometries or the call is refused.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 2000 positions. Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself.
Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON FeatureCollection or GeometryCollection holding two or more Polygons or MultiPolygons in EPSG:4326. At most 2000 positions across all members. Anything non-polygonal is rejected. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 2,000 positions | Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/union \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"FeatureCollection","features":[{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.1],[-150,61.1],[-150,61]]]}},{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-149.95,61.05],[-149.85,61.05],[-149.85,61.15],[-149.95,61.15],[-149.95,61.05]]]}}]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
inputGeometriesrequired | integer | How many polygonal geometries the request held. Always two or more. |
geojsonrequired | object,null | The merged shape as a single Feature -- Polygon when the members touch, MultiPolygon when they do not -- or null when nothing survived. |
emptyrequired | boolean | True when nothing survived the overlay and geojson is null. |
outputVerticesrequired | integer | Positions in the result. Zero when empty. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "union",
"inputVertices": 10,
"notes": [
"Vertices are treated as planar in longitude/latitude, which is what every polygon-clipping library does. Edges are straight in degrees, not geodesics; on edges spanning more than a degree or so the two differ."
],
"inputGeometries": 2,
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-150,
61
],
[
-149.9,
61
],
[
-149.9,
61.05
],
[
-149.85,
61.05
],
[
-149.85,
61.15
],
[
-149.95,
61.15
],
[
-149.95,
61.1
],
[
-150,
61.1
],
[
-150,
61
]
]
]
}
},
"empty": false,
"outputVertices": 9
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/intersect
$0.01 10000 atomic USDC
Return the area two or more polygons have in common.
Clip polygons to their common area: how much of a parcel falls inside a flood zone, where two service areas overlap, what a boundary and a study area share. Pair it with POST /v1/geometry/area to turn the overlap into square metres. Disjoint input answers geojson:null with empty:true, which is a real answer and not an error, so check empty rather than treating null as a failure. Send a FeatureCollection of Polygons or MultiPolygons; the answer is the common area as a single Feature. Clipping is planar in longitude/latitude, so edges are straight in degrees rather than geodesics.
The 2000-position cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input. Input is EPSG:4326 only. Limits: 2000 positions across all members, 1 MiB request body, and at least two polygonal geometries or the call is refused.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 2000 positions. Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself.
Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON FeatureCollection or GeometryCollection holding two or more Polygons or MultiPolygons in EPSG:4326. At most 2000 positions across all members. Anything non-polygonal is rejected. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 2,000 positions | Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/intersect \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"FeatureCollection","features":[{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.1],[-150,61.1],[-150,61]]]}},{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-149.95,61.05],[-149.85,61.05],[-149.85,61.15],[-149.95,61.15],[-149.95,61.05]]]}}]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
inputGeometriesrequired | integer | How many polygonal geometries the request held. Always two or more. |
geojsonrequired | object,null | The common area as a single Feature, or null when the members are disjoint. Check empty rather than reading null as a failure. |
emptyrequired | boolean | True when nothing survived the overlay and geojson is null. |
outputVerticesrequired | integer | Positions in the result. Zero when empty. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "intersect",
"inputVertices": 10,
"notes": [
"Vertices are treated as planar in longitude/latitude, which is what every polygon-clipping library does. Edges are straight in degrees, not geodesics; on edges spanning more than a degree or so the two differ."
],
"inputGeometries": 2,
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-149.95,
61.05
],
[
-149.9,
61.05
],
[
-149.9,
61.1
],
[
-149.95,
61.1
],
[
-149.95,
61.05
]
]
]
}
},
"empty": false,
"outputVertices": 5
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/difference
$0.01 10000 atomic USDC
Subtract later polygons from the first one. Order matters.
Cut holes out of a polygon: the buildable part of a parcel once setbacks are removed, the land outside a protected area, the part of a route not already covered. Order is significant, so this is the one overlay op where swapping the operands gives a different answer. Subtracting everything answers geojson:null with empty:true, a real answer rather than an error. Send a FeatureCollection of Polygons or MultiPolygons; the first member in document order has every later member subtracted from it. The answer is a single Feature, or null with empty:true when nothing survives.
Clipping is planar in longitude/latitude, so edges are straight in degrees rather than geodesics. The 2000-position cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input. Input is EPSG:4326 only. Limits: 2000 positions across all members, 1 MiB request body, and at least two polygonal geometries or the call is refused.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 2000 positions. Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself.
Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON FeatureCollection or GeometryCollection holding two or more Polygons or MultiPolygons in EPSG:4326. The first is the minuend and every later one is subtracted from it. At most 2000 positions across all members. Anything non-polygonal is rejected. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 2,000 positions | Counted across every member of the collection. The cap is CPU rather than bandwidth: the sweep-line overlay is quadratic on self-intersecting input, where 2000 positions is already about 350 ms and 10000 is 16 seconds. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/difference \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"FeatureCollection","features":[{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.1],[-150,61.1],[-150,61]]]}},{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-149.95,61.05],[-149.85,61.05],[-149.85,61.15],[-149.95,61.15],[-149.95,61.05]]]}}]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
inputGeometriesrequired | integer | How many polygonal geometries the request held. Always two or more. |
geojsonrequired | object,null | What is left of the first geometry as a single Feature, or null when every part of it was subtracted away. |
emptyrequired | boolean | True when nothing survived the overlay and geojson is null. |
outputVerticesrequired | integer | Positions in the result. Zero when empty. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "difference",
"inputVertices": 10,
"notes": [
"The first geometry in document order had every later one subtracted from it.",
"Vertices are treated as planar in longitude/latitude, which is what every polygon-clipping library does. Edges are straight in degrees, not geodesics; on edges spanning more than a degree or so the two differ."
],
"inputGeometries": 2,
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
-150,
61
],
[
-149.9,
61
],
[
-149.9,
61.05
],
[
-149.95,
61.05
],
[
-149.95,
61.1
],
[
-150,
61.1
],
[
-150,
61
]
]
]
}
},
"empty": false,
"outputVertices": 7
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/area
$0.01 10000 atomic USDC
Ellipsoidal area of a polygon in square metres, holes subtracted.
Measure how much ground a polygon covers, in square metres and square kilometres, with holes subtracted and every part of a MultiPolygon summed. Use it after POST /v1/geometry/intersect to size an overlap, or on a boundary you already hold. Points and lines contribute zero rather than an error, so a mixed collection is safe to send. Not turf's area, which applies the spherical-excess formula to geodetic latitudes on a 6371008.8 m sphere and comes out 0.45% high at the equator and 0.85% low at 80 deg latitude.
This maps latitude to authalic latitude on the WGS 84 authalic sphere first, landing within 5e-3% of GeographicLib's geodesic area on parcel-scale rings and converging to 1e-6% as ring edges are densified. Ring edges are treated as straight in longitude/latitude, per RFC 7946 and turf; a geodesic-edge area differs on long slanted edges, by 1.9% on a 1.7-degree triangle. Winding order is ignored: ring 0 is the exterior and the rest are holes. Input is EPSG:4326 only. Limits: 100000 positions across the whole document, 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 100000 positions. Counted across the whole document. The measurement is linear, so the 1 MiB body cap binds first in any realistic request; this is the backstop against a body of repeated [0,0].
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON Feature, FeatureCollection or Geometry in EPSG:4326, longitude then latitude in degrees. At most 100000 positions across the whole document. Points and lines contribute zero area. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 100,000 positions | Counted across the whole document. The measurement is linear, so the 1 MiB body cap binds first in any realistic request; this is the backstop against a body of repeated [0,0]. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/area \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.05],[-150,61.05],[-150,61]]]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
arearequired | number | Area in square metres, holes subtracted. |
unitrequired | string | Unit of the area field. Always square metres. |
areaSquareKilometresrequired | number | The same number divided by 1e6, so nobody has to. |
polygonCountrequired | integer | Polygons summed, counting each part of a MultiPolygon separately. Zero when the input was all points and lines. |
accuracyrequired | string | The measured error characteristics of this answer, in one sentence. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "area",
"inputVertices": 5,
"notes": [],
"area": 30122157.17615345,
"unit": "m2",
"areaSquareKilometres": 30.12215717615345,
"polygonCount": 1,
"accuracy": "Ellipsoidal (WGS 84) area, within about 5e-3% of a geodesic reference at parcel scale. Ring edges are straight in longitude/latitude, not geodesics."
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/centroid
$0.01 10000 atomic USDC
True area centroid of a polygon, weighted correctly at high latitude.
Find the area centroid of a polygon: a representative point for labelling, joining, or reverse geocoding a shape you only have a boundary for. It is the centre of mass of the area with holes subtracted, not the average of the vertices, so densely sampled edges do not drag it. Know what a centroid is not: on a concave or ring-shaped polygon the centre of mass can fall outside the polygon itself, and this endpoint returns it anyway rather than nudging the point inside. Test it with POST /v1/pip if you need a point that is guaranteed to be inside.
Not turf's centroid, which is the arithmetic mean of the vertices and drifts towards whichever part of the boundary happens to be densely sampled. The moment integral is evaluated in a Lambert cylindrical equal-area projection of the WGS 84 authalic sphere, so the cos(latitude) area weighting is right at high latitude, then inverted back to longitude and latitude. Non-areal input, and polygons that enclose zero area, fall back to the vertex mean and say so in the method field. Input is EPSG:4326 only.
Limits: 100000 positions across the whole document, 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 100000 positions. Counted across the whole document. The moment integral is linear, so the 1 MiB body cap binds first in any realistic request.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON Feature, FeatureCollection or Geometry in EPSG:4326, longitude then latitude in degrees. At most 100000 positions across the whole document. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 100,000 positions | Counted across the whole document. The moment integral is linear, so the 1 MiB body cap binds first in any realistic request. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/centroid \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.05],[-150,61.05],[-150,61]]]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
methodrequired | string | How the point was found. "area_centroid" is the real thing. "vertex_mean" is the fallback for non-areal input and for polygons enclosing zero area, and it is said out loud rather than passed off as a centroid. |
geojsonrequired | object | A Point Feature at the centroid, in EPSG:4326. |
accuracyrequired | string | The measured error characteristics of this answer, in one sentence. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "centroid",
"inputVertices": 5,
"notes": [],
"method": "area_centroid",
"geojson": {
"type": "Feature",
"properties": null,
"geometry": {
"type": "Point",
"coordinates": [
-149.95000000000005,
61.02499021252749
]
}
},
"accuracy": "True area centroid, computed in an equal-area space so the cos(latitude) weighting is right, with holes subtracted. Not the arithmetic mean of the vertices."
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/geometry/bbox
$0.01 10000 atomic USDC
Extent of any geometry as [minLon, minLat, maxLon, maxLat].
Get the extent of a geometry or a whole collection: a map viewport, a tile query window, a cheap prefilter before you pay for a containment test. Takes any geometry type, not just polygons. Bounding box of any geometry as [minLongitude, minLatitude, maxLongitude, maxLatitude]. Naive minimum and maximum per axis, so a geometry spanning the antimeridian reports a full-width longitude range rather than the narrow band it occupies; the response flags that case instead of guessing which side you meant.
Input is EPSG:4326 only. Limits: 100000 positions across the whole document, 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- positions: at most 100000 positions. Counted across the whole document. A min/max walk is linear, so the 1 MiB body cap binds first in any realistic request.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | A GeoJSON Feature, FeatureCollection or Geometry in EPSG:4326, longitude then latitude in degrees. At most 100000 positions across the whole document. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
positions | 100,000 positions | Counted across the whole document. A min/max walk is linear, so the 1 MiB body cap binds first in any realistic request. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/geometry/bbox \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"Polygon","coordinates":[[[-150,61],[-149.9,61],[-149.9,61.05],[-150,61.05],[-150,61]]]}}'
Response
| Field | Type | Meaning |
oprequired | string | The operation you called, echoed back. |
inputVerticesrequired | integer | Positions read from the request. This is the number the vertex cap applies to, so it is also what you would check a batch against. |
notesrequired | array | Anything worth knowing about this particular answer: a collapsed buffer, an antimeridian span, how a tolerance was interpreted. Empty when there is nothing to say. |
bboxrequired | array | [minLongitude, minLatitude, maxLongitude, maxLatitude] in EPSG:4326, in RFC 7946 order. On an antimeridian-spanning input this is the naive minimum and maximum, and notes says so. |
The real answer to that request
Generated by running the example above.
Response body
{
"op": "bbox",
"inputVertices": 5,
"notes": [],
"bbox": [
-150,
61,
-149.9,
61.05
]
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/h3/index
$0.005 5000 atomic USDC
Index positions to their containing H3 cell, at one or more resolutions.
Index points to H3 cells. Send "points" as [longitude, latitude] positions in EPSG:4326 and get each one's containing H3 cell at one or several resolutions, optionally with the cell's centre and its closed boundary ring. Use it to bucket points into a fixed hexagonal grid so datasets that share no keys can be joined, aggregated or deduplicated on cell id. This route indexes positions and nothing else. It does not accept polygons: a body carrying "geojson" is refused rather than served, and because "points" is required here that refusal costs you nothing – it happens before a price is quoted.
To cover a Polygon or MultiPolygon with the cells that tile it, POST /v1/h3/polyfill instead. That is a separate route at a higher price because it is a materially more expensive job: measured on this runtime, indexing one point takes about 0.003 ms and returns 168 bytes, while the cheapest possible polyfill takes about 1.5 ms – the cell count has to be estimated before any cell is generated – and a polyfill at its cell cap is about 200 ms and 1.8 MB. This route is priced with POST /v1/transform, which does the same class of per-position work, and below POST /v1/h3/polyfill.
Every call needs exactly one of "resolution" or "resolutions". Input is EPSG:4326, longitude then latitude; out-of-range values are refused rather than normalised, because H3 will happily index a latitude of 950 and answer with a real, wrong cell. Limits: 10000 points, 4 resolutions per call, and a 1 MiB request body.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- points: at most 10000 positions. 100000 calls to latLngToCell is 72 ms, so this is bandwidth rather than CPU: 10000 points at four resolutions is a 2.1 MB response, and 15.8 MB with includeBoundary set.
- resolutions: at most 4 resolutions. Per call, distinct. Every extra resolution multiplies the response.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself.
Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
pointsrequired | array | Positions to index, each [longitude, latitude] in EPSG:4326. At least 1, at most 10000. Required: this route takes nothing else. To cover a polygon with cells, POST /v1/h3/polyfill. |
resolution | integer | H3 resolution, 0 (about 4.4 million square kilometres per cell) to 15 (about 0.9 square metres). Send this or "resolutions", not both. |
resolutions | array | Several H3 resolutions at once, 0 to 15, distinct. At most 4, because each one multiplies the response. |
includeBoundary | boolean | Include each cell's centre and its closed boundary ring, in GeoJSON [longitude, latitude] order, ready to paste into a Polygon. Defaults to false. It multiplies the response by about eight, so 10000 points at four resolutions goes from 2.1 MB to 15.8 MB. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
points | 10,000 positions | 100000 calls to latLngToCell is 72 ms, so this is bandwidth rather than CPU: 10000 points at four resolutions is a 2.1 MB response, and 15.8 MB with includeBoundary set. |
resolutions | 4 resolutions | Per call, distinct. Every extra resolution multiplies the response. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/h3/index \
-H 'content-type: application/json' \
-d '{"points":[[-122.4184,37.7752]],"resolution":9}'
Response
| Field | Type | Meaning |
moderequired | string | Always "index" on this route. POST /v1/h3/polyfill answers "polyfill" or "polyfill_estimate". |
resolutionsrequired | array | The resolutions actually used, ascending and deduplicated. |
pointCountrequired | integer | Points indexed. |
cellCountrequired | integer | Cells in the response: points times resolutions. |
resultsrequired | array | One row per point, in the order you sent them. |
notesrequired | array | Anything worth knowing about this particular answer. |
The real answer to that request
Generated by running the example above.
Response body
{
"mode": "index",
"resolutions": [
9
],
"pointCount": 1,
"cellCount": 1,
"results": [
{
"point": [
-122.4184,
37.7752
],
"cells": [
{
"resolution": 9,
"cell": "8928308280fffff"
}
]
}
],
"notes": []
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/h3/polyfill
$0.01 10000 atomic USDC
Cover a polygon with the set of H3 cells that tile it.
Polyfill: turn a polygon into the set of H3 cells that cover it, so a spatial question becomes a set-membership question you can answer with a join. Send "geojson" as a Polygon, a MultiPolygon, or a Feature or FeatureCollection wrapping them, in EPSG:4326, plus exactly one of "resolution" or "resolutions". Choose the containment rule with "containment": "center" returns cells whose centre is inside the polygon, which is H3's default and under-covers at the edges; "overlapping" returns every cell the polygon touches, so the set fully covers it; "full" returns only cells entirely inside.
Holes are respected, a MultiPolygon is treated as one polygon with its parts unioned and deduplicated, and feature ids are echoed so rows join back to your records. This route covers polygons and nothing else: a body carrying "points" is refused rather than served, and because "geojson" is required here that refusal happens before a price is quoted and costs you nothing. To index individual positions to their containing cell, POST /v1/h3/index instead, which is cheaper because it is a much smaller job.
What you are paying for here, measured on this runtime: cell count grows about sevenfold per resolution step, so the cost has to be estimated before any cell is generated, which puts a floor of about 1.5 ms on even a city-block polyfill against about 0.003 ms for a one-point index call, and a polyfill at the 100000-cell cap is about 200 ms and 1.8 MB. Anything estimated above that cap is refused with the finest resolution that would have fitted, before a cell is generated; set "estimateOnly" to size a job for the price of the call and nothing else.
Input is EPSG:4326, longitude then latitude, and non-areal geometry is refused by name. Limits: 500 polygons, 10000 polygon positions, 4 resolutions per call, 100000 cells per call, and a 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- resolutions: at most 4 resolutions. Per call, distinct. All of them are counted against the one cell cap, so asking for four at once buys a quarter of the budget each.
- polygons: at most 500 polygons. A MultiPolygon counts as one polygon.
- polygonPositions: at most 10000 positions. Counted across every ring of every polygon.
- polyfillCells: at most 100000 cells. Estimated before a single cell is generated, and refused above this.
About 200 ms and 1.8 MB at the cap, measured on this runtime. Cell count grows about sevenfold per resolution step, so one step too deep is the difference between a cheap call and an impossible one; set estimateOnly to size a job for the price of the call and nothing else.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
geojsonrequired | object | Polygons to cover: a Polygon, a MultiPolygon, or a Feature or FeatureCollection wrapping them, in EPSG:4326. At most 500 polygons and 10000 positions in total. Non-areal geometry is rejected. Required: this route takes nothing else. To index individual positions, POST /v1/h3/index. |
resolution | integer | H3 resolution, 0 (about 4.4 million square kilometres per cell) to 15 (about 0.9 square metres). Send this or "resolutions", not both. One step finer is about seven times as many cells. |
resolutions | array | Several H3 resolutions at once, 0 to 15, distinct. At most 4, and all of them are counted against the one 100000-cell cap. |
containment | string | "center" returns cells whose centre is inside the polygon, the H3 default, which under-covers at the edges. "overlapping" returns every cell the polygon touches, so the set fully covers it. "full" returns only cells entirely inside it. Defaults to "center". |
estimateOnly | boolean | Return the estimated cell count per polygon and resolution without generating any cells. The estimate is an upper bound, never lower than the true count. Defaults to false. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
resolutions | 4 resolutions | Per call, distinct. All of them are counted against the one cell cap, so asking for four at once buys a quarter of the budget each. |
polygons | 500 polygons | A MultiPolygon counts as one polygon. |
polygonPositions | 10,000 positions | Counted across every ring of every polygon. |
polyfillCells | 100,000 cells | Estimated before a single cell is generated, and refused above this. About 200 ms and 1.8 MB at the cap, measured on this runtime. Cell count grows about sevenfold per resolution step, so one step too deep is the difference between a cheap call and an impossible one; set estimateOnly to size a job for the price of the call and nothing else. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/h3/polyfill \
-H 'content-type: application/json' \
-d '{"geojson":{"type":"Polygon","coordinates":[[[-122.43,37.77],[-122.4,37.77],[-122.4,37.79],[-122.43,37.79],[-122.43,37.77]]]},"resolution":8}'
Response
| Field | Type | Meaning |
moderequired | string | "polyfill_estimate" means estimateOnly was set and no cells were generated. |
resolutionsrequired | array | The resolutions actually used, ascending and deduplicated. |
containmentrequired | string | The containment rule that was applied. |
polygonCountrequired | integer | Polygons covered. |
cellCountrequired | integer | Cells in the response. In polyfill_estimate mode this is the upper-bound estimate rather than a count of anything generated. |
resultsrequired | array | One row per polygon and resolution. |
notesrequired | array | Anything worth knowing about this particular answer. |
The real answer to that request
Generated by running the example above.
Response body
{
"mode": "polyfill",
"resolutions": [
8
],
"containment": "center",
"polygonCount": 1,
"cellCount": 9,
"results": [
{
"polygon": 0,
"id": null,
"resolution": 8,
"cellCount": 9,
"estimatedCellCount": 15,
"cells": [
"8828308283fffff",
"882830828bfffff",
"88283082b9fffff",
"8828308281fffff",
"8828308287fffff",
"88283082e3fffff",
"88283082abfffff",
"88283082bdfffff",
"8828308285fffff"
]
}
],
"notes": []
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/pip
$0.01 10000 atomic USDC
Test many points against many polygons and get the full matrix.
Answer which of your points fall inside which of your polygons, in one call. Use it to tag events with the district, catchment or territory they belong to, to filter a point set down to a study area, or to join two datasets that share nothing but geography. The answer comes back twice, as a dense 0/1 matrix and as a per-point list of polygon indices, so neither shape needs rebuilding on your side, and polygon ids are echoed as column labels so the matrix joins straight back to your records. Rings must be closed and coordinates must be EPSG:4326: run POST /v1/geojson/repair first if they are not, and POST /v1/transform if they are not in degrees.
Costs scale with polygon detail, not just polygon count, so POST /v1/geometry/simplify on the polygons is often the cheapest way under the caps. The matrix is dense: every point is tested against every polygon, one row per point and one column per polygon, 1 for inside and 0 for outside. A point exactly on an edge or vertex counts as inside unless "ignoreBoundary" is set. Two caps, both on products rather than on either side: at most 250000 point-polygon pairs, and at most 25000000 point-vertex tests, since a ray cast walks every edge.
Input is EPSG:4326, longitude then latitude. Limits: 250000 points, 2000 polygons, 100000 polygon positions, and a 1 MiB request body.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- points: at most 250000 positions. Rejected while points is being read rather than after. Set where the 1 MiB body cap binds anyway: 250000 positions of [0,0] is about 1.5 MB.
- polygons: at most 2000 polygons. A MultiPolygon is one polygon, and one column of the matrix.
- polygonPositions: at most 100000 positions. Counted across every ring of every polygon. A backstop under the work cap.
- pointPolygonPairs: at most 250000 pairs. points x polygons, checked before any test runs.
The cap is on the product, not on either side, so split whichever side is easier to batch.
- pointVertexTests: at most 25000000 tests. points x total polygon vertices. A ray cast walks every edge, so cost is points times vertices and not points times polygons: one 10000-vertex polygon against 250000 points is 2.5 billion edge tests, which a per-side cap would wave straight through. Simplify the polygons or send fewer points.
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself.
Split the request and post it in parts.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
pointsrequired | array | Positions to test, each [longitude, latitude] in EPSG:4326. At least 1, at most 250000, and further bounded by the two product caps below. |
polygonsrequired | object | A Polygon, a MultiPolygon, or a Feature or FeatureCollection wrapping them, in EPSG:4326. A MultiPolygon is one column of the matrix, not several. Feature "id", else "properties.id", is echoed back as the column label. At most 2000 polygons and 100000 positions. |
ignoreBoundary | boolean | Treat a point lying exactly on a polygon edge or vertex as outside. Defaults to false, which counts it as inside. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
points | 250,000 positions | Rejected while points is being read rather than after. Set where the 1 MiB body cap binds anyway: 250000 positions of [0,0] is about 1.5 MB. |
polygons | 2,000 polygons | A MultiPolygon is one polygon, and one column of the matrix. |
polygonPositions | 100,000 positions | Counted across every ring of every polygon. A backstop under the work cap. |
pointPolygonPairs | 250,000 pairs | points x polygons, checked before any test runs. The cap is on the product, not on either side, so split whichever side is easier to batch. |
pointVertexTests | 25,000,000 tests | points x total polygon vertices. A ray cast walks every edge, so cost is points times vertices and not points times polygons: one 10000-vertex polygon against 250000 points is 2.5 billion edge tests, which a per-side cap would wave straight through. Simplify the polygons or send fewer points. |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/pip \
-H 'content-type: application/json' \
-d '{"points":[[-122.4184,37.7752],[-122.3,37.9]],"polygons":{"type":"FeatureCollection","features":[{"type":"Feature","id":"sf","properties":null,"geometry":{"type":"Polygon","coordinates":[[[-122.52,37.7],[-122.35,37.7],[-122.35,37.84],[-122.52,37.84],[-122.52,37.7]]]}}]}}'
Response
| Field | Type | Meaning |
pointCountrequired | integer | Points tested. Rows in the matrix. |
polygonCountrequired | integer | Polygons tested. Columns in the matrix. |
polygonIdsrequired | array | Column labels in column order. Feature "id", else "properties.id", else null where the feature carried neither. |
matrixrequired | array | Row i, column j is 1 when points[i] is inside polygons[j], else 0. Rows follow the order you sent the points in. |
containedByrequired | array | The same answer as a per-point list of the column indices that are 1. Empty for a point inside nothing. |
pairsTestedrequired | integer | points x polygons. The number the 250000-pair cap applies to. |
containedPairsrequired | integer | How many of those pairs came back inside. |
ignoreBoundaryrequired | boolean | The flag that was in force. |
boundaryRulerequired | string | The boundary rule in words, so the answer is self-describing. |
notesrequired | array | |
The real answer to that request
Generated by running the example above.
Response body
{
"pointCount": 2,
"polygonCount": 1,
"polygonIds": [
"sf"
],
"matrix": [
[
1
],
[
0
]
],
"containedBy": [
[
0
],
[]
],
"pairsTested": 2,
"containedPairs": 1,
"ignoreBoundary": false,
"boundaryRule": "A point exactly on a polygon edge or vertex counts as inside. Set ignoreBoundary to flip this.",
"notes": []
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
GET /v1/timezone
$0.005 5000 atomic USDC
IANA timezone, current UTC offset and DST flag for a coordinate.
Give it a longitude and latitude and it answers with the IANA timezone identifier (America/New_York), the UTC offset in force there right now, whether daylight saving is currently in effect and by how much, the local wall-clock time, and the zone's standard offset. Pass an optional ISO 8601 at instant to ask about any moment between 1900 and 2100 instead of now, which is what you want when scheduling something months out across a DST boundary. Parameters go in the query string: this is a GET, there is no body.
Two halves, with different vintages, and the difference matters. The offset and the DST rules come from the runtime's own timezone database and are current: a rule change reaches this endpoint when Cloudflare updates ICU, with no redeploy here. The boundaries come from tz_world (efele.net), CC0 1.0 and derived from VMAP0, a public-domain US government product. That map was frozen at tzdb 2016d in May 2016, and this catalog sells public-domain and permissively-licensed data only, which is why it is used rather than the current OpenStreetMap-derived alternative.
What the vintage costs, measured against IANA's own coordinates for all 312 zones in zone1970.tab rather than estimated: 293 agree. Of the 19 that do not, 7 are Antarctic research stations, which this map does not cover at all and which fall back to the open-water rule; 7 more return a different identifier for the identical offset and DST all year (Australia/Brisbane for Australia/Lindeman, America/Regina for America/Swift_Current, and five like them), so the time is right and only the name is old.
That leaves four regions where the answer is up to one hour wrong because the zone was split after 2016: Chilean Magallanes and Aysen (answered as America/Santiago rather than America/Punta_Arenas or America/Coyhaique), the Mexican border municipalities around Ciudad Juarez, Saratov Oblast in Russia, and the weeks each spring when Asia/Hebron and Asia/Jerusalem change on different dates. If you query inside one of those, do not trust this endpoint. Boundary precision is a separate question from vintage.
The map is indexed as compacted H3 cells at resolution 6, whose half-width is about 1.6 km, chosen to match VMAP0's own stated positional accuracy of roughly 2 km rather than to flatter the index. So a point within a couple of kilometres of a zone border may be attributed to the neighbour, and no amount of the source's precision would fix that because the source does not have it. Away from a border the answer is exact. Every answer says where it came from under source. boundary means the point is inside a mapped timezone polygon and is the answer you are paying for.
coastal means it is over water or on land this map does not cover, and the identifier is the nearest zone within about 60 km – correct for a harbour or a coastal city whose coordinate falls just offshore, and not a boundary result. nautical means open water more than about 60 km from mapped land, answered with the tz database's own rule for international waters, meridians 15 degrees apart; it is not the time kept aboard any vessel and it ignores territorial waters. Longitude and latitude are checked against EPSG:4326 bounds before anything else touches them, because H3 silently normalises an out-of-range latitude into a real but wrong cell rather than refusing it.
Reproject with POST /v1/transform first if you are not already in WGS 84. The DST flag is decided on offsets rather than on the tz database's own isdst bit, so Europe/Dublin reads as daylight saving in summer like everywhere else, and it is measured over a window either side of the instant so that a zone which changes its standard offset mid-year – Asia/Almaty in 2024 – is not mistaken for one on daylight saving.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- latitude: at most 90 degrees. Absolute value. Outside EPSG:4326 bounds the request is refused rather than normalised, because H3 answers an out-of-range latitude with a real but wrong cell.
- longitude: at most 180 degrees. Absolute value. Reproject with POST /v1/transform if you are not in WGS 84.
- year: at most 2100 year. Latest year the optional at parameter may name; the earliest is 1900.
Outside that the daylight-saving rules are guesswork rather than data.
Requests are validated before they are priced. A request that is missing a query parameter this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Query parameters
| Name | Type | Meaning |
latrequired | number | Latitude in decimal degrees, EPSG:4326. Refused outside -90..90 rather than normalised. |
lonrequired | number | Longitude in decimal degrees, EPSG:4326. Refused outside -180..180. |
at | string | Optional ISO 8601 instant to answer for, e.g. 2026-07-04T16:00:00Z. Defaults to now. Must fall between 1900 and 2100. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
latitude | 90 degrees | Absolute value. Outside EPSG:4326 bounds the request is refused rather than normalised, because H3 answers an out-of-range latitude with a real but wrong cell. |
longitude | 180 degrees | Absolute value. Reproject with POST /v1/transform if you are not in WGS 84. |
year | 2,100 year | Latest year the optional `at` parameter may name; the earliest is 1900. Outside that the daylight-saving rules are guesswork rather than data. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl "https://api.geoprimitives.dev/v1/timezone?lat=40.7128&lon=-74.006"
Response
| Field | Type | Meaning |
timezonerequired | string | IANA timezone identifier, e.g. America/New_York. Always one the runtime's own timezone database resolves. |
utcOffsetrequired | string | Offset in force at `at`, as "+HH:MM". |
utcOffsetMinutesrequired | integer | The same offset in whole minutes east of UTC. Negative west of it. |
isDSTrequired | boolean | Are the clocks advanced at `at` relative to this zone's own standard time? Decided on offsets, not on tzdb's isdst bit. |
dstOffsetMinutesrequired | integer | How far advanced, in minutes. 0 when isDST is false. 30 in Lord Howe. |
standardUtcOffsetrequired | string | Standard-time offset as "+HH:MM". |
standardUtcOffsetMinutesrequired | integer | Standard-time offset in minutes. |
abbreviationrequired | string,null | Short name as the runtime spells it in English: "EST", "GMT+5:45". Null if it has none. |
localTimerequired | string | The zone's wall clock at `at`, ISO 8601 with the offset attached. |
atrequired | string | The instant answered for, ISO 8601 UTC. Echoed. |
sourcerequired | string | boundary: inside a mapped timezone polygon. coastal: nearest zone within about 60 km, for a point over water. nautical: open water, answered by the 15-degree meridian rule. |
cellrequired | string,null | The H3 cell the lookup resolved through, at resolution 6. Null for a nautical answer. Two points in the same cell always get the same zone, so it is also a cache key. |
queryrequired | object | The coordinate as parsed, echoed back. |
boundariesrequired | object | Provenance, on every response, because a buyer cannot inspect it from outside. |
notesrequired | array | Anything worth knowing about this particular answer. Empty for an ordinary boundary result; populated for a coastal or nautical one. |
The real answer to that request
Generated by running the example above.
Response body
{
"timezone": "America/New_York",
"utcOffset": "-04:00",
"utcOffsetMinutes": -240,
"isDST": true,
"dstOffsetMinutes": 60,
"standardUtcOffset": "-05:00",
"standardUtcOffsetMinutes": -300,
"abbreviation": "EDT",
"localTime": "2026-07-04T12:00:00-04:00",
"at": "2026-07-04T16:00:00.000Z",
"source": "boundary",
"cell": "862a1072fffffff",
"query": {
"lat": 40.7128,
"lon": -74.006
},
"boundaries": {
"dataset": "tz_world_mp",
"license": "CC0-1.0",
"tzdbVintage": "2016d",
"h3Resolution": 6,
"accuracyMetres": 1600
},
"notes": []
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
GET /v1/us/geographies
$0.01 10000 atomic USDC
US Census FIPS codes for a coordinate: state, county and congressional district.
Give it a longitude and latitude inside the United States and it answers with the Census FIPS codes for that point: the 2-digit state code, the 5-digit county GEOID, and the GEOID of the congressional district. Parameters go in the query string: this is a GET, there is no body. The codes are the product and the names are a courtesy. A county GEOID is the join key to essentially every geography-keyed federal dataset – the American Community Survey, BLS Local Area Unemployment Statistics, BEA regional accounts, CDC WONDER, HUD, USDA, FEMA – and the state code is its first two digits, so one call gives you both keys already in the form those tables expect.
County GEOID is also the first five digits of a tract GEOID (11 digits) and of a block group GEOID (12), so it is the prefix you would filter a tract-level extract down with. COVERAGE, stated up front because the shortfall is the point: this endpoint answers state, county and congressional district, and it does NOT answer tract, block group, place or ZCTA. That is a size limit, not an oversight, and it was measured rather than guessed. A national compacted H3 index of tracts is 3196 KiB gzipped and of block groups 5335 KiB, at a resolution already too coarse for either; the whole Worker has 3 MiB compressed to spend and most of that was gone before this route existed.
Those four tiers need an object store behind the Worker and will arrive with one. Every response repeats this under coverage, so an agent never has to infer it from a missing field. In the meantime geocoding.geo.census.gov answers all seven tiers, free, authoritative, at the cost of a round trip and a dependency. VINTAGE: TIGER/Line 2025, congressional districts for the 119th Congress. It is on every response under vintage and it matters more here than almost anywhere else in this catalog, because TIGER is re-cut annually and districts move with redistricting, so a stale district code is a wrong answer a caller cannot detect from the outside.
Nothing here self-updates; a new vintage means a rebuild. The data is a work of the United States government and is in the public domain (17 U.S.C. 105) – no attribution clause and no redistribution terms. ACCURACY, and this is the part to read before you trust a code. The boundaries themselves are full-resolution TIGER, the same geometry the Census Geocoder answers from, not a simplified copy. What is approximate is the lookup: the polygons are indexed as compacted H3 cells at resolution 7, about 1.22 km across, so a point within roughly 610 m of a boundary may be attributed to the neighbour.
County equivalents and districts too small to describe at that resolution – Falls Church city, the other Virginia independent cities, the congressional districts inside New York City – carry a second index at resolution 9, about 87 m, and boundaryAccuracyMetres says which one answered you. Because a 610 m index is only useful if you know when not to trust it, every response carries boundaryUncertain.county and boundaryUncertain.congressionalDistrict. They are true when a neighbouring cell resolves to a different code, which means the boundary is close enough that the index cannot settle which side you are on.
Measured against geocoding.geo.census.gov on 650 coordinates: on 400 population-weighted points the county code was right 389 times and the district code 383, and every one of the 11 and 17 disagreements was flagged; on 250 points drawn deliberately 100 m to 1500 m from a county line the county code was right 183 times, and every one of the 67 wrong county codes and 25 wrong district codes was flagged. Across all 650 it never returned a wrong code with the flag clear. It is generous in the other direction – it flagged 74 of the 400 correct-heavy sample – so treat true as "verify this one if it matters" rather than as "this is wrong".
Outside the United States the answer is found: false with null codes and a paid 200, because "this coordinate is not in the US" is the answer you asked for. County polygons include their legal water area out to the three-mile limit and across the Great Lakes, so harbours, piers and lake coordinates resolve normally and only genuinely foreign or high-seas points fall out. Coverage is the 50 states, DC, Puerto Rico, American Samoa, Guam, the Northern Mariana Islands and the US Virgin Islands. A small amount of water is inside a county but inside no congressional district, which TIGER publishes as a real polygon; that answers with a district whose number is null rather than with a guess.
Longitude and latitude are checked against EPSG:4326 bounds before anything else touches them, because H3 silently normalises an out-of-range latitude into a real but wrong cell rather than refusing it. Reproject with POST /v1/transform first if you are not already in WGS 84. TIGER publishes in NAD83, which this reads as WGS 84; the two differ by under two metres, which is a fortieth of the index's own resolution.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- latitude: at most 90 degrees. Absolute value. Outside EPSG:4326 bounds the request is refused rather than normalised, because H3 answers an out-of-range latitude with a real but wrong cell.
- longitude: at most 180 degrees. Absolute value. Reproject with POST /v1/transform if you are not in WGS 84.
- boundaryAccuracyMetres: at most 610 metres. Half-width of one resolution 7 H3 cell, and so how close to a boundary an answer stops being reliable.
Falls to 87 m where a refined index answered. boundaryUncertain tells you per response which side of that you are on.
- answeredTiers: at most 3 tiers. State, county and congressional district. Tract, block group, place and ZCTA are not answered at any price here; see coverage on every response for why.
Requests are validated before they are priced. A request that is missing a query parameter this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Query parameters
| Name | Type | Meaning |
latrequired | number | Latitude in decimal degrees, EPSG:4326. Refused outside -90..90 rather than normalised. |
lonrequired | number | Longitude in decimal degrees, EPSG:4326. Refused outside -180..180. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
latitude | 90 degrees | Absolute value. Outside EPSG:4326 bounds the request is refused rather than normalised, because H3 answers an out-of-range latitude with a real but wrong cell. |
longitude | 180 degrees | Absolute value. Reproject with POST /v1/transform if you are not in WGS 84. |
boundaryAccuracyMetres | 610 metres | Half-width of one resolution 7 H3 cell, and so how close to a boundary an answer stops being reliable. Falls to 87 m where a refined index answered. `boundaryUncertain` tells you per response which side of that you are on. |
answeredTiers | 3 tiers | State, county and congressional district. Tract, block group, place and ZCTA are not answered at any price here; see `coverage` on every response for why. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl "https://api.geoprimitives.dev/v1/us/geographies?lat=33.4484&lon=-112.074"
Response
| Field | Type | Meaning |
foundrequired | boolean | Is this coordinate inside the area TIGER covers? False means outside the United States, and every code below is null. |
staterequired | object,null | State, DC or territory. The GEOID is the first two digits of every other GEOID here. |
countyrequired | object,null | County or county equivalent. |
congressionalDistrictrequired | object,null | Congressional district. Null when the point is inside a county but inside no district, which happens over some water. |
boundaryUncertainrequired | object | True when a neighbouring index cell resolves to a different code, so the point is within about 610 m of a boundary and the code is a best guess. False was never wrong across 650 coordinates checked against the Census Geocoder. |
queryrequired | object | The coordinate as parsed, echoed back. |
cellrequired | string | The H3 cell the lookup resolved through, at resolution 7. Two points in the same cell always get the same codes, so it is also a cache key. |
boundaryAccuracyMetresrequired | integer | How close to a boundary this particular answer is good to: 610 from the main index, 87 where a refined index answered. |
vintagerequired | object | Provenance, on every response, because a buyer cannot inspect it from outside. |
coveragerequired | object | Constant on every response. What this endpoint answers and what it does not. |
notesrequired | array | Anything worth knowing about this particular answer: a flagged boundary, a refined lookup, a point outside the country. Empty for an ordinary interior result. |
The real answer to that request
Generated by running the example above.
Response body
{
"found": true,
"state": {
"geoid": "04",
"fips": "04",
"usps": "AZ",
"name": "Arizona"
},
"county": {
"geoid": "04013",
"fips": "013",
"stateFips": "04",
"name": "Maricopa",
"nameWithLegalType": "Maricopa County"
},
"congressionalDistrict": {
"geoid": "0403",
"stateFips": "04",
"district": "03",
"congress": "119",
"name": "Congressional District 3"
},
"boundaryUncertain": {
"county": false,
"congressionalDistrict": false
},
"query": {
"lat": 33.4484,
"lon": -112.074
},
"cell": "8729b6d35ffffff",
"boundaryAccuracyMetres": 610,
"vintage": {
"dataset": "TIGER/Line",
"publisher": "US Census Bureau",
"vintage": "2025",
"congress": "119",
"rights": "public domain (17 U.S.C. 105, work of the US federal government)",
"h3Resolution": 7,
"h3FineResolution": 9
},
"coverage": {
"answered": [
"state",
"county",
"congressionalDistrict"
],
"notAnswered": [
"tract",
"blockGroup",
"place",
"zcta"
],
"reason": "Tract, block group, place and ZCTA are not answered. A national H3 index of any one of them is larger than this Worker's entire 3 MiB compressed code limit at the resolution those tiers need -- measured, not estimated: tract 3196 KiB gzipped and block group 5335 KiB even at the too-coarse resolution 8. They need an object store behind the Worker (EDW-1256 brings one). Until then, geocoding.geo.census.gov answers them free with a round trip, and the county GEOID this endpoint returns is the first five digits of both."
},
"notes": []
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
GET /v1/elevation
$0.005 5000 atomic USDC
Ground elevation in metres above NAVD88 at a coordinate, from the USGS 3DEP DEM. Alaska.
Give it a latitude and longitude and it answers with the ground elevation there in metres above NAVD88, read from the USGS 3D Elevation Program seamless DEM. Parameters go in the query string: this is a GET, there is no body. COVERAGE, stated up front because it is the first thing to check: the state of Alaska, whole, and nothing else yet. The coverage gate reads the coordinate, so it runs in the handler and after payment, not in front of the 402: a coordinate outside Alaska is quoted like any other and then refused with outside_coverage.
A refusal does not settle, so nothing is charged, but it costs a signature and a second round trip to find out. Check the coverage against this description or the free catalog first. The conterminous United States and Hawaii are the next extension and arrive through the same pipeline with no change to this interface; every response repeats the coverage under coverage so an agent never infers it from an error. Canada is not served even where USGS publishes pixels over it: those come from Natural Resources Canada under a licence with an attribution clause, and this catalog carries public-domain data only, so they were not stored.
SOURCE AND VINTAGE: USGS 3DEP seamless 2 arc-second product, the resolution USGS publishes for Alaska, a work of the United States government in the public domain (17 U.S.C. 105). A 2 arc-second pixel is about 62 m north to south and 20 to 30 m east to west at Alaskan latitudes; the response states the pixel size at your latitude. USGS republishes blocks as new lidar and IFSAR arrive, and the response carries the publication date of the block that answered you. Heights are orthometric, NAVD88, metres; horizontal positions are NAD83, which this endpoint reads as WGS 84 since the two differ by under two metres, a fraction of a pixel.
INTERPOLATION: bilinear over the four pixel centres around the point by default, which is what GDAL and PROJ do for a continuous surface. Pass interpolation=nearest for the raster's own value at the pixel the point falls in, which is bit-identical to gdallocationinfo on the USGS file and the right choice when you want the source unblended. Checked against GDAL rather than against itself: of 200 coordinates drawn over the built blocks, the 150 inside the coverage agreed with gdallocationinfo -r bilinear on the USGS GeoTIFFs to 6.2e-4 m at worst, and interpolation=nearest agreed with plain gdallocationinfo to the millimetre this endpoint rounds to.
The response always names the method used. VOIDS AND WATER: gaps in the USGS footprint and the Canadian side of the border are void, and so is open water beyond the three-mile county limit. A void pixel inside a bilinear window is dropped and the remaining weights renormalised, as gdalwarp treats nodata, and the response says how many of the four were used. A window that is entirely void is refused with no_data and not charged, never answered with zero. Inshore water is a different case and you should know it: USGS writes 0 over the sea in some blocks and nodata in others, so a coordinate in a harbour or a bay inside the county limit may answer 0 m, which is the source's value and not a measurement of anything.
ACCURACY of the data itself is USGS's, not this endpoint's: the Alaska seamless layer is built from IFSAR at 5 m resampled to 2 arc-seconds, and USGS states its vertical accuracy per source project rather than for the seamless layer as a whole. Expect metre-level agreement with a survey on open terrain and worse under forest and on steep slopes. This endpoint adds nothing to that error beyond interpolation. Longitude and latitude are checked against EPSG:4326 bounds first. Reproject with POST /v1/transform if you are not already in WGS 84 or NAD83.
For a line rather than a point use POST /v1/elevation/profile, which samples along a polyline in one call.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- latitude: at most 90 degrees. Absolute value. Outside EPSG:4326 bounds the request is refused.
- longitude: at most 180 degrees. Absolute value. Reproject with POST /v1/transform if you are not in WGS 84 or NAD83.
- coverageBlocks: at most 439 blocks. 1-degree blocks in the store, all in Alaska. A coordinate outside them is refused with outside_coverage, which does not settle. See coverage on every response.
- pixelArcSeconds: at most 2 arc-seconds.
Source resolution: about 62 m north-south and 20 to 30 m east-west in Alaska. Nothing finer is answered; the response states the pixel size at your latitude.
Requests are validated before they are priced. A request that is missing a query parameter this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Query parameters
| Name | Type | Meaning |
latrequired | number | Latitude in decimal degrees, EPSG:4326. Refused outside -90..90. |
lonrequired | number | Longitude in decimal degrees, EPSG:4326. Refused outside -180..180. |
interpolation | string | bilinear (default): weighted mean of the four pixel centres around the point. nearest: the value of the pixel the point falls in, bit-identical to gdallocationinfo. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
latitude | 90 degrees | Absolute value. Outside EPSG:4326 bounds the request is refused. |
longitude | 180 degrees | Absolute value. Reproject with POST /v1/transform if you are not in WGS 84 or NAD83. |
coverageBlocks | 439 blocks | 1-degree blocks in the store, all in Alaska. A coordinate outside them is refused with outside_coverage, which does not settle. See `coverage` on every response. |
pixelArcSeconds | 2 arc-seconds | Source resolution: about 62 m north-south and 20 to 30 m east-west in Alaska. Nothing finer is answered; the response states the pixel size at your latitude. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl "https://api.geoprimitives.dev/v1/elevation?lat=61.2181&lon=-149.8997"
Response
| Field | Type | Meaning |
elevationrequired | number | Metres above NAVD88, to the millimetre. Never null: a void answer is a 400 with code no_data. |
unitsrequired | string | |
verticalDatumrequired | string | |
interpolationrequired | string | The method that produced this number. |
queryrequired | object | The coordinate as parsed, echoed back. |
samplerequired | object | |
sourcerequired | object | Provenance, on every response, because a buyer cannot inspect it from outside. |
coveragerequired | object | Constant on every response. What this endpoint answers and what it does not. |
notesrequired | array | Anything worth knowing about this particular answer, such as a void neighbour. Empty for an interior result. |
The real answer to that request
Generated by running the example above.
Response body
{
"elevation": 31.763,
"units": "metres",
"verticalDatum": "NAVD88",
"interpolation": "bilinear",
"query": {
"lat": 61.2181,
"lon": -149.8997
},
"sample": {
"block": "n62w150",
"neighboursUsed": 4,
"neighboursVoid": 0,
"pixelMetres": {
"northSouth": 61.9,
"eastWest": 29.9
}
},
"source": {
"dataset": "3D Elevation Program (3DEP) seamless DEM",
"product": "2 arc-second",
"publisher": "US Geological Survey",
"url": "https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/2/TIFF/current/",
"usgsPublished": "20170501",
"usgsPublishedRange": [
"2013",
"20260611"
],
"verticalDatum": "NAVD88",
"horizontalDatum": "NAD83",
"units": "metres",
"pixelArcSeconds": 2,
"rights": "Public domain (US government work, 17 U.S.C. 105)"
},
"coverage": {
"region": "Alaska",
"answered": [
"Alaska"
],
"notAnswered": [
"conterminous United States",
"Hawaii",
"territories",
"Canada",
"the rest of the world"
],
"blocks": 439,
"reason": "Coverage is the state of Alaska, whole, from the USGS 3DEP seamless 2 arc-second DEM: every 1-degree block that holds Alaskan ground as the Census TIGER county boundaries draw it, 439 blocks. The conterminous United States and Hawaii are not answered yet. USGS publishes them at 1 arc-second through the same pipeline; they are a 47 GB upload rather than a design change and will arrive as a coverage extension with no change to this interface. Canada is not answered, and the pixels USGS ships over Yukon and British Columbia were not stored: they come from Natural Resources Canada under a licence with an attribution clause, and this catalog carries public-domain data only. A coordinate outside the coverage is quoted like any other and then refused with outside_coverage; a refusal does not settle, so nothing is charged for it."
},
"notes": []
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
POST /v1/elevation/profile
$0.01 10000 atomic USDC
Elevation profile along a polyline: equally spaced samples, gain, loss, min and max. Alaska.
Send a polyline as an array of [longitude, latitude] positions and it answers with elevations sampled at equal geodesic spacing along it, from the USGS 3D Elevation Program seamless DEM, plus the cumulative gain and loss and the minimum and maximum. One call for a whole route rather than one call per point: 2000 samples cost what two cost. COVERAGE is the state of Alaska, whole, and nothing else yet; see GET /v1/elevation for the full statement, which this route shares and repeats under coverage on every response.
Every sample is checked against the coverage before a tile is read, and a profile with any sample outside Alaska is refused with outside_coverage naming the sample. That check reads coordinates, so it runs after payment like the rest of the handler; the refusal does not settle and nothing is charged, but it costs a signature and a second round trip. Canada is not served even where USGS publishes pixels over it, for the licensing reason stated there. SAMPLING: samples are placed at equal geodesic distance (GRS 80) along the polyline, each on the great circle of its segment, the first at the first position and the last at the last.
Omit samples to sample every 60 m, the native pixel height, up to 2000; pass it to choose the count. Each sample is bilinear over the four pixel centres around it by default, or nearest for the raster's own values; the response names the method. The source is the 2 arc-second product USGS publishes for Alaska, about 62 m north to south, so asking for spacing much under 60 m buys interpolation rather than information. VOIDS inside the line: gaps in the USGS footprint, the Canadian side of the border and water beyond the county limit are void.
A sample whose window is entirely void is null in the list and counted in stats.voidSamples; gain and loss are summed over consecutive non-null samples so a crossing of void water is not a descent to zero and back. Inshore water is 0 in some USGS blocks rather than void, so a line across a bay may read 0 m there, which is the source's value; see GET /v1/elevation. A profile that is void throughout is refused with no_data and not charged. SOURCE, VINTAGE, DATUMS AND ACCURACY are those of GET /v1/elevation: USGS 3DEP, public domain, NAVD88 metres, NAD83 horizontal read as WGS 84, IFSAR-derived in Alaska with metre-level vertical accuracy on open ground.
Gain and loss over a 2 arc-second surface are an estimate of the terrain's, not of a track's: fine structure narrower than a pixel is not in the data. Limits: 500 positions in the polyline, 2000 samples, 1000 km of length, 512 elevation tiles read per call (about 2000 km of profile at the coarsest sampling; a 100 km profile at native spacing reads about 40), and a 1 MiB body. All are checked before a tile is read.
Price: $0.01 per call (0.01 USD, 10000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- bodyBytes: at most 1048576 bytes. 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts.
- positions: at most 500 positions. Vertices in the polyline. Simplify a dense GPS track first (POST /v1/geometry/simplify) rather than sending every fix.
- samples: at most 2000 samples. Samples returned per call.
The default spacing of 60 m runs into this at 120 km; longer lines are sampled more coarsely and the response says so, or split the line.
- lengthMetres: at most 1000000 metres. Geodesic length of the whole polyline. Over it the request is refused with profile_too_long before a tile is read.
- tiles: at most 512 tiles. Distinct 64x64 pixel tiles one call may read, counted before any read. A 100 km profile at native spacing needs about 40; only a long line at dense sampling approaches it.
- coverageBlocks: at most 439 blocks. 1-degree blocks in the store, all in Alaska.
A sample outside them refuses the whole profile with outside_coverage, which does not settle.
- pixelArcSeconds: at most 2 arc-seconds. Source resolution. Sampling finer than about 60 m returns interpolated values, not new information.
Requests are validated before they are priced. A request that is over the size cap, is not parseable JSON, declares a Content-Type that is not JSON, is missing a field this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price.
Satisfy the schema before you send: that is what it is published for.
Request body
| Field | Type | Meaning |
coordinatesrequired | array | The polyline, in order. At least two distinct positions. |
samples | integer | How many samples to return, equally spaced along the line including both ends. Omit for one every 60 m, capped at 2000. |
interpolation | string | bilinear (default) or nearest, as on GET /v1/elevation. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
bodyBytes | 1,048,576 bytes | 1 MiB request body, refused on Content-Length before the body is read and again on the body itself. Split the request and post it in parts. |
positions | 500 positions | Vertices in the polyline. Simplify a dense GPS track first (POST /v1/geometry/simplify) rather than sending every fix. |
samples | 2,000 samples | Samples returned per call. The default spacing of 60 m runs into this at 120 km; longer lines are sampled more coarsely and the response says so, or split the line. |
lengthMetres | 1,000,000 metres | Geodesic length of the whole polyline. Over it the request is refused with profile_too_long before a tile is read. |
tiles | 512 tiles | Distinct 64x64 pixel tiles one call may read, counted before any read. A 100 km profile at native spacing needs about 40; only a long line at dense sampling approaches it. |
coverageBlocks | 439 blocks | 1-degree blocks in the store, all in Alaska. A sample outside them refuses the whole profile with outside_coverage, which does not settle. |
pixelArcSeconds | 2 arc-seconds | Source resolution. Sampling finer than about 60 m returns interpolated values, not new information. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl -X POST https://api.geoprimitives.dev/v1/elevation/profile \
-H 'content-type: application/json' \
-d '{"coordinates":[[-149.8997,61.2181],[-149.8397,61.2081]],"samples":5}'
Response
| Field | Type | Meaning |
lengthMetresrequired | number | Geodesic length of the whole polyline on GRS 80. |
sampleCountrequired | integer | |
spacingMetresrequired | number | lengthMetres / (sampleCount - 1). |
interpolationrequired | string | |
unitsrequired | string | |
verticalDatumrequired | string | |
samplesrequired | array | |
statsrequired | object | |
tilesReadrequired | integer | Distinct 64x64 tiles the request needed; the per-call cap is 512. |
geometryrequired | string | How the samples were placed, in words. |
sourcerequired | object | |
coveragerequired | object | |
notesrequired | array | |
The real answer to that request
Generated by running the example above.
Show 2,699 bytes of JSON
{
"lengthMetres": 3411.78,
"sampleCount": 5,
"spacingMetres": 852.95,
"interpolation": "bilinear",
"units": "metres",
"verticalDatum": "NAVD88",
"samples": [
{
"distanceMetres": 0,
"lon": -149.8997,
"lat": 61.2181,
"elevation": 31.763
},
{
"distanceMetres": 852.95,
"lon": -149.8846964,
"lat": 61.2156025,
"elevation": 34.569
},
{
"distanceMetres": 1705.89,
"lon": -149.8696952,
"lat": 61.2131033,
"elevation": 30.598
},
{
"distanceMetres": 2558.84,
"lon": -149.8546964,
"lat": 61.2106025,
"elevation": 37.525
},
{
"distanceMetres": 3411.78,
"lon": -149.8397,
"lat": 61.2081,
"elevation": 36.292
}
],
"stats": {
"minMetres": 30.598,
"maxMetres": 37.525,
"gainMetres": 9.733,
"lossMetres": 5.204,
"voidSamples": 0
},
"tilesRead": 3,
"geometry": "Samples are spaced equally by geodesic distance (GRS 80) along the polyline; each lies on the great circle of its segment.",
"source": {
"dataset": "3D Elevation Program (3DEP) seamless DEM",
"product": "2 arc-second",
"publisher": "US Geological Survey",
"url": "https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/2/TIFF/current/",
"usgsPublished": null,
"usgsPublishedRange": [
"2013",
"20260611"
],
"verticalDatum": "NAVD88",
"horizontalDatum": "NAD83",
"units": "metres",
"pixelArcSeconds": 2,
"rights": "Public domain (US government work, 17 U.S.C. 105)"
},
"coverage": {
"region": "Alaska",
"answered": [
"Alaska"
],
"notAnswered": [
"conterminous United States",
"Hawaii",
"territories",
"Canada",
"the rest of the world"
],
"blocks": 439,
"reason": "Coverage is the state of Alaska, whole, from the USGS 3DEP seamless 2 arc-second DEM: every 1-degree block that holds Alaskan ground as the Census TIGER county boundaries draw it, 439 blocks. The conterminous United States and Hawaii are not answered yet. USGS publishes them at 1 arc-second through the same pipeline; they are a 47 GB upload rather than a design change and will arrive as a coverage extension with no change to this interface. Canada is not answered, and the pixels USGS ships over Yukon and British Columbia were not stored: they come from Natural Resources Canada under a licence with an attribution clause, and this catalog carries public-domain data only. A coordinate outside the coverage is quoted like any other and then refused with outside_coverage; a refusal does not settle, so nothing is charged for it."
},
"notes": []
}
Settles as exact on eip155:8453, 10000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
GET /v1/marine/depth
$0.005 5000 atomic USDC
Charted depth, nearest sounding and survey quality at a coordinate, from NOAA ENC charts. Southeast and southcentral Alaska.
Give it a latitude and longitude and it answers with what the nautical chart says about the water there: the depth range of the charted depth area containing the position, the nearest charted sounding and how far away it is, and the chart's own zone of confidence for that area. Parameters go in the query string: this is a GET, there is no body. NOT FOR NAVIGATION, stated first because it is a condition of using the source. This is derived from NOAA Electronic Navigational Charts and redistributed; it is not an official NOAA ENC and does not meet federal chart carriage regulations under 33 CFR 164 or SOLAS chapter V, which only charts from NOAA or a certified NOAA ENC distributor do.
Every response repeats this under disclaimer. NOAA and NOAA ENC are registered trademarks of NOAA, which does not endorse this service. COVERAGE is 65 NOAA ENC cells of usage bands 3, 4 and 5 over five working areas of southeast and southcentral Alaska: Sitka Sound; Kasaan Bay and Clarence Strait; Metlakatla, Ketchikan and Revillagigedo Channel; Juneau, Gastineau Channel and Lynn Canal; and Cordova with eastern Prince William Sound. It is not statewide, it is not the whole coast, and the small-scale cells that would make it look wider were deliberately left out.
The coverage check reads the coordinate, so it runs in the handler after payment rather than in front of the 402: a position off the charts is quoted like any other and then refused with outside_coverage. A refusal does not settle and nothing is charged, but it costs a signature and a round trip, so check the coverage first. Every response repeats it under coverage. NOTHING IS INTERPOLATED, and that is the difference between this and GET /v1/elevation. Terrain is a continuous surface and sampling it between pixels is legitimate; a chart is a set of assertions a hydrographer made, and averaging two of them invents a depth nobody surveyed.
So depthAreaMinimum is DRVAL1 of the charted depth area the position falls inside, the shoalest depth that area is guaranteed to hold, and nearestSounding is a real charted sounding with the geodesic distance to it. The two routinely disagree and are meant to: a sounding is a measurement at a point and the area is a guarantee over a region. The guarantee is the number to navigate a plan by; the sounding tells you how the area was arrived at. WHICH CHART ANSWERS: ENC cells overlap on purpose, so a position in Sitka sits on a 1:12,000 harbour cell, a 1:45,000 approach cell and a 1:180,000 coastal cell at once, and they do not agree because the smaller scales generalise.
The largest-scale cell covering the position wins outright, usage band first and then compilation scale, which is the rule an ECDIS follows. The response names the cell, its band, its scale, its edition and its NOAA issue date, and lists any cell it consulted first that charted no depth area there. SURVEY QUALITY is on every answer as the chart's CATZOC zone of confidence, with the position and depth accuracy that zone implies, and the survey start and end dates when the chart gives them. Read it before you trust a depth.
NOAA's own warning applies to all of it: much of the depth information on NOAA charts is from surveys conducted before 1940 and the shoreline is more than 20 years old. Zone A1 is a modern full-coverage survey; zone D or U means the chart is telling you it does not know. DATUMS: depths are metres below Mean Lower Low Water, which is the US chart datum, so a real depth at a real moment is this number plus the tide. Heights on the same charts are above Mean High Water. Positions are WGS 84. Reproject with POST /v1/transform if you are in State Plane or NAD27; the datum shift matters here, since a NAD27 Alaskan position is over a hundred metres from its WGS 84 one.
VINTAGE: the charts are a snapshot taken on 2026-05-02. NOAA reissues ENCs weekly for Notice to Mariners and this store does not follow them, so a cell here can be behind NOAA's current edition. The cell edition, update number and issue date that answered are in source for exactly that comparison. REFUSALS that cost nothing: a position with no charted depth area is no_depth_area, which means land, a drying area the chart gives no depth for, or a gap between cells, and it is refused rather than answered with null.
A position off the charts is outside_coverage. A chart object that cannot be read is a 503, not a guess. For what is in the way rather than how deep it is, use GET /v1/marine/hazards, which returns charted rocks, obstructions and wrecks within a radius.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- coverageCells: at most 65 cells. NOAA ENC cells in the store, all in southeast and southcentral Alaska. A position off them refuses with outside_coverage, which does not settle.
- tiles: at most 100 tiles. Chart tiles one call may read, counted before any read. A depth answer normally reads one, or up to nine when the nearest sounding may lie in a neighbouring tile, so this is a backstop rather than a limit you will meet.
- objectClasses: at most 7 S-57 classes.
SOUNDG, DEPARE, DRGARE, UWTROC, OBSTRN, WRECKS and M_QUAL. Buoys, lights, cables, restricted areas and everything else on the chart are not in this store at all.
Requests are validated before they are priced. A request that is missing a query parameter this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Query parameters
| Name | Type | Meaning |
latrequired | number | Latitude in decimal degrees, EPSG:4326. Refused outside -90..90. |
lonrequired | number | Longitude in decimal degrees, EPSG:4326. Refused outside -180..180. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
coverageCells | 65 cells | NOAA ENC cells in the store, all in southeast and southcentral Alaska. A position off them refuses with outside_coverage, which does not settle. |
tiles | 100 tiles | Chart tiles one call may read, counted before any read. A depth answer normally reads one, or up to nine when the nearest sounding may lie in a neighbouring tile, so this is a backstop rather than a limit you will meet. |
objectClasses | 7 S-57 classes | SOUNDG, DEPARE, DRGARE, UWTROC, OBSTRN, WRECKS and M_QUAL. Buoys, lights, cables, restricted areas and everything else on the chart are not in this store at all. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl "https://api.geoprimitives.dev/v1/marine/depth?lat=57.0405&lon=-135.3421"
Response
| Field | Type | Meaning |
depthAreaMinimumrequired | number,null | DRVAL1: the shoalest depth in metres the charted depth area is guaranteed to hold, at chart datum. Null only where the chart gives no minimum, which on a NOAA chart means a drying or intertidal area. |
depthAreaMaximumrequired | number,null | DRVAL2: the deepest depth in the same area, metres. Null where the chart gives none. |
unitsrequired | string | |
soundingDatumrequired | string | |
queryrequired | object | |
nearestSoundingrequired | object,null | The nearest charted sounding within the tiles searched, or null when there is none nearby. Never interpolated with the depth area. |
surveyQualityrequired | object,null | The chart's own assessment of this area. Null where the cell carries no M_QUAL polygon here. |
chartrequired | object | |
tilesReadrequired | integer | Chart tiles read from the object store for this answer. |
interpolationrequired | string | Constant. Says that nothing was interpolated and what the two depth numbers actually are. |
sourcerequired | object | Provenance, on every response, because a buyer cannot inspect it from outside. |
coveragerequired | object | |
disclaimerrequired | object | Constant on every response, and a condition of the source's user agreement rather than decoration. |
notesrequired | array | Anything worth knowing about this particular answer: a dredged area, a missing sounding, no quality statement. Empty when there is nothing to say. |
The real answer to that request
Generated by running the example above.
Show 3,912 bytes of JSON
{
"depthAreaMinimum": 18.2,
"depthAreaMaximum": 36.5,
"units": "metres",
"soundingDatum": "Mean Lower Low Water",
"query": {
"lat": 57.0405,
"lon": -135.3421
},
"nearestSounding": {
"depth": 20.1,
"lon": -135.3420156,
"lat": 57.0405259,
"distanceMetres": 5.88
},
"surveyQuality": {
"zoneOfConfidence": "A1",
"positionAccuracy": "5 m + 5% depth",
"depthAccuracy": "0.50 m + 1% depth; full area search, all significant seafloor features detected",
"positionAccuracyMetres": null,
"soundingAccuracyMetres": null,
"surveyStart": "20020429",
"surveyEnd": "20020510"
},
"chart": {
"cell": "US5AK2NE",
"band": "harbour",
"scale": 12000,
"edition": 3,
"update": 0,
"issued": "20260302",
"cellsConsulted": [
"US5AK2NE"
]
},
"tilesRead": 1,
"interpolation": "none. depthAreaMinimum is DRVAL1 of the charted depth area containing the position, and nearestSounding is a real charted sounding with the distance to it. Nothing between them is invented.",
"source": {
"dataset": "NOAA Electronic Navigational Charts, S-57 edition 3.1",
"publisher": "NOAA Office of Coast Survey (IHO S-62 producer code 550)",
"url": "https://www.charts.noaa.gov/ENCs/",
"release": "20260502",
"cell": "US5AK2NE",
"cellScale": 12000,
"cellBand": "harbour",
"cellEdition": 3,
"cellUpdate": 0,
"cellIssued": "20260302",
"horizontalDatum": "WGS 84",
"soundingDatum": "Mean Lower Low Water",
"units": "metres",
"rights": "Public domain (US government work, 17 U.S.C. 105). NOAA ENC is a registered trademark of NOAA."
},
"coverage": {
"region": "Southeast and southcentral Alaska, 65 NOAA ENC cells",
"cells": 65,
"bands": [
"coastal (band 3)",
"approach (band 4)",
"harbour (band 5)"
],
"areas": [
"Sitka Sound",
"Kasaan Bay and Clarence Strait",
"Metlakatla, Ketchikan and Revillagigedo Channel",
"Juneau, Gastineau Channel and Lynn Canal",
"Cordova and eastern Prince William Sound"
],
"classes": [
"SOUNDG",
"DEPARE",
"DRGARE",
"UWTROC",
"OBSTRN",
"WRECKS",
"M_QUAL"
],
"reason": "The charts are the 65 NOAA ENC cells of usage bands 3 to 5 that cover five working areas of southeast and southcentral Alaska. This is not statewide and it is not the whole coast. The coverage check reads the coordinate, so it runs in the handler and after payment, not in front of the 402: a position off the charts is quoted like any other and then refused with outside_coverage. A refusal does not settle, so nothing is charged, but it costs a signature and a round trip. Check the coverage against this list first."
},
"disclaimer": {
"notForNavigation": true,
"statement": "Derived from NOAA ENC data and redistributed. This is NOT an official NOAA ENC and does NOT meet federal chart carriage regulations under 33 CFR 164 or SOLAS chapter V; only charts from NOAA or a certified NOAA ENC distributor do. Do not navigate on this answer.",
"vintage": "Chart data as held on 2026-05-02. NOAA reissues ENCs weekly for Notice to Mariners and this snapshot does not track them; the cell edition and issue date that answered are in \"source\" so you can compare against NOAA's own catalog.",
"surveyAge": "NOAA's own warning, repeated because it governs how much any of this is worth: much of the depth information on NOAA charts comes from surveys conducted before 1940 and the shoreline is more than 20 years old. The zone of confidence in every depth answer is the chart's own assessment of that, per area.",
"origin": "https://www.charts.noaa.gov/ENCs/",
"trademark": "NOAA and NOAA ENC are registered trademarks of the National Oceanic and Atmospheric Administration, which does not endorse this service."
},
"notes": []
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.
GET /v1/marine/hazards
$0.005 5000 atomic USDC
Charted rocks, obstructions and wrecks within a radius of a coordinate, from NOAA ENC charts. Southeast and southcentral Alaska.
Give it a latitude, a longitude and a radius in metres and it answers with every charted underwater rock, obstruction and wreck inside that circle, nearest first, each with its charted depth, its water level effect, its name where the chart names it, and the geodesic distance from your position. Parameters go in the query string: this is a GET, there is no body. NOT FOR NAVIGATION, stated first because it is a condition of using the source. Derived from NOAA Electronic Navigational Charts and redistributed; not an official NOAA ENC, and it does not meet federal chart carriage regulations under 33 CFR 164 or SOLAS chapter V.
Every response repeats this under disclaimer. NOAA and NOAA ENC are registered trademarks of NOAA, which does not endorse this service. COVERAGE is the same 65 cells as GET /v1/marine/depth: bands 3 to 5 over Sitka Sound; Kasaan Bay and Clarence Strait; Metlakatla, Ketchikan and Revillagigedo Channel; Juneau, Gastineau Channel and Lynn Canal; and Cordova with eastern Prince William Sound. Not statewide and not the whole coast. The check reads the coordinate, so it happens after payment; a position off the charts is refused with outside_coverage, which does not settle.
Every response repeats the coverage under coverage. WHAT AN EMPTY ANSWER MEANS, because this is the one that can hurt: an empty list means the chart shows nothing there, not that the water is clear. Read the zone of confidence from GET /v1/marine/depth for the same position before you read anything into a clean result. Zones C, D and U mean the area was never fully searched and NOAA says so; in zone D large depth anomalies may be expected. Only three S-57 classes are searched – UWTROC, OBSTRN and WRECKS – so a charted danger that is a buoy, a cable, a fish haven area, a spoil ground or a restricted area is not in this store and will not appear.
WHICH CHART ANSWERS, and why it is not just one: the largest-scale cell covering each part of the search wins, applied per location rather than once for the whole circle. A harbour cell ends at its own edge and the water a kilometre outside it is charted by the approach cell, so a search that fixed on one chart would drop those hazards silently. cellsSearched lists every cell that contributed, and each hazard names the cell it came from. Where two cells chart the same rock at a seam it is returned once.
DEPTHS are metres below Mean Lower Low Water. A null depth is a hazard the chart gives no sounding for; read waterLevel instead, since an object that covers and uncovers, or is awash, has no single depth. chartedAsArea marks a wreck or obstruction the chart carries as an area rather than a point, in which case the position given is its centroid and the thing itself is larger. LIMITS: the radius runs from 1 to 5000 metres and defaults to 1000. A search may read 100 chart tiles, counted from the geometry before any read, so an over-wide request is refused for free rather than part-served; the widest legal radius needs at most 81, so the radius is the limit you meet and the tile count is a backstop.
At most 200 hazards come back, nearest first, and the response says when it truncated. VINTAGE is a 2026-05-02 snapshot of charts NOAA reissues weekly; the cell edition and issue date that answered are in each hazard's cell and in source. For how deep the water is rather than what is in it, use GET /v1/marine/depth.
Price: $0.005 per call (0.005 USD, 5000 atomic units of USDC at 6 decimals), settled on eip155:8453 via the x402 protocol. There is no account, no API key and no subscription; the payment travels with the request.
Limits the handler enforces. Every one of these is a 400, and see the note below about when a 400 is refused free:
- radiusMetres: at most 5000 metres. Widest search radius. Over it the call is refused before any chart is read, so it costs nothing; split a wider sweep into several calls.
- hazards: at most 200 hazards. Hazards returned, nearest first. A truncated list says so in notes; narrow the radius for a complete one.
- tiles: at most 100 tiles. Chart tiles one call may read, counted from the search geometry before any read.
The widest legal radius needs at most 81 anywhere in the coverage, so radiusMetres is the limit you will actually meet and this one is a backstop.
- coverageCells: at most 65 cells. NOAA ENC cells in the store. A position off them refuses with outside_coverage, which does not settle.
- objectClasses: at most 3 S-57 classes. UWTROC, OBSTRN and WRECKS. A charted danger of any other class – a buoy, a cable, a fish haven, a restricted area – is not searched and will not appear.
Requests are validated before they are priced. A request that is missing a query parameter this schema marks required is refused with a 400 carrying a machine code, and no 402 is issued at all: nothing is quoted, nothing is signed, nothing settles. Anything that needs the geometry read is checked after payment, and a 400 from that stage cancels settlement rather than completing it, so it still costs you a round trip but not the price. Satisfy the schema before you send: that is what it is published for.
Query parameters
| Name | Type | Meaning |
latrequired | number | Latitude in decimal degrees, EPSG:4326. Refused outside -90..90. |
lonrequired | number | Longitude in decimal degrees, EPSG:4326. Refused outside -180..180. |
radiusMetres | number | Search radius in metres, 1 to 5000. Omit for 1000. A wider search is refused rather than truncated silently. |
Limits
Every cap is enforced; the published number and the enforced number are the same constant read twice.
| Limit | Max | Detail |
radiusMetres | 5,000 metres | Widest search radius. Over it the call is refused before any chart is read, so it costs nothing; split a wider sweep into several calls. |
hazards | 200 hazards | Hazards returned, nearest first. A truncated list says so in notes; narrow the radius for a complete one. |
tiles | 100 tiles | Chart tiles one call may read, counted from the search geometry before any read. The widest legal radius needs at most 81 anywhere in the coverage, so radiusMetres is the limit you will actually meet and this one is a backstop. |
coverageCells | 65 cells | NOAA ENC cells in the store. A position off them refuses with outside_coverage, which does not settle. |
objectClasses | 3 S-57 classes | UWTROC, OBSTRN and WRECKS. A charted danger of any other class -- a buoy, a cable, a fish haven, a restricted area -- is not searched and will not appear. |
Try it
The first call answers 402 with terms in the PAYMENT-REQUIRED header; a paying client signs and retries. With @x402/fetch that is one wrapper around fetch.
curl "https://api.geoprimitives.dev/v1/marine/hazards?lat=57.0405&lon=-135.3421"
Response
| Field | Type | Meaning |
hazardCountrequired | integer | Hazards returned, after the 200 cap. |
radiusMetresrequired | number | The radius as parsed, echoed back. |
queryrequired | object | |
unitsrequired | string | |
soundingDatumrequired | string | |
hazardsrequired | array | Nearest first. |
classesrequired | array | The classes searched. Constant, and shorter than the list of things a chart can show. |
cellsSearchedrequired | array | Every ENC cell that contributed to this search. |
tilesReadrequired | integer | |
sourcerequired | object | |
coveragerequired | object | |
disclaimerrequired | object | |
notesrequired | array | What to make of this particular answer: an empty result, a truncated list, hazards with no charted depth. |
The real answer to that request
Generated by running the example above.
Show 4,344 bytes of JSON
{
"hazardCount": 3,
"radiusMetres": 250,
"query": {
"lat": 57.0405,
"lon": -135.3421
},
"units": "metres",
"soundingDatum": "Mean Lower Low Water",
"hazards": [
{
"classCode": "UWTROC",
"className": "underwater rock",
"name": null,
"depth": 4.2,
"waterLevel": "always under water/submerged",
"category": null,
"chartedAsArea": false,
"lon": -135.3438043,
"lat": 57.0403539,
"distanceMetres": 104.73,
"cell": "US5AK2NE"
},
{
"classCode": "UWTROC",
"className": "underwater rock",
"name": "Mitchell Rock",
"depth": 1.5,
"waterLevel": "always under water/submerged",
"category": null,
"chartedAsArea": false,
"lon": -135.340454,
"lat": 57.0390562,
"distanceMetres": 189.3,
"cell": "US5AK2NE"
},
{
"classCode": "UWTROC",
"className": "underwater rock",
"name": null,
"depth": null,
"waterLevel": "covers and uncovers",
"category": null,
"chartedAsArea": false,
"lon": -135.3400117,
"lat": 57.0422918,
"distanceMetres": 236.4,
"cell": "US5AK2NE"
}
],
"classes": [
"UWTROC",
"OBSTRN",
"WRECKS"
],
"cellsSearched": [
"US5AK2NE"
],
"tilesRead": 1,
"source": {
"dataset": "NOAA Electronic Navigational Charts, S-57 edition 3.1",
"publisher": "NOAA Office of Coast Survey (IHO S-62 producer code 550)",
"url": "https://www.charts.noaa.gov/ENCs/",
"release": "20260502",
"cell": null,
"cellScale": null,
"cellBand": null,
"cellEdition": null,
"cellUpdate": null,
"cellIssued": null,
"horizontalDatum": "WGS 84",
"soundingDatum": "Mean Lower Low Water",
"units": "metres",
"rights": "Public domain (US government work, 17 U.S.C. 105). NOAA ENC is a registered trademark of NOAA."
},
"coverage": {
"region": "Southeast and southcentral Alaska, 65 NOAA ENC cells",
"cells": 65,
"bands": [
"coastal (band 3)",
"approach (band 4)",
"harbour (band 5)"
],
"areas": [
"Sitka Sound",
"Kasaan Bay and Clarence Strait",
"Metlakatla, Ketchikan and Revillagigedo Channel",
"Juneau, Gastineau Channel and Lynn Canal",
"Cordova and eastern Prince William Sound"
],
"classes": [
"SOUNDG",
"DEPARE",
"DRGARE",
"UWTROC",
"OBSTRN",
"WRECKS",
"M_QUAL"
],
"reason": "The charts are the 65 NOAA ENC cells of usage bands 3 to 5 that cover five working areas of southeast and southcentral Alaska. This is not statewide and it is not the whole coast. The coverage check reads the coordinate, so it runs in the handler and after payment, not in front of the 402: a position off the charts is quoted like any other and then refused with outside_coverage. A refusal does not settle, so nothing is charged, but it costs a signature and a round trip. Check the coverage against this list first."
},
"disclaimer": {
"notForNavigation": true,
"statement": "Derived from NOAA ENC data and redistributed. This is NOT an official NOAA ENC and does NOT meet federal chart carriage regulations under 33 CFR 164 or SOLAS chapter V; only charts from NOAA or a certified NOAA ENC distributor do. Do not navigate on this answer.",
"vintage": "Chart data as held on 2026-05-02. NOAA reissues ENCs weekly for Notice to Mariners and this snapshot does not track them; the cell edition and issue date that answered are in \"source\" so you can compare against NOAA's own catalog.",
"surveyAge": "NOAA's own warning, repeated because it governs how much any of this is worth: much of the depth information on NOAA charts comes from surveys conducted before 1940 and the shoreline is more than 20 years old. The zone of confidence in every depth answer is the chart's own assessment of that, per area.",
"origin": "https://www.charts.noaa.gov/ENCs/",
"trademark": "NOAA and NOAA ENC are registered trademarks of the National Oceanic and Atmospheric Administration, which does not endorse this service."
},
"notes": [
"A hazard with a null depth is one the chart gives no sounding for (VALSOU absent). Read its waterLevel instead: an object that covers and uncovers, or is awash, has no single depth."
]
}
Settles as exact on eip155:8453, 5000 atomic USDC to 0x76A51b5Dd3729950B3b60c17f19252d221968FCe.