{
  "openapi": "3.1.0",
  "info": {
    "title": "Otiggo Route Optimization API",
    "version": "1.0.0",
    "summary": "Optimize delivery routes: send your stops, get the best order and arrival times back.",
    "description": "Route optimization API for last-mile delivery. Send up to 200 stops (coordinates or addresses) and receive the optimal stop order, arrival times, total distance and duration, and the stops that would be late against their deadlines.\n\nBilling is prepaid per stop, with no subscription. A failed call is never charged. Test keys (`otg_test_…`) are free and limited; live keys (`otg_live_…`) debit one stop per stop sent.\n\nDocumentation: https://www.otiggo.com/developers/en",
    "contact": { "name": "Otiggo", "url": "https://www.otiggo.com" }
  },
  "servers": [{ "url": "https://app.otiggo.com", "description": "Production" }],
  "externalDocs": { "description": "Full documentation", "url": "https://www.otiggo.com/developers/en" },
  "security": [{ "bearerAuth": [] }],
  "tags": [
    { "name": "Optimization", "description": "Compute optimized routes." },
    { "name": "Account", "description": "Balance and sandbox quotas." }
  ],
  "paths": {
    "/api/v1/optimize": {
      "post": {
        "tags": ["Optimization"],
        "operationId": "optimizeRoute",
        "summary": "Optimize a route",
        "description": "Computes the optimal order of 2 to 200 stops from a start point, with arrival times, distance and duration. Live keys debit one stop per stop sent (refunded if the call fails). Test keys are free but limited to 10 stops per call, 20 calls per minute and 200 stops per day, and accept coordinates only (no address geocoding).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/OptimizeRequest" },
              "example": {
                "start": { "street": "12 rue de la Paix", "postalCode": "75002", "city": "Paris" },
                "stops": [
                  { "id": "A", "latitude": 48.87, "longitude": 2.3, "deadline": "10:30" },
                  { "id": "B", "street": "5 avenue Foch", "postalCode": "75116", "city": "Paris" },
                  { "id": "C", "latitude": 48.84, "longitude": 2.37 }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Optimized route.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OptimizeResponse" },
                "example": {
                  "order": ["B", "A", "C"],
                  "waypoints": [
                    { "id": "B", "order": 1, "latitude": 48.8738, "longitude": 2.2950 },
                    { "id": "A", "order": 2, "latitude": 48.87, "longitude": 2.3 },
                    { "id": "C", "order": 3, "latitude": 48.84, "longitude": 2.37 }
                  ],
                  "totalDistanceKm": 11.2,
                  "estimatedTimeMin": 54,
                  "schedule": [{ "colisId": "B", "arrivee": "2026-10-20T05:41:00Z" }],
                  "departAt": "2026-10-20T05:30:00Z",
                  "returnAt": null,
                  "lateStops": null,
                  "stopsCharged": 3,
                  "stopsBalance": 1997
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": {
            "description": "Insufficient stop balance. Buy a pack.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Error" },
                    {
                      "type": "object",
                      "properties": {
                        "required": { "type": "integer", "description": "Stops needed for this call." },
                        "balance": { "type": "integer", "description": "Current stop balance." }
                      }
                    }
                  ]
                },
                "example": { "error": "insufficient_stops", "message": "Insufficient stop balance: buy a pack.", "required": 40, "balance": 12 }
              }
            }
          },
          "422": {
            "description": "An address could not be geocoded. Nothing is charged.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "geocoding_failed", "message": "Address not found." }
              }
            }
          },
          "429": {
            "description": "Sandbox rate limit reached (test keys only).",
            "headers": { "Retry-After": { "schema": { "type": "integer" }, "description": "Seconds before retrying." } },
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/Error" },
                    {
                      "type": "object",
                      "properties": {
                        "limit": { "type": "integer" },
                        "resetAt": { "type": "string", "format": "date-time" }
                      }
                    }
                  ]
                }
              }
            }
          },
          "502": {
            "description": "Optimization failed. Nothing is charged.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "error": "optimization_failed", "message": "Optimization failed. No stop charged." }
              }
            }
          },
          "503": {
            "description": "Sandbox temporarily unavailable (test keys only).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/v1/balance": {
      "get": {
        "tags": ["Account"],
        "operationId": "getBalance",
        "summary": "Get stop balance",
        "description": "Returns the remaining stop balance for a live key, or the remaining sandbox quotas for a test key.",
        "responses": {
          "200": {
            "description": "Balance or sandbox quotas.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "title": "Live key",
                      "required": ["stopsBalance"],
                      "properties": { "stopsBalance": { "type": "integer", "description": "Remaining prepaid stops." } }
                    },
                    {
                      "type": "object",
                      "title": "Test key",
                      "required": ["sandbox", "sandboxStopsRemainingToday", "sandboxCallsRemainingThisMinute"],
                      "properties": {
                        "sandbox": { "type": "boolean", "const": true },
                        "sandboxStopsRemainingToday": { "type": "integer" },
                        "sandboxCallsRemainingThisMinute": { "type": "integer" }
                      }
                    }
                  ]
                },
                "examples": {
                  "live": { "value": { "stopsBalance": 1997 } },
                  "sandbox": { "value": { "sandbox": true, "sandboxStopsRemainingToday": 190, "sandboxCallsRemainingThisMinute": 19 } }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/api/v1/checkout": {
      "post": {
        "tags": ["Account"],
        "operationId": "buyStopPack",
        "summary": "Buy a stop pack",
        "description": "Creates a payment link for a prepaid pack. No API key needed: the live key is emailed after payment.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "packId"],
                "properties": {
                  "email": { "type": "string", "format": "email", "description": "Where the API key is sent after payment." },
                  "packId": { "type": "string", "enum": ["pack_2k", "pack_10k", "pack_50k", "pack_250k"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Payment URL.",
            "content": {
              "application/json": {
                "schema": { "type": "object", "required": ["url"], "properties": { "url": { "type": "string", "format": "uri" } } }
              }
            }
          },
          "400": { "$ref": "#/components/responses/InvalidRequest" },
          "503": {
            "description": "Payment unavailable.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key sent as `Authorization: Bearer otg_live_…` (production) or `otg_test_…` (sandbox)."
      }
    },
    "responses": {
      "InvalidRequest": {
        "description": "Invalid request body.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "invalid_request", "message": "At least 2 stops required" }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, invalid or revoked API key.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "error": "unauthorized", "message": "Invalid or revoked API key" }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": { "type": "string", "description": "Machine-readable error code." },
          "message": { "type": "string", "description": "Human-readable explanation." }
        }
      },
      "Location": {
        "description": "A place, given either as coordinates or as a full address to geocode (addresses are not accepted with test keys).",
        "oneOf": [
          {
            "type": "object",
            "title": "Coordinates",
            "required": ["latitude", "longitude"],
            "properties": {
              "latitude": { "type": "number", "minimum": -90, "maximum": 90 },
              "longitude": { "type": "number", "minimum": -180, "maximum": 180 }
            }
          },
          {
            "type": "object",
            "title": "Address",
            "required": ["street", "postalCode", "city"],
            "properties": {
              "street": { "type": "string" },
              "postalCode": { "type": "string" },
              "city": { "type": "string" }
            }
          }
        ]
      },
      "Stop": {
        "allOf": [
          { "$ref": "#/components/schemas/Location" },
          {
            "type": "object",
            "required": ["id"],
            "properties": {
              "id": { "type": "string", "maxLength": 100, "description": "Your identifier, unique within the call." },
              "deadline": {
                "type": "string",
                "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
                "description": "Latest delivery time, `HH:MM`, Europe/Paris local time."
              },
              "serviceSeconds": { "type": "number", "minimum": 0, "maximum": 3600, "description": "Time spent at the stop, in seconds." }
            }
          }
        ]
      },
      "OptimizeRequest": {
        "type": "object",
        "required": ["start", "stops"],
        "properties": {
          "start": { "$ref": "#/components/schemas/Location" },
          "end": { "$ref": "#/components/schemas/Location", "description": "Optional return point." },
          "stops": {
            "type": "array",
            "minItems": 2,
            "maxItems": 200,
            "items": { "$ref": "#/components/schemas/Stop" }
          },
          "avoidTolls": { "type": "boolean", "default": false },
          "departureTime": { "type": "string", "format": "date-time", "description": "Departure time, ISO 8601." }
        }
      },
      "OptimizeResponse": {
        "type": "object",
        "required": ["order", "waypoints", "totalDistanceKm", "estimatedTimeMin", "stopsCharged"],
        "properties": {
          "sandbox": { "type": "boolean", "description": "Present and true for test keys." },
          "order": { "type": "array", "items": { "type": "string" }, "description": "Stop ids in the optimal visiting order." },
          "waypoints": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "order": { "type": "integer" },
                "latitude": { "type": ["number", "null"] },
                "longitude": { "type": ["number", "null"] }
              }
            }
          },
          "totalDistanceKm": { "type": "number" },
          "estimatedTimeMin": { "type": "number" },
          "schedule": {
            "type": ["array", "null"],
            "items": {
              "type": "object",
              "properties": {
                "colisId": { "type": "string", "description": "Stop id." },
                "arrivee": { "type": "string", "format": "date-time", "description": "Estimated arrival time." }
              }
            }
          },
          "departAt": { "type": ["string", "null"], "format": "date-time" },
          "returnAt": { "type": ["string", "null"], "format": "date-time" },
          "lateStops": {
            "type": ["array", "null"],
            "description": "Stops whose estimated arrival is after their deadline.",
            "items": {
              "type": "object",
              "properties": {
                "colisId": { "type": "string" },
                "arrivee": { "type": "string" },
                "limite": { "type": "string" },
                "retardMin": { "type": "number" }
              }
            }
          },
          "stopsCharged": { "type": "integer", "description": "Stops debited by this call (0 in sandbox)." },
          "stopsBalance": { "type": "integer", "description": "Remaining balance (live keys only)." },
          "sandboxStopsRemainingToday": { "type": "integer", "description": "Remaining daily quota (test keys only)." }
        }
      }
    }
  }
}
