API reference

One call answers one question: for this address, what should happen. The answer always carries a reason code, because a decision you cannot explain is one you cannot act on.

The machine-readable description is at /openapi.json. It is generated from the same code the resolver runs, so the codes below are the codes you will actually receive.

Authenticating

Every request carries a key as a bearer token. Keys are created in Settings, shown once, and stored as a hash, so a lost key is replaced rather than recovered.

Keys begin ml_test_ or ml_live_, and carry scopes. A resolve needs the resolve scope and nothing more. Give an integration the narrowest set that does its job.

Deciding an address

Send an address or a coordinate. An address is geocoded first. A coordinate is used as given, which is what makes an answer reproducible.

Request bash

curl https://api.maplogics.com/v1/resolve \
  -H "Authorization: Bearer ml_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "address": {
      "line1": "2 E Main St",
      "city": "Richmond",
      "region": "VA",
      "postal_code": "23219",
      "country": "US"
    },
    "context": { "service": "hvac" }
  }'

Response json

{
  "request_id": "req_9039f987",
  "status": "resolved",
  "reason_code": "ASSIGNMENT_RULE_MATCHED",
  "serviceable": true,
  "territory": { "id": "trr_...", "name": "Richmond Central" },
  "location":  { "id": "loc_...", "name": "Richmond Central" },
  "outputs": {},
  "ruleset_version": "cfg_01M0X5..."
}

Unserviceable is a 200. The question was answered and the answer was no. A non-2xx means we could not answer, which is a different thing and usually your retry.

Reason codes

Branch on reason_code, never on message. The message is written for a person and will be reworded; the code is the contract.

resolved

PRIMARY_ELIGIBLE_TERRITORY
Resolved to the highest-priority eligible territory.
ASSIGNMENT_RULE_MATCHED
An assignment rule selected this territory explicitly.
FALLBACK_ASSIGNED
No primary candidate could take it, so the configured fallback did.

unserviceable

ALL_CANDIDATES_INELIGIBLE
A territory covers this address, but no assigned location can perform the requested service. Check the capabilities on those locations.
EXCLUDED_BY_RULE
Every candidate was removed by an exclusion rule.

no_coverage

NO_TERRITORY_COVERS_POINT
No territory covers this address. Extend a boundary or add one to cover it.

ambiguous

EQUAL_PRIORITY_OVERLAP
Two or more territories are equally eligible and nothing breaks the tie. Set an explicit priority, or narrow one boundary.

invalid_input

ADDRESS_NOT_FOUND
No geocoder could place this address. Check it for typos, or send a coordinate instead.
ADDRESS_TOO_IMPRECISE
This address could only be placed to a postal code or region, which is not precise enough to route: a boundary can run through it. Add a street number, or send a coordinate.
INVALID_INPUT
The request could not be understood.

Never collapse these into a boolean. "No territory covers this address", "a territory covers it but nobody can do the work", and "two territories tie" have three different remedies. A single false throws away the one thing that tells you which.

Errors

Every error carries a stable code, the same request_id as the response header, and a message for a person.

CodeHTTPMeaning
invalid_request 400 The request body could not be understood.
invalid_coordinate 400 lat must be between -90 and 90 and lng between -180 and 180. Note the order: [lng, lat].
missing_location_input 400 Provide either an address or a coordinate.
unauthorized 401 Provide an API key as "Authorization: Bearer ml_live_...".
invalid_api_key 401 That API key is not valid. Keys are shown once at creation and cannot be recovered.
revoked_api_key 401 That API key has been revoked.
organization_suspended 403 This organization is not active. Contact support to restore access.
forbidden 403 Your role in this organization does not allow this action.
forbidden_origin 403 This key is not allowed to be called from that origin.
insufficient_scope 403 That key does not carry the scope this endpoint needs.
no_published_configuration 409 This organisation has not published a configuration yet, so there is nothing to resolve against.
conflict 409 Someone else changed this since you loaded it. Reload to see their version.
precondition_required 428 This change would overwrite an existing record. Send the version you are editing so we can check nobody else changed it first.
idempotency_conflict 409 That idempotency key was already used with a different request body.
rate_limited 429 Too many requests. Retry after the interval in the Retry-After header.
quota_exceeded 429 This organisation is over its plan limit for resolves.
geocoder_unavailable 502 The address could not be geocoded because every provider failed. Retry, or send a coordinate.
runtime_unavailable 503 The configuration for this organisation could not be loaded. This is on our side.
not_found 404 No record with that identifier exists for this organization.
internal_error 500 Something went wrong on our end. Quote the request id if you get in touch.

Webhooks

Rather than polling for changes, subscribe. We send a signed POST when a configuration goes live, when one is rolled back, and when a location is archived.

Each delivery carries X-MapLogics-Signature: t=<unix>,v1=<hex>. Verify it before trusting the body: your endpoint is public, and without the check anyone who learns the URL can tell you your routing changed.

Verifying a delivery javascript

import crypto from 'node:crypto'

// The timestamp is inside the signed material, so it cannot be
// edited to make an old body look current.
export function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=')),
  )
  const age = Math.abs(Date.now() / 1000 - Number(parts.t))
  if (age > 300) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')

  // Constant time: comparing with === leaks where the first
  // difference is, which over many attempts is enough.
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1),
  )
}

Answer 2xx once you have stored it. Anything else is retried with widening backoff over about two hours, and every attempt is visible in Settings with the status we got back.