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.
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.
| Code | HTTP | Meaning |
|---|---|---|
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.