Skip to main content

HTTP APIs

Scope​

Mailwoman serves four HTTP surfaces. One is native. Three answer on the wire shapes that Nominatim, Photon and libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it. clients already send, so an existing client changes only a base URL.

This page is the endpoint and error interface for all four. It is not a tutorial: for a running server with real responses, follow Run the API server, and for replacing an existing deployment, Swap in for Nominatim.

Every surface emits its own OpenAPI document, and those documents are generated from the same route definitions the server registersinput modeThe Decision-A register switch: 'fragmented' (human-typed fragments — feeds the evidence channels) vs 'formatted' (complete records — runs the trained absence identity). Explicit on CLI/API; per-endpoint defaults (batch→formatted, autocomplete→fragmented); kind-derived otherwise.. Where this page and a document disagree, the document is right.

Surfaces​

SurfacePackageCommandDefault portOpenAPI
Native@mailwoman/apimailwoman serve3000mailwoman.json
Nominatim@mailwoman/nominatimnominatim serve8080nominatim.json
Photon@mailwoman/photonphoton serve2322photon.json
libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.@mailwoman/libpostallibpostal serve8081libpostal.json

Each drop-in package declares one unscoped binary, so it installs as nominatim, photon or libpostal in node_modules/.bin, and npx @mailwoman/nominatim serve reaches it without naming the binary. Each server binds 0.0.0.0 and takes --port and --host. Neither is settable by an environment variable, so a deployment that configures ports through the environment passes them as flags. Each server also serves its own document at /openapi.json.

URL anatomy​

Every request to every surface has the same four parts.

http://localhost:3000/v1/geocode
└──┬──┘└─────┬──────┘└┬┘└──┬───┘
│ │ │ │
│ │ │ └─ endpoint — the operation
│ │ └────── version prefix — native surface only; the drop-ins have none
│ └─────────────── host and port — 3000 native, 8080 / 2322 / 8081 for the drop-ins
└───────────────────────── scheme — the servers speak HTTP; terminate TLS in front

The version prefix is the one part that differs by surface. /v1 exists only on the native API, because the drop-ins reproduce paths their originals already fixed.

Native API​

@mailwoman/api takes an engine object in which every method is optional. An absent method answers a status rather than throwing. That makes a partial deployment legible.

MethodPathOperation IDBody or query200 response
GET/v1/parseparseAddressQuery?address, ?debug=true, ?input_modeinput, components[], tree, debug
POST/v1/parseparseAddress{ address, debug?, input_mode? }input, components[], tree, debug
POST/v1/geocodegeocodeAddress{ address, input_mode? }A GeocodeResult: lat, lon, resolution_tier, hierarchy, …
POST/v1/batchgeocodeBatch{ addresses: string[], input_mode? }{ results: [] }, each a GeocodeResult or { input, error }
POST/v1/resolveresolveTree{ tree, opts? }{ tree }
POST/v1/formatformatAddress{ components, country, options? }{ formatted, canonicalKey }
POST/v1/reloadreloadDatanone{ reloaded, versions }
GET/healthretrieveHealthnonestatus, uptime_s, plus whatever the engine reports
GET/metricsretrieveMetricsnoneuptime_s, timings

Each operation is defined once in @mailwoman/api/operations/*, and both the Hono app and @mailwoman/fastify serve those definitions. The GeocodeResult schema lives in @mailwoman/core/geocode. @mailwoman/fastify also serves POST /v1/poi (searchPOI), which takes { query }.

address is required on /v1/parse and /v1/geocode. input_mode is fragmented, formatted or auto. /v1/batch defaults it to formatted, and the other two default to auto, which derives it from the input. On /v1/format, a components entry whose value is an array collapses to its first element.

Four behaviors decide how a client reads a response, and none of them is visible in the table.

  • /v1/format needs no engine method at all. It is wired in-package from @mailwoman/codex/address/format.
  • /health answers 200 with no engine wired, so it reports process liveness rather than readiness. When mailwoman serve wires the real engine it also reports the loaded modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' and the data root: model.name, model.version, model.labels, data.data_root, data.wof_dbs, data.situs_states, data.interpolation_states.
  • /v1/batch with addresses: [] answers 200 { "results": [] } before it checks for an engine.
  • /metrics counts only /v1/geocode and /v1/batch, and reports { uptime_s, timings: { total, errors, tiers, latency_ms, latency_samples } }. latency_ms is null until the first sample and a tier never seen is absent rather than zero. Counters reset on restart, and under mailwoman serve --cpus N each worker keeps its own.

Request and response​

curl -sS localhost:3000/v1/parse \
-H 'content-type: application/json' \
-d '{"address":"221B Baker St, London NW1 6XE"}'

The full response, piped through python3 -m json.tool:

{
"input": "221B Baker St, London NW1 6XE",
"components": [
{ "tag": "house_number", "value": "221B" },
{ "tag": "street", "value": "Baker" },
{ "tag": "street_suffix", "value": "St" },
{ "tag": "locality", "value": "London" },
{ "tag": "postcode", "value": "NW1 6XE" }
],
"tree": {
"raw": "221B Baker St, London NW1 6XE",
"roots": [
{
"tag": "locality",
"start": 15,
"end": 21,
"value": "London",
"confidence": 0.9503604529684699,
"children": [
{
"tag": "street",
"start": 5,
"end": 10,
"value": "Baker",
"confidence": 0.9047541558664625,
"children": [
{
"tag": "house_number",
"start": 0,
"end": 4,
"value": "221B",
"confidence": 0.9040313732694398,
"children": []
},
{
"tag": "street_suffix",
"start": 11,
"end": 13,
"value": "St",
"confidence": 0.8870032167591001,
"children": []
}
]
},
{
"tag": "postcode",
"start": 22,
"end": 29,
"value": "NW1 6XE",
"confidence": 0.6799167287312248,
"children": []
}
]
}
]
}
}

components is the flat projection and tree is the same parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. with containment preserved: the postcodepostcodeThe country-specific postcode (US ZIP, French code postal, etc.). Mailwoman handles postcode parsing entirely by rule classifier — a regex problem, not an ML one. and the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels. sit under the localitylocalityThe city / town / settlement component of an address: a populated place sitting between region and neighborhood in the hierarchy., and the house numberhouse numberThe numeric or alphanumeric identifier of a building on a street. Mailwoman's house_number component; its position relative to the street name flips between locales. and suffix sit under the streetstreetThe named linear feature along which house numbers are ordered. Decomposes into a name plus street affixes; one of the Tier 2 fine labels.. confidence on each node is the raw spanspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. confidence and is under-confident by default — see createCalibrator in Library API.

An omitted address answers the envelope:

curl -sS localhost:3000/v1/parse -H 'content-type: application/json' -d '{}'
{ "error": "address is required" }

Errors​

The native surface returns one envelope: an error string a caller matches on, and an optional detail a human reads. No other key appears, and the raw validator output is never surfaced.

StatuserrordetailMeaningNext step
400address is requiredabsentaddress was missing, empty, or whitespaceSend a non-empty address
400body must be { addresses: string[] }absent/v1/batch body failed validationFix the body shape
400body must be { tree: AddressTree, opts? }absent/v1/resolve body failed validationSend a { tree } object
400invalid request body<field>: <message>A validated field failedRead detail for the failed field
400invalid request bodymalformed JSONThe body did not parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. as JSONSend well-formed JSON
413request body too largeabsentThe body exceeded the limit, 2 MiB by defaultSplit the request
413batch too large: <n> > <max>absentMore rows than the batchbatch sizeHow many examples the model processes before each parameter update. Larger batches give smoother gradients but cost more memory; gradient accumulation simulates a big batch on a small GPU. cap allowsChunk to the cap. Set MAILWOMAN_BATCH_MAX to raise it
500internal errorthe underlying messageThe engine faultedRead detail, then report it
501parse not implementedabsentThe engine has no parse. The modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' is not wiredInstall the weightsparameterA single learned number inside a model — one weight or bias. Mailwoman's encoder has roughly 30 million of them; training is the search for good values. package for your localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for.
503geocoder not availablethe install line for the missing dependencygeocode, batch or reload is absentFollow detail for the packages and database paths
503resolver not availablethe same install lineresolveTree is absentFollow detail

The 501 and 503 split is deliberate. 501 means the surface exists and the capability was never wired. 503 means the code is present and a deployment dependency — a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. database, a resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. package — is missing. The first is a build decision. The second is fixable by installing something.

Two orderings matter to a caller writing retry logic: the batchbatch sizeHow many examples the model processes before each parameter update. Larger batches give smoother gradients but cost more memory; gradient accumulation simulates a big batch on a small GPU.-size check runs before the engine check, so an oversized batchbatch sizeHow many examples the model processes before each parameter update. Larger batches give smoother gradients but cost more memory; gradient accumulation simulates a big batch on a small GPU. against an unwired engine answers 413 rather than 503; and an unmatched path answers Hono's plain-text 404 Not Found rather than this envelope.

Drop-in surfaces​

Each drop-in keeps its original's error shape rather than the native envelope, because a client written against the original parsesaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. that shape. That is the whole point of a drop-in.

Nominatim​

MethodPathQueryResponse
GET/searchq, street, city, county, state, country, postalcode, countrycodes, bounded, limit, addressdetails, format, accept-languageResult array, FeatureCollection, or JSON-LD
GET/reverselat, lon, zoom, addressdetails, format, accept-languageOne result, null, a FeaturefeatureAn input signal a model conditions on. Beyond the raw tokens, Mailwoman feeds soft features — gazetteer-membership channels and the postcode anchor — that inform predictions without overriding them., or JSON-LD
GET/lookuposm_ids, addressdetails, formatResult array or FeatureCollection
GET/statusnone{ status, message, data_updated? }

lat and lon are required on /reverse. format accepts jsonv2 (the default), json, geojson and jsonld; limit defaults to 10. Results include an OpenCage-style annotations block — see Use annotations.

/lookup accepts format=jsonld and answers with the jsonv2 array anyway. That is the original's behavior, preserved.

Photon​

MethodPathQueryResponse
GET/apiq, limit, lang, lat, lon, osm_tag, layer, formatGeoJSON FeatureCollection, or JSON-LD
GET/reverselat, lon, limit, lang, radius, formatGeoJSON FeatureCollection, or JSON-LD

q is required on /api; lat and lon are required on /reverse. limit defaults to 15. osm_tag and layer are repeatable.

Photon's error shape is an empty FeatureCollection carrying a message, never an error key, so a client that iterates features handles a failure without a branch. Photon serves no health endpoint.

libpostal​

MethodPathBody or queryResponse
GET/parse?query or ?address[{ label, value }, …]
POST/parse{ query? } or { address? }[{ label, value }, …]
GET/expand?address{ expansions: string[] }
POST/expand{ address? }{ expansions: string[] }

Precedence on /parse is fixed: body query, then ?query, then body address, then ?address. Precedence is by presence rather than by truthiness, so a present empty string wins over a lower one and then fails validation. The body limit is 100 KiB rather than the native surface's 2 MiB.

Drop-in error shapes​

SurfaceStatusBody
Nominatim400{ "error": "lat and lon are required" }
Nominatim400{ "error": "lat must be in [-90, 90] and lon in [-180, 180]" }
Nominatim500{ "error": "internal error" }
Nominatim501{ "error": "search not implemented (see #802)" }; reverse cites #803, lookup #805
Photon400{ "type": "FeatureCollection", "features": [], "message": "q is required" }
Photon500{ "type": "FeatureCollection", "features": [], "message": "internal error" }
Photon501{ "type": "FeatureCollection", "features": [], "message": "search not implemented" }
libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.400{ "error": "query is required" } or { "error": "address is required" }
libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.413{ "error": "request body too large" }
libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.500{ "error": "internal error" }
libpostallibpostalAn open-source C address parser used by Pelias. Mailwoman's rule-based v0 and neural classifier supersede it.501{ "error": "expand not implemented" }

Nominatim's /status is the one endpoint that answers 200 with no engine method wired, reporting { "status": 0, "message": "OK" }. Every other absent method on every surface answers 501.

OpenAPI​

Each server serves its document at /openapi.json and each CLI emits it to a file:

node node_modules/mailwoman/out/cli.js openapi --out ./mailwoman.json

--flavor selects 3.1 (the default, emitting 3.1.0) or 3.0 (emitting 3.0.3). Output is single-line JSON. The four documents published with this site are regenerated on every docs build from the same route definitions the servers registerinput modeThe Decision-A register switch: 'fragmented' (human-typed fragments — feeds the evidence channels) vs 'formatted' (complete records — runs the trained absence identity). Explicit on CLI/API; per-endpoint defaults (batch→formatted, autocomplete→fragmented); kind-derived otherwise.: mailwoman.json, nominatim.json, photon.json, libpostal.json.

Each document declares servers[0] as http://{host}:{port} with host defaulting to 127.0.0.1. The servers bind 0.0.0.0; the document states the address a client dials rather than the address the process binds.

Rationale​

The drop-ins exist because the cost of changing a geocoder is rarely the geocoder. It is the client code, the response parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates., and the tests written against a particular JSON shape. The server answers on the shape a deployment already parsesaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. moves that cost to zero and makes the swap reversible: point the base URL back and the old system answers again.

The native surface has the /v1 prefix the drop-ins lack because it is the one shape this project controls and therefore the one that has to survive its own revisions.

Absent engine methods answer statuses rather than throwing because a partial deployment is a normal state rather than a bug. A parser with no gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. is a useful service. It should return 503 on /v1/geocode and keep answering /v1/parse.

See also​