{
  "openapi": "3.1.0",
  "info": {
    "title": "MapLogics API",
    "version": "1.0.0",
    "description": "Ask what should happen at an address: which location serves it, whether it is serviceable, and why."
  },
  "servers": [
    {
      "url": "https://api.maplogics.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key created in the app. Test keys begin ml_test_, live keys ml_live_. A key carries scopes; a resolve needs the resolve scope."
      }
    },
    "schemas": {
      "Address": {
        "type": "object",
        "required": [
          "line1"
        ],
        "properties": {
          "line1": {
            "type": "string",
            "example": "2 E Main St"
          },
          "line2": {
            "type": "string"
          },
          "city": {
            "type": "string",
            "example": "Richmond"
          },
          "region": {
            "type": "string",
            "example": "VA"
          },
          "postal_code": {
            "type": "string",
            "example": "23219"
          },
          "country": {
            "type": "string",
            "example": "US",
            "description": "ISO 3166-1 alpha-2."
          }
        }
      },
      "Coordinate": {
        "type": "object",
        "required": [
          "lng",
          "lat"
        ],
        "properties": {
          "lng": {
            "type": "number",
            "minimum": -180,
            "maximum": 180,
            "example": -77.436
          },
          "lat": {
            "type": "number",
            "minimum": -90,
            "maximum": 90,
            "example": 37.541
          }
        },
        "description": "WGS84, longitude first. The same order GeoJSON uses."
      },
      "Decision": {
        "type": "object",
        "properties": {
          "request_id": {
            "type": "string",
            "example": "req_9039f987"
          },
          "status": {
            "type": "string",
            "enum": [
              "resolved",
              "unserviceable",
              "ambiguous",
              "no_coverage",
              "invalid_input",
              "error"
            ]
          },
          "reason_code": {
            "type": "string",
            "enum": [
              "PRIMARY_ELIGIBLE_TERRITORY",
              "ASSIGNMENT_RULE_MATCHED",
              "FALLBACK_ASSIGNED",
              "NO_TERRITORY_COVERS_POINT",
              "ALL_CANDIDATES_INELIGIBLE",
              "EXCLUDED_BY_RULE",
              "EQUAL_PRIORITY_OVERLAP",
              "ADDRESS_NOT_FOUND",
              "ADDRESS_TOO_IMPRECISE",
              "INVALID_INPUT"
            ]
          },
          "message": {
            "type": "string",
            "description": "Written for a person. Branch on reason_code, never on this."
          },
          "serviceable": {
            "type": "boolean",
            "nullable": true,
            "description": "Null when the question could not be answered. Treating null as false turns \"we do not know\" into \"no\"."
          },
          "territory": {
            "type": "object",
            "nullable": true
          },
          "location": {
            "type": "object",
            "nullable": true
          },
          "outputs": {
            "type": "object",
            "additionalProperties": true
          },
          "ruleset_version": {
            "type": "string",
            "description": "Which published configuration produced this. Immutable."
          }
        }
      }
    }
  },
  "paths": {
    "/v1/resolve": {
      "post": {
        "summary": "Decide what happens at an address",
        "description": "Send an address or a coordinate. An address is geocoded first; a coordinate is used as given, which makes the answer reproducible.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "address": {
                    "$ref": "#/components/schemas/Address"
                  },
                  "coordinate": {
                    "$ref": "#/components/schemas/Coordinate"
                  },
                  "context": {
                    "type": "object",
                    "properties": {
                      "service": {
                        "type": "string",
                        "example": "hvac"
                      },
                      "channel": {
                        "type": "string"
                      },
                      "customer_type": {
                        "type": "string"
                      },
                      "capacity": {
                        "type": "number"
                      }
                    },
                    "description": "What rules can test. Omitted fields simply do not match."
                  },
                  "idempotency_key": {
                    "type": "string",
                    "description": "Replaying the same key returns the stored answer rather than deciding again, so a retry is not billed twice."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A decision. Note that unserviceable and no_coverage are 200s: the question was answered, and the answer was no.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Decision"
                }
              }
            }
          },
          "400": {
            "description": "The request could not be understood."
          },
          "401": {
            "description": "Missing, unknown or revoked API key."
          },
          "429": {
            "description": "Rate limited. Retry after the time in the header."
          }
        }
      }
    },
    "/v1/simulate/resolve": {
      "post": {
        "summary": "Decide against a draft, changing nothing",
        "description": "Takes a coordinate rather than an address, because geocoding the same text twice can return different points and a simulation you cannot reproduce is not evidence. Records no resolve event and is not billed.",
        "responses": {
          "200": {
            "description": "A decision, with a full trace."
          }
        }
      }
    },
    "/v1/decisions/{requestId}": {
      "get": {
        "summary": "Fetch a decision already made",
        "parameters": [
          {
            "name": "requestId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The stored decision and its trace."
          },
          "404": {
            "description": "No such decision in this organization."
          }
        }
      }
    }
  },
  "x-reason-codes": [
    {
      "code": "PRIMARY_ELIGIBLE_TERRITORY",
      "status": "resolved",
      "guidance": "Resolved to the highest-priority eligible territory."
    },
    {
      "code": "ASSIGNMENT_RULE_MATCHED",
      "status": "resolved",
      "guidance": "An assignment rule selected this territory explicitly."
    },
    {
      "code": "FALLBACK_ASSIGNED",
      "status": "resolved",
      "guidance": "No primary candidate could take it, so the configured fallback did."
    },
    {
      "code": "NO_TERRITORY_COVERS_POINT",
      "status": "no_coverage",
      "guidance": "No territory covers this address. Extend a boundary or add one to cover it."
    },
    {
      "code": "ALL_CANDIDATES_INELIGIBLE",
      "status": "unserviceable",
      "guidance": "A territory covers this address, but no assigned location can perform the requested service. Check the capabilities on those locations."
    },
    {
      "code": "EXCLUDED_BY_RULE",
      "status": "unserviceable",
      "guidance": "Every candidate was removed by an exclusion rule."
    },
    {
      "code": "EQUAL_PRIORITY_OVERLAP",
      "status": "ambiguous",
      "guidance": "Two or more territories are equally eligible and nothing breaks the tie. Set an explicit priority, or narrow one boundary."
    },
    {
      "code": "ADDRESS_NOT_FOUND",
      "status": "invalid_input",
      "guidance": "No geocoder could place this address. Check it for typos, or send a coordinate instead."
    },
    {
      "code": "ADDRESS_TOO_IMPRECISE",
      "status": "invalid_input",
      "guidance": "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."
    },
    {
      "code": "INVALID_INPUT",
      "status": "invalid_input",
      "guidance": "The request could not be understood."
    }
  ],
  "x-error-codes": [
    {
      "code": "invalid_request",
      "status": 400,
      "message": "The request body could not be understood."
    },
    {
      "code": "invalid_coordinate",
      "status": 400,
      "message": "lat must be between -90 and 90 and lng between -180 and 180. Note the order: [lng, lat]."
    },
    {
      "code": "missing_location_input",
      "status": 400,
      "message": "Provide either an address or a coordinate."
    },
    {
      "code": "unauthorized",
      "status": 401,
      "message": "Provide an API key as \"Authorization: Bearer ml_live_...\"."
    },
    {
      "code": "invalid_api_key",
      "status": 401,
      "message": "That API key is not valid. Keys are shown once at creation and cannot be recovered."
    },
    {
      "code": "revoked_api_key",
      "status": 401,
      "message": "That API key has been revoked."
    },
    {
      "code": "organization_suspended",
      "status": 403,
      "message": "This organization is not active. Contact support to restore access."
    },
    {
      "code": "forbidden",
      "status": 403,
      "message": "Your role in this organization does not allow this action."
    },
    {
      "code": "forbidden_origin",
      "status": 403,
      "message": "This key is not allowed to be called from that origin."
    },
    {
      "code": "insufficient_scope",
      "status": 403,
      "message": "That key does not carry the scope this endpoint needs."
    },
    {
      "code": "no_published_configuration",
      "status": 409,
      "message": "This organisation has not published a configuration yet, so there is nothing to resolve against."
    },
    {
      "code": "conflict",
      "status": 409,
      "message": "Someone else changed this since you loaded it. Reload to see their version."
    },
    {
      "code": "precondition_required",
      "status": 428,
      "message": "This change would overwrite an existing record. Send the version you are editing so we can check nobody else changed it first."
    },
    {
      "code": "idempotency_conflict",
      "status": 409,
      "message": "That idempotency key was already used with a different request body."
    },
    {
      "code": "rate_limited",
      "status": 429,
      "message": "Too many requests. Retry after the interval in the Retry-After header."
    },
    {
      "code": "quota_exceeded",
      "status": 429,
      "message": "This organisation is over its plan limit for resolves."
    },
    {
      "code": "geocoder_unavailable",
      "status": 502,
      "message": "The address could not be geocoded because every provider failed. Retry, or send a coordinate."
    },
    {
      "code": "runtime_unavailable",
      "status": 503,
      "message": "The configuration for this organisation could not be loaded. This is on our side."
    },
    {
      "code": "not_found",
      "status": 404,
      "message": "No record with that identifier exists for this organization."
    },
    {
      "code": "internal_error",
      "status": 500,
      "message": "Something went wrong on our end. Quote the request id if you get in touch."
    }
  ]
}