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.
Testnet. Every route currently settles on Base Sepolia in testnet USDC. Real money arrives with the mainnet cutover; the sandbox stays at sandbox.geoprimitives.dev afterwards.
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:84532, Base Sepolia), the amount in atomic USDC (six decimals), the asset, and payTo (0x42b5882F9Fb35bC1014416F1637F351421151cb5). 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://x402.org/facilitator) 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 | |
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:84532 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:84532, 1000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 5000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
POST /v1/transform
$0.005 5000 atomic USDC
Reproject coordinates or GeoJSON between 1236 EPSG codes.
Reproject coordinates between EPSG coordinate reference systems. 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 1236 codes: WGS 84 and every NAD83 realization as geographic, Web Mercator, all 120 WGS 84 UTM zones north and south, the NAD83 UTM zones, and the 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. Measured on this runtime, one position is about 0.005 ms and 10000 positions about 5 ms – a difference in compute roughly a thousandth of what it costs to settle the payment – so there is no honest basis for a bulk tier and none is offered.
Horizontal only: elevations pass through untouched. Accuracy, and read this before buying: no datum-shift grid is applied. proj4js cannot load NADCON or the NSRS realizations, so WGS 84 and every NAD83 realization are treated as one ellipsoidal datum. Measured against a full PROJ 9.5.1 pipeline that costs up to about 1.29 m on a WGS 84 to NAD83 pair – fine for mapping, wrong for survey control – and every response carries datumShiftApplied:false to say so. Projection arithmetic itself agrees with PROJ to under 1e-8 m.
NAD27 codes are refused outright rather than approximated, because there the same simplification would cost tens of metres. 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, 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:84532 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.
- 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. |
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. |
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 | Always false. No NADCON or NSRS grid is applied, which costs up to about 1.29 m on a WGS 84 to NAD83 pair. Present on every response so nobody has to infer it. |
notesrequired | array | Anything worth knowing about this particular answer: the output unit, a dropped bbox, elevations passed through untouched. |
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"
},
"to": {
"id": "EPSG:3645",
"code": 3645,
"name": "NAD83(NSRS2007) / Oregon North",
"unit": "m"
},
"count": 2,
"datumShiftApplied": false,
"notes": [
"Output coordinates are in m."
],
"coordinates": [
[
2329993.7922822135,
208581.5922682928
],
[
2299969.330455107,
144955.2523715077
]
]
}
Settles as exact on eip155:84532, 5000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 5000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 5000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.
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:84532 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:84532, 10000 atomic USDC to 0x42b5882F9Fb35bC1014416F1637F351421151cb5.