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 a base URL and nothing else.

This page is the endpoint and error contract 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 on the native API alone, 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, which is what makes a partial deployment legible.

MethodPathBody or query200 response
GET/v1/parse?address, ?debug=true, ?input_modeinput, components[], tree, debug?
POST/v1/parse{ address, debug?, input_mode? }input, components[], tree, debug?
POST/v1/geocode{ address, input_mode? }The geocode result: lat, lon, resolution_tier, hierarchy, …
POST/v1/batch{ addresses: string[], input_mode? }{ results: [] }, each a geocode result or { input, error }
POST/v1/resolve{ tree, opts? }{ tree }
POST/v1/format{ components, country, options? }{ formatted, canonicalKey }
POST/v1/reloadnone{ reloaded, versions }
GET/healthnonestatus, uptime_s, plus whatever the engine reports
GET/metricsnoneuptime_s, timings

address is required on /v1/parse and /v1/geocode. input_mode is fragmented or formatted; /v1/batch defaults it to formatted while the other two derive 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/formatter.
  • /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 postal code (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; it names the 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. It names the packages and the 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 carry 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 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, not 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. Answering 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 carries 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, not 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 say 503 on /v1/geocode and keep answering /v1/parse.

See also