{
  "openapi": "3.1.0",
  "info": {
    "title": "Modan African Currency Data API",
    "description": "African FX and provider pricing data — hourly-refreshed on free/pro keys, real-time on enterprise (Team) keys. Provider-level price discovery across corridors like GBP/NGN, USD/KES and USD/XOF: latest rates, conversion, historical time series, corridor coverage and provider metadata. Authenticate with an X-API-Key header — sign up free at https://modan.io/signup and your first key is minted automatically. spread_bps is the distance in basis points below the best current rate on the corridor from the same provider_type and the same rate_type (not vs an independent mid-market rate): IMTO, fintech, bank and central-bank prices behave differently, so they are never ranked against each other; rate_type distinguishes official, interbank, retail, p2p and parallel prices, which are not substitutes for one another. The rate reads (/rates, /convert, /corridors, /rates/history, /time-series, /historical, /change and /fetch-*) take an optional provider_type filter (one type, or a comma-separated list); omitted, every type is returned. A quote not checked for 24 hours (weekday hours for commercial and central banks) is returned with stale: true and a null spread_bps and is never a best rate; a quote not checked for 7 days is not returned. AI agents: a native MCP server (streamable HTTP) exposes the same data as tools at https://modan.io/api/mcp — see https://modan.io/llms-full.txt for the complete LLM-readable reference.",
    "version": "1.0.0",
    "contact": {
      "name": "Modan",
      "url": "https://modan.io"
    }
  },
  "servers": [
    {
      "url": "https://modan.io/api/v1"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "paths": {
    "/rates": {
      "get": {
        "operationId": "getRates",
        "summary": "Latest provider rates for a currency corridor",
        "description": "Every tracked provider's latest quote for the corridor. A quote not checked for 24 hours is returned with stale: true and a null spread_bps and is never ranked; a quote not checked for 7 days is left out. Commercial and central banks age on a weekday clock. provider_type narrows the quotes to those provider types; spread_bps is measured within each provider_type and rate_type, so narrowing never changes it. Free and Individual keys are served as of the top of the current UTC hour (data_freshness, as_of).",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            },
            "description": "Source currency code"
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            },
            "description": "Target currency code"
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Current rates from all active providers covering the corridor",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RatesResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "summary": "Ingest rate observations (data-team only)",
        "description": "Record provider rate observations. Requires an API key whose owning account holds the admin or treasury role; regular data keys receive 403. Accepts one object or an array of up to 100. The batch is atomic: any invalid row rejects the whole request (422, per-row errors) and nothing is inserted. Ingestion does not consume the daily read quota.",
        "operationId": "ingestRates",
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/IngestRate"
                  },
                  {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/IngestRate"
                    },
                    "maxItems": 100
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "All rows inserted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "inserted": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Malformed body"
          },
          "401": {
            "description": "API key missing, invalid, or revoked"
          },
          "403": {
            "description": "Key's account lacks the admin/treasury role"
          },
          "422": {
            "description": "Validation failed; nothing inserted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "errors": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "index": {
                            "type": "integer"
                          },
                          "error": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/rates/history": {
      "get": {
        "operationId": "getRateHistory",
        "summary": "Historical rate time series for a corridor",
        "description": "Timestamped provider observations, paginated. Each carries provider_type and a spread_bps measured within its provider_type and rate_type in the same minute. With provider_type the filter runs before paging, so limit, offset and has_more page through the filtered history.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1d",
                "7d",
                "30d",
                "90d"
              ],
              "default": "30d"
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "example": "wise"
            },
            "description": "Filter to a single provider id"
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "asc"
            },
            "description": "Chronological order; asc (oldest first) suits time-series charting"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 500,
              "maximum": 5000
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 0
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Timestamped rate observations (paginated; has_more indicates further pages)",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HistoryResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/convert": {
      "get": {
        "operationId": "convert",
        "summary": "Convert an amount across a corridor, per provider (net of fees)",
        "description": "best is the entry with the highest net_converted among executable, non-stale quotes of every provider type returned, or null when none qualifies; best_by_type answers the same question within each provider type. Stale quotes are returned, flagged and never chosen. provider_type narrows the quotes to those types.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "name": "amount",
            "in": "query",
            "required": true,
            "schema": {
              "type": "number",
              "example": 1000
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Per-provider converted and net-of-fee amounts, the best value, and the mid-converted amount when available",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConvertResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/currencies": {
      "get": {
        "operationId": "getCurrencies",
        "summary": "Active currencies and the corridors currently served",
        "responses": {
          "200": {
            "description": "Active currencies plus the distinct corridors with live rates",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CurrenciesResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/corridors": {
      "get": {
        "operationId": "getCorridors",
        "summary": "All covered currency corridors with current provider counts and best rates",
        "description": "provider_count counts current quotes and stale_count the stale ones. best_rate is the best current executable rate across the provider types returned and best_rate_by_type the best within each; they, and avg_spread_bps, describe current executable quotes only and are null when there are none. With provider_type, only corridors those types quote are returned, each counted over their quotes alone. A corridor with nothing checked in 7 days is omitted.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Corridor summaries",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CorridorsResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/providers": {
      "get": {
        "operationId": "getProviders",
        "summary": "All active providers with metadata",
        "description": "The whole active catalogue, each with its provider_type and rate_type. Takes no provider_type filter.",
        "responses": {
          "200": {
            "description": "Active providers",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProvidersResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/fetch-one": {
      "get": {
        "operationId": "fetchOne",
        "summary": "Mid-market rate for one pair, plus the best current executable provider quote when there is one",
        "description": "provider_best is the best current executable provider quote for each pair (interbank, retail or p2p, checked within 24 hours) among the provider types returned, every type unless provider_type narrows it, and null when the pair is not covered or no quote qualifies. The mid never depends on provider_type.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "USD"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Mid + provider_best for the pair",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FetchOneResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/fetch-multi": {
      "get": {
        "operationId": "fetchMulti",
        "summary": "One base against up to 20 quote currencies in a single call (one quota unit)",
        "description": "provider_best is the best current executable provider quote for each pair (interbank, retail or p2p, checked within 24 hours) among the provider types returned, every type unless provider_type narrows it, and null when the pair is not covered or no quote qualifies. The mid never depends on provider_type.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "USD"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN,KES,GHS",
              "description": "Comma-separated ISO-4217 codes"
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Per-quote mid + provider_best",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FetchMultiResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/fetch-matrix": {
      "get": {
        "operationId": "fetchMatrix",
        "summary": "Full cross matrix, up to 10 bases x 10 quotes (one quota unit)",
        "description": "provider_best is the best current executable provider quote for each pair (interbank, retail or p2p, checked within 24 hours) among the provider types returned, every type unless provider_type narrows it, and null when the pair is not covered or no quote qualifies. The mid never depends on provider_type.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "USD,GBP",
              "description": "Comma-separated ISO-4217 codes (max 10)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN,KES",
              "description": "Comma-separated ISO-4217 codes (max 10)"
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Nested base->quote map of mid + provider_best",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FetchMatrixResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/fetch-many-to-one": {
      "get": {
        "operationId": "fetchManyToOne",
        "summary": "Many base currencies into a single quote currency (one quota unit)",
        "description": "provider_best is the best current executable provider quote for each pair (interbank, retail or p2p, checked within 24 hours) among the provider types returned, every type unless provider_type narrows it, and null when the pair is not covered or no quote qualifies. The mid never depends on provider_type.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "USD,GBP,CAD",
              "description": "Comma-separated ISO-4217 codes (max 20)"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Per-base mid + provider_best into the quote currency",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FetchManyToOneResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/time-series": {
      "get": {
        "operationId": "getTimeSeries",
        "summary": "Bucketed daily/hourly series: the mid and the best current executable quote at each bucket's end",
        "description": "Each point describes the END of its bucket: the end of the day or hour, or the end of the window for the last, partial one (never later than as_of on free and Individual keys). best is the highest quote that was current and executable at that moment: each provider's latest price at or before it, counted only when it had been checked (last_checked) within the 24 hours before, and only for rate_type interbank, retail or p2p. Commercial-bank and central-bank quotes (provider_type commercial_bank or central_bank) age on the weekday clock, so Saturday and Sunday (UTC) hours do not count. A provider holding a steady price counts although it recorded no new price in the bucket; official and parallel prints never count. samples is how many quotes best was taken from. A bucket is listed while the corridor has any quote checked within 7 days of its end; best is null, and samples 0, when none was current and executable. provider_type narrows best and samples to those provider types; the mid never depends on it.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "name": "interval",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "P1D",
                "PT1H"
              ],
              "default": "P1D"
            },
            "description": "ISO-8601 bucket size: P1D daily (max 366 buckets) or PT1H hourly (max 168)"
          },
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1d",
                "7d",
                "30d",
                "90d"
              ],
              "default": "30d"
            }
          },
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Explicit window start (overrides period)"
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Explicit window end (defaults to now)"
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Series of { t, mid, best, samples } buckets",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TimeSeriesResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/historical": {
      "get": {
        "operationId": "getHistorical",
        "summary": "Corridor snapshot as of a past date",
        "description": "The /rates shape judged at the end of the requested date. provider_type narrows the quotes to those provider types.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "name": "date",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-07-01"
            },
            "description": "As-of date (YYYY-MM-DD, UTC, not in the future)"
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Each provider's latest quote set on or before the date, judged stale or left out as of that date, plus mid when covered",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HistoricalResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/change": {
      "get": {
        "operationId": "getChange",
        "summary": "Absolute and percent change of mid + best rate over a period",
        "description": "Start and end values for the mid and for the best executable quote that was current at each end. provider_type narrows best to those provider types; the mid never depends on it.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "GBP"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "NGN"
            }
          },
          {
            "name": "period",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1d",
                "7d",
                "30d",
                "90d"
              ],
              "default": "7d"
            }
          },
          {
            "$ref": "#/components/parameters/ProviderType"
          }
        ],
        "responses": {
          "200": {
            "description": "Start/end values and change legs; best at each end is the best executable quote that was current at that moment",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChangeResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequestProviderType"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/rates/provider": {
      "get": {
        "operationId": "getProviderRates",
        "summary": "Every corridor and current rate one provider quotes",
        "description": "Each corridor carries stale: true when this provider's quote on it has not been checked for 24 hours; corridors not checked for 7 days are left out. Takes no provider_type: the provider named has one type.",
        "parameters": [
          {
            "name": "provider",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "example": "lemfi"
            },
            "description": "A provider_id from GET /providers"
          }
        ],
        "responses": {
          "200": {
            "description": "Provider metadata + all quoted corridors",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProviderRatesResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              },
              "X-Data-Freshness": {
                "$ref": "#/components/headers/XDataFreshness"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Unknown or inactive provider",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/admin/usage": {
      "get": {
        "operationId": "getUsage",
        "summary": "Your account's usage, quota and per-key breakdown (does not consume quota)",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Metering state for the calling account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UsageResponse"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/XRateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/XRateLimitRemaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/XRateLimitReset"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/status": {
      "get": {
        "operationId": "getStatus",
        "summary": "Public platform health (no API key, never consumes quota)",
        "security": [],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Platform health snapshot",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatusResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Create a free API key at https://modan.io/signup (terminal → Developers → My Keys)."
      }
    },
    "parameters": {
      "ProviderType": {
        "name": "provider_type",
        "in": "query",
        "required": false,
        "style": "form",
        "explode": false,
        "schema": {
          "type": "array",
          "items": {
            "$ref": "#/components/schemas/ProviderTypeId"
          }
        },
        "example": [
          "imto",
          "fintech_psp"
        ],
        "description": "Only quotes from providers of these types: one id, or several separated by commas (provider_type=imto,fintech_psp). Omit it for every type, including providers with no type recorded. Ids are lower-case (other cases are accepted), duplicates are ignored, and the types applied are echoed in taxonomy order as provider_types. An id the taxonomy does not define is refused with 400 and the ids in invalid, before the API key is checked, so the request spends no quota. Narrowing never changes a returned quote's spread_bps, which is always measured within its own provider_type and rate_type. A best taken across types (best on /convert, best_rate on /corridors, provider_best on /fetch-*, best on /change and /time-series) covers only the types returned. The independent mid never depends on it."
      }
    },
    "headers": {
      "XRateLimitLimit": {
        "description": "Your plan's daily request allowance (free 50 / pro 250 / enterprise 1,000).",
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitRemaining": {
        "description": "Requests remaining in today's UTC window.",
        "schema": {
          "type": "integer"
        }
      },
      "XRateLimitReset": {
        "description": "Epoch seconds at which the daily window resets (next UTC midnight).",
        "schema": {
          "type": "integer"
        }
      },
      "XDataFreshness": {
        "description": "hourly = served from the top-of-the-current-UTC-hour snapshot (free/pro keys); realtime = live data (enterprise keys).",
        "schema": {
          "type": "string",
          "enum": [
            "hourly",
            "realtime"
          ]
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Missing required query parameters",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "API key missing, invalid, or revoked",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Daily rate limit exceeded for your tier (free 50/day, pro 250/day, enterprise 1,000/day). Data freshness is also tiered: free/pro serve rates as of the top of the current UTC hour (data_freshness: hourly + as_of in responses); enterprise is real-time.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "BadRequestProviderType": {
        "description": "Missing or invalid query parameters; the error message says which. An unknown provider_type returns ProviderTypeError, with the refused values in invalid. It is answered before the API key is checked, so it spends no quota.",
        "content": {
          "application/json": {
            "schema": {
              "anyOf": [
                {
                  "$ref": "#/components/schemas/ProviderTypeError"
                },
                {
                  "$ref": "#/components/schemas/Error"
                }
              ]
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      },
      "ProviderRate": {
        "type": "object",
        "properties": {
          "provider_id": {
            "type": "string",
            "example": "wise"
          },
          "provider_name": {
            "type": "string",
            "example": "Wise"
          },
          "rate": {
            "type": "number",
            "example": 2045.5
          },
          "fee": {
            "type": [
              "number",
              "null"
            ],
            "example": 2.99
          },
          "fee_currency": {
            "type": [
              "string",
              "null"
            ],
            "example": "GBP"
          },
          "spread_bps": {
            "type": [
              "number",
              "null"
            ],
            "example": 24.5,
            "description": "Basis points below the best CURRENT quote returned for the corridor from the same provider_type AND the same rate_type (0 = best of its kind): an IMTO is measured against IMTOs, a fintech against fintechs, a bank's interbank print against banks' interbank prints, never across kinds. Until 28 Sep 2026 it was measured within rate_type across every provider type. null when this quote is stale: a stale quote is shown but not ranked, and it never sets the benchmark the others are measured against. A provider_type filter never changes it. Not a spread against the independent mid."
          },
          "stale": {
            "type": "boolean",
            "example": false,
            "description": "True when this quote has not been checked (last_checked) for 24 hours before the moment the response describes: now on Team keys, as_of on free and Individual keys, the requested date on /historical. Commercial-bank and central-bank quotes (provider_type commercial_bank or central_bank) age on a weekday clock: Saturday and Sunday (UTC) hours do not count, so Friday's close stays current through the weekend, while a missed weekday update still goes stale after 24 weekday hours. A stale quote is returned so you can see it, with spread_bps null, and it is never a corridor's best rate or the best value on /convert. A quote not checked for 7 days, on the same clock, is not returned at all."
          },
          "vs_mid_bps": {
            "type": "number",
            "example": -12.3,
            "description": "Basis points vs the independent mid (negative = below mid). Present only when a recent reference mid exists for the corridor."
          },
          "transfer_time": {
            "type": [
              "string",
              "null"
            ],
            "example": "1 - 2 business days"
          },
          "last_checked": {
            "type": "string",
            "format": "date-time",
            "description": "When we last confirmed this rate was still being quoted, whether or not the value moved. Read this to judge whether the data is current; stale is judged on it. On free and Individual keys a confirmation made after as_of is reported as as_of itself (the quote was provably still on offer then), so it is never later than as_of."
          },
          "last_changed": {
            "type": "string",
            "format": "date-time",
            "description": "When the quote last changed: its rate moved, or its fee changed. A steady corridor can have an old last_changed and a recent last_checked — that means the price is flat, not that the feed is stale."
          },
          "last_updated": {
            "type": "string",
            "format": "date-time",
            "deprecated": true,
            "description": "Deprecated alias of last_changed, kept so existing clients do not break. Read last_checked for freshness."
          },
          "rate_type": {
            "type": "string",
            "enum": [
              "official",
              "interbank",
              "retail",
              "p2p",
              "parallel"
            ],
            "description": "What KIND of price this is. Ranking across kinds is meaningless: a central bank's official reference is real and not obtainable, so it is never the corridor's best rate and never the best value on /convert. Absent means retail."
          },
          "provider_type": {
            "$ref": "#/components/schemas/Provider/properties/provider_type",
            "description": "What kind of institution published the price; null when the provider has no type recorded. spread_bps is measured within it. It also decides the staleness clock: commercial_bank and central_bank quotes age on a weekday clock (see stale)."
          }
        }
      },
      "RatesResponse": {
        "type": "object",
        "properties": {
          "corridor": {
            "type": "string",
            "example": "GBP/NGN"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProviderRate"
            }
          },
          "count": {
            "type": "integer"
          },
          "stale_count": {
            "type": "integer",
            "description": "How many entries in providers are stale (see ProviderRate.stale). They are included in count."
          },
          "mid_rate": {
            "type": "number",
            "description": "Independent mid-market rate for the corridor. Present only when a recent reference mid exists."
          },
          "mid_source": {
            "type": "string",
            "example": "open.er-api.com"
          },
          "mid_fetched_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "ConvertProviderEntry": {
        "type": "object",
        "properties": {
          "provider_id": {
            "type": "string"
          },
          "provider_name": {
            "type": "string"
          },
          "rate": {
            "type": "number"
          },
          "fee": {
            "type": [
              "number",
              "null"
            ]
          },
          "fee_currency": {
            "type": [
              "string",
              "null"
            ]
          },
          "converted": {
            "type": "number",
            "description": "amount * rate, before fees, in the target currency"
          },
          "net_converted": {
            "type": "number",
            "description": "converted minus fee, in the target currency"
          },
          "spread_bps": {
            "type": [
              "number",
              "null"
            ],
            "description": "Basis points below the best CURRENT quote returned for the corridor from the same provider_type AND the same rate_type (0 = best of its kind): an IMTO is measured against IMTOs, a fintech against fintechs, a bank's interbank print against banks' interbank prints, never across kinds. Until 28 Sep 2026 it was measured within rate_type across every provider type. null when this quote is stale: a stale quote is shown but not ranked, and it never sets the benchmark the others are measured against. A provider_type filter never changes it. Not a spread against the independent mid."
          },
          "vs_mid_bps": {
            "type": "number",
            "description": "Basis points vs the independent mid (negative = below mid). Present only when a recent reference mid exists for the corridor."
          },
          "rate_type": {
            "type": "string",
            "enum": [
              "official",
              "interbank",
              "retail",
              "p2p",
              "parallel"
            ],
            "description": "What KIND of price this is. Ranking across kinds is meaningless: a central bank's official reference is real and not obtainable, so it is never the corridor's best rate and never the best value on /convert. Absent means retail."
          },
          "provider_type": {
            "$ref": "#/components/schemas/Provider/properties/provider_type",
            "description": "What kind of institution published the price; null when the provider has no type recorded. spread_bps is measured within it, and best_by_type groups by it."
          },
          "executable": {
            "type": "boolean",
            "description": "Whether a caller could deal at this price: true for interbank, retail and p2p; false for official and parallel prints, which are returned and labelled but never chosen as best."
          },
          "stale": {
            "type": "boolean",
            "description": "True when this quote has not been checked (last_checked) for 24 hours before the moment the response describes: now on Team keys, as_of on free and Individual keys. Commercial-bank and central-bank quotes (provider_type commercial_bank or central_bank) age on a weekday clock: Saturday and Sunday (UTC) hours do not count, so Friday's close stays current through the weekend, while a missed weekday update still goes stale after 24 weekday hours. A stale quote is returned so you can see it, with spread_bps null, and it is never chosen as best. A quote not checked for 7 days, on the same clock, is not returned at all."
          },
          "last_checked": {
            "type": "string",
            "format": "date-time",
            "description": "When we last confirmed this rate was still being quoted, whether or not the value moved. Read this to judge whether the data is current; stale is judged on it. On free and Individual keys a confirmation made after as_of is reported as as_of itself (the quote was provably still on offer then), so it is never later than as_of."
          },
          "last_changed": {
            "type": "string",
            "format": "date-time",
            "description": "When the quote last changed: its rate moved, or its fee changed. A steady corridor can have an old last_changed and a recent last_checked — that means the price is flat, not that the feed is stale."
          }
        }
      },
      "ConvertResponse": {
        "type": "object",
        "properties": {
          "from": {
            "type": "string",
            "example": "GBP"
          },
          "to": {
            "type": "string",
            "example": "NGN"
          },
          "amount": {
            "type": "number",
            "example": 1000
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "mid": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "rate": {
                "type": "number"
              },
              "converted": {
                "type": "number"
              },
              "source": {
                "type": "string"
              },
              "fetched_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "best": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ConvertProviderEntry"
              },
              {
                "type": "null"
              }
            ],
            "description": "The entry with the highest net_converted among quotes a caller could deal on today, across every provider type returned: executable (interbank, retail or p2p) and not stale. Never an official or parallel print, never a stale quote. null when no quote qualifies. It compares kinds of provider with each other; for a like-for-like answer read best_by_type, or narrow with provider_type."
          },
          "best_by_type": {
            "type": "object",
            "propertyNames": {
              "$ref": "#/components/schemas/ProviderTypeKey"
            },
            "additionalProperties": {
              "type": [
                "string",
                "null"
              ]
            },
            "example": {
              "imto": null,
              "fintech_psp": "lemfi"
            },
            "description": "The question best answers, asked within each provider type present among the returned quotes: the provider_id delivering the highest net_converted among that type's current executable quotes, or null when the type is present but none qualifies (a central bank's official print, or a type whose only quotes are stale). Keys follow taxonomy order; unclassified groups providers with no type recorded. {} when no quote is returned."
          },
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ConvertProviderEntry"
            }
          },
          "count": {
            "type": "integer"
          },
          "stale_count": {
            "type": "integer",
            "description": "How many entries in providers are stale (see ConvertProviderEntry.stale). They are included in count."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "CurrenciesResponse": {
        "type": "object",
        "properties": {
          "currencies": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "code": {
                  "type": "string",
                  "example": "NGN"
                },
                "name": {
                  "type": "string",
                  "example": "Nigerian Naira"
                },
                "type": {
                  "type": "string",
                  "enum": [
                    "source",
                    "target",
                    "both"
                  ]
                },
                "flag_url": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "is_active": {
                  "type": "boolean"
                },
                "sort_order": {
                  "type": "integer"
                }
              }
            }
          },
          "corridors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "from": {
                  "type": "string"
                },
                "to": {
                  "type": "string"
                }
              }
            }
          },
          "currency_count": {
            "type": "integer"
          },
          "corridor_count": {
            "type": "integer"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "HistoryPoint": {
        "type": "object",
        "properties": {
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "provider_id": {
            "type": "string"
          },
          "provider_name": {
            "type": "string"
          },
          "rate": {
            "type": "number"
          },
          "fee": {
            "type": [
              "number",
              "null"
            ]
          },
          "fee_currency": {
            "type": [
              "string",
              "null"
            ]
          },
          "spread_bps": {
            "type": [
              "number",
              "null"
            ],
            "description": "Basis points below the best rate set in the same minute, among the observations returned, by a provider of the same provider_type AND the same rate_type (0 = best of its kind). Until 28 Sep 2026 it was measured within rate_type only. History is not judged for staleness: it records what was quoted at the time. null only for a rate that is not positive. Not a spread against the independent mid."
          },
          "rate_type": {
            "type": "string",
            "enum": [
              "official",
              "interbank",
              "retail",
              "p2p",
              "parallel"
            ],
            "description": "What KIND of price this is. Ranking across kinds is meaningless: a central bank's official reference is real and not obtainable, so it is never the corridor's best rate and never the best value on /convert. Absent means retail."
          },
          "provider_type": {
            "$ref": "#/components/schemas/Provider/properties/provider_type",
            "description": "What kind of institution published the price; null when the provider has no type recorded. spread_bps is measured within it."
          }
        }
      },
      "HistoryResponse": {
        "type": "object",
        "properties": {
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "corridor": {
            "type": "string",
            "example": "GBP/NGN"
          },
          "period": {
            "type": "string",
            "example": "30d"
          },
          "from_date": {
            "type": "string",
            "format": "date-time"
          },
          "to_date": {
            "type": "string",
            "format": "date-time"
          },
          "order": {
            "type": "string",
            "enum": [
              "asc",
              "desc"
            ]
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "has_more": {
            "type": "boolean"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/HistoryPoint"
            }
          },
          "count": {
            "type": "integer"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "Corridor": {
        "type": "object",
        "properties": {
          "from": {
            "type": "string",
            "example": "GBP"
          },
          "to": {
            "type": "string",
            "example": "NGN"
          },
          "provider_count": {
            "type": "integer",
            "description": "Providers with a CURRENT quote on the corridor: checked within 24 hours (weekday hours for commercial and central banks). Stale quotes are counted in stale_count instead."
          },
          "stale_count": {
            "type": "integer",
            "description": "Providers whose quote on the corridor is stale: not checked for 24 hours, but checked within 7 days (weekday hours for commercial and central banks). Not included in provider_count, best_rate or avg_spread_bps."
          },
          "best_rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "The best current executable rate across the provider types returned (every type unless provider_type narrows it): the highest interbank, retail or p2p quote checked within 24 hours. It ranks kinds of provider against each other; for a like-for-like read use best_rate_by_type. null when the corridor has no such quote, because it carries only reference prints (official, parallel) or only stale quotes. Until 28 Sep 2026 this was 0 in that case; 0 is no longer returned."
          },
          "best_rate_by_type": {
            "type": "object",
            "propertyNames": {
              "$ref": "#/components/schemas/ProviderTypeKey"
            },
            "additionalProperties": {
              "type": [
                "number",
                "null"
              ]
            },
            "example": {
              "imto": null,
              "fintech_psp": 2050.5
            },
            "description": "The best current executable rate within each provider type quoting the corridor, keyed in taxonomy order; null for a type whose quotes are all reference prints or stale. The like-for-like read, since provider types are never ranked against each other. Its keys are exactly provider_types."
          },
          "provider_types": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProviderTypeKey"
            },
            "description": "Provider types with a quote on the corridor checked within the last 7 days, current or stale, in taxonomy order; unclassified for providers with no type recorded. On a filtered request, only the types asked for. Not the same list as the top-level provider_types, which echoes the filter."
          },
          "last_checked": {
            "type": "string",
            "format": "date-time",
            "description": "When we last confirmed this rate was still being quoted, whether or not the value moved. Read this to judge whether the data is current; stale is judged on it. On free and Individual keys a confirmation made after as_of is reported as as_of itself (the quote was provably still on offer then), so it is never later than as_of. The most recent across the corridor's quotes checked within 7 days."
          },
          "last_changed": {
            "type": "string",
            "format": "date-time",
            "description": "When the quote last changed: its rate moved, or its fee changed. A steady corridor can have an old last_changed and a recent last_checked — that means the price is flat, not that the feed is stale."
          },
          "last_updated": {
            "type": "string",
            "format": "date-time",
            "deprecated": true,
            "description": "Deprecated alias of last_changed, kept so existing clients do not break. Read last_checked for freshness."
          },
          "avg_spread_bps": {
            "type": [
              "number",
              "null"
            ],
            "description": "Average spread across the corridor's CURRENT executable quotes of the types returned, each measured within its own provider_type and rate_type. Non-executable prices (official, parallel) and stale quotes are excluded. null when there are none (0 until 28 Sep 2026)."
          }
        }
      },
      "CorridorsResponse": {
        "type": "object",
        "properties": {
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "corridors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Corridor"
            },
            "description": "Every corridor with at least one quote checked in the last 7 days (weekday hours for commercial and central banks), from the provider types asked for when provider_type is given. A corridor with nothing checked in that window is omitted."
          },
          "count": {
            "type": "integer"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "Provider": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "wise"
          },
          "name": {
            "type": "string",
            "example": "Wise"
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "provider_type": {
            "type": [
              "string",
              "null"
            ],
            "example": "imto",
            "enum": [
              "imto",
              "fintech_psp",
              "commercial_bank",
              "central_bank",
              "non_bank_lp",
              "bureau_de_change",
              "crypto_venue",
              "aggregator",
              null
            ],
            "description": "What kind of institution published the price, in the taxonomy's order. Orthogonal to rate_type. 'aggregator' republishes another source — derived, not observed. Quotes are only ever ranked against quotes of the same provider_type and rate_type. null when the provider has no type recorded."
          },
          "rate_type": {
            "type": "string",
            "enum": [
              "official",
              "interbank",
              "retail",
              "p2p",
              "parallel"
            ],
            "example": "retail",
            "description": "What KIND of price this provider publishes. Orthogonal to provider_type. official and parallel are not executable: they are returned and labelled, but never chosen as a corridor's best rate."
          },
          "region": {
            "type": [
              "string",
              "null"
            ]
          },
          "transfer_time": {
            "type": [
              "string",
              "null"
            ]
          },
          "payment_methods": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            }
          }
        }
      },
      "ProvidersResponse": {
        "type": "object",
        "properties": {
          "providers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Provider"
            }
          },
          "count": {
            "type": "integer"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "IngestRate": {
        "type": "object",
        "required": [
          "provider_id",
          "rate"
        ],
        "properties": {
          "provider_id": {
            "type": "string",
            "description": "Active provider id (see GET /providers)"
          },
          "from": {
            "type": "string",
            "description": "Alias for source_currency (ISO-4217)"
          },
          "to": {
            "type": "string",
            "description": "Alias for target_currency (ISO-4217)"
          },
          "source_currency": {
            "type": "string"
          },
          "target_currency": {
            "type": "string"
          },
          "rate": {
            "type": "number",
            "exclusiveMinimum": 0
          },
          "fee": {
            "type": "number",
            "minimum": 0
          },
          "fee_currency": {
            "type": "string",
            "description": "Defaults to the source currency when a fee is given"
          },
          "notes": {
            "type": "string",
            "maxLength": 500
          },
          "effective_from": {
            "type": "string",
            "format": "date-time",
            "description": "Defaults to now; max 5y backfill, no future timestamps"
          }
        }
      },
      "PairValue": {
        "type": "object",
        "description": "Mid-market rate for a pair plus the best current executable provider quote when the pair is a covered corridor and one qualifies",
        "properties": {
          "mid": {
            "type": "number",
            "description": "Independent mid-market rate (cross-computed through the freshest USD reference snapshot)"
          },
          "provider_best": {
            "type": [
              "object",
              "null"
            ],
            "description": "The best CURRENT EXECUTABLE provider quote for the pair among the provider types returned (every type unless provider_type narrows it): the highest interbank, retail or p2p rate checked within 24 hours (weekday hours for commercial and central banks). null when the pair is not a covered corridor, is a same-currency pair, or has no quote of those types that qualifies. Until 28 Sep 2026 this was a plain maximum that could name an official print or a quote unchecked for weeks.",
            "properties": {
              "rate": {
                "type": "number"
              },
              "provider_id": {
                "type": "string"
              },
              "last_checked": {
                "type": "string",
                "format": "date-time",
                "description": "When we last confirmed this rate was still being quoted, whether or not the value moved. Read this to judge whether the data is current; stale is judged on it. On free and Individual keys a confirmation made after as_of is reported as as_of itself (the quote was provably still on offer then), so it is never later than as_of."
              },
              "last_changed": {
                "type": "string",
                "format": "date-time",
                "description": "When the quote last changed: its rate moved, or its fee changed. A steady corridor can have an old last_changed and a recent last_checked — that means the price is flat, not that the feed is stale."
              },
              "last_updated": {
                "type": "string",
                "format": "date-time",
                "deprecated": true,
                "description": "Deprecated alias of last_changed, kept so existing clients do not break. Read last_checked for freshness."
              }
            }
          }
        },
        "required": [
          "mid",
          "provider_best"
        ]
      },
      "FetchOneResponse": {
        "type": "object",
        "properties": {
          "base": {
            "type": "string"
          },
          "quote": {
            "type": "string"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "mid": {
            "type": "number"
          },
          "provider_best": {
            "$ref": "#/components/schemas/PairValue/properties/provider_best"
          },
          "source": {
            "type": "string"
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "FetchMultiResponse": {
        "type": "object",
        "properties": {
          "base": {
            "type": "string"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "results": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/PairValue"
            }
          },
          "count": {
            "type": "integer"
          },
          "source": {
            "type": "string"
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "FetchMatrixResponse": {
        "type": "object",
        "properties": {
          "bases": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "quotes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "results": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "$ref": "#/components/schemas/PairValue"
              }
            }
          },
          "source": {
            "type": "string"
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "FetchManyToOneResponse": {
        "type": "object",
        "properties": {
          "quote": {
            "type": "string"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "results": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/PairValue"
            }
          },
          "count": {
            "type": "integer"
          },
          "source": {
            "type": "string"
          },
          "fetched_at": {
            "type": "string",
            "format": "date-time"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "TimeSeriesPoint": {
        "type": "object",
        "properties": {
          "t": {
            "type": "string",
            "format": "date-time",
            "description": "Bucket start (UTC). best and samples describe the bucket's END; mid is the last reference mid fetched within the bucket."
          },
          "mid": {
            "type": [
              "number",
              "null"
            ],
            "description": "Last reference mid in the bucket (null before the feed's history begins)"
          },
          "best": {
            "type": [
              "number",
              "null"
            ],
            "description": "The highest CURRENT EXECUTABLE quote standing at the end of the bucket (the window's end for the last bucket): each provider's latest price at or before that moment, counted when it had been checked within the 24 hours before it (weekday hours for commercial_bank and central_bank quotes) and its rate_type is interbank, retail or p2p. null when no quote qualified. Until 28 Sep 2026 this was the highest price recorded during the bucket, of any rate type. With provider_type, only quotes from those provider types count."
          },
          "samples": {
            "type": "integer",
            "description": "How many current executable quotes stood at the end of the bucket: the quotes best is the maximum of. 0 exactly when best is null. Until 28 Sep 2026 this counted the provider rows written during the bucket. With provider_type, only quotes from those provider types."
          }
        }
      },
      "TimeSeriesResponse": {
        "type": "object",
        "properties": {
          "corridor": {
            "type": "string"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "interval": {
            "type": "string",
            "enum": [
              "P1D",
              "PT1H"
            ]
          },
          "start": {
            "type": "string",
            "format": "date-time"
          },
          "end": {
            "type": "string",
            "format": "date-time"
          },
          "tracked_corridor": {
            "type": "boolean",
            "description": "True when the corridor had a provider quote of any rate type checked within 7 days of at least one bucket end in the window. False means the series is mid-market reference only, and a note says so. With provider_type, only quotes of those types count, so it can be false on a corridor that other provider types quote; the note says so."
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimeSeriesPoint"
            }
          },
          "count": {
            "type": "integer"
          },
          "note": {
            "type": "string",
            "description": "Present when the window was clamped, or when no provider of the types returned set a rate in the window (the series is then mid-market reference only)"
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "HistoricalResponse": {
        "description": "Same shape as /rates, judged at the requested moment: each provider's latest quote set on or before as_of, however long before it; stale means not checked for 24 hours before as_of (weekday hours for commercial and central banks), and a quote not checked for 7 days before as_of is left out. With provider_type, only those types, echoed in provider_types.",
        "allOf": [
          {
            "$ref": "#/components/schemas/RatesResponse"
          },
          {
            "type": "object",
            "properties": {
              "date": {
                "type": "string",
                "format": "date",
                "description": "Requested as-of date"
              },
              "as_of": {
                "type": "string",
                "format": "date-time",
                "description": "The moment the snapshot describes: the end of the requested UTC day (now, if the date is today), or the top of the current hour on a free or Individual key if that is earlier. Staleness is judged here, and no last_checked in the response is later than it."
              }
            }
          }
        ]
      },
      "ChangeLeg": {
        "type": "object",
        "properties": {
          "abs": {
            "type": [
              "number",
              "null"
            ]
          },
          "pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Percent change"
          }
        }
      },
      "ChangeResponse": {
        "type": "object",
        "properties": {
          "corridor": {
            "type": "string"
          },
          "provider_types": {
            "$ref": "#/components/schemas/RequestedProviderTypes"
          },
          "period": {
            "type": "string"
          },
          "start": {
            "type": "object",
            "properties": {
              "at": {
                "type": "string",
                "format": "date-time"
              },
              "mid": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "best": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "The best executable quote that was current at this moment among the provider types returned (every type unless provider_type narrows it): the highest interbank, retail or p2p rate among quotes checked within 24 hours before it (weekday hours for commercial and central banks). null when none qualifies."
              }
            }
          },
          "end": {
            "type": "object",
            "properties": {
              "at": {
                "type": "string",
                "format": "date-time"
              },
              "mid": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "best": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "The best executable quote that was current at this moment among the provider types returned (every type unless provider_type narrows it): the highest interbank, retail or p2p rate among quotes checked within 24 hours before it (weekday hours for commercial and central banks). null when none qualifies."
              }
            }
          },
          "change": {
            "type": "object",
            "properties": {
              "mid": {
                "$ref": "#/components/schemas/ChangeLeg"
              },
              "best": {
                "$ref": "#/components/schemas/ChangeLeg"
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "ProviderRatesResponse": {
        "type": "object",
        "properties": {
          "provider": {
            "$ref": "#/components/schemas/Provider"
          },
          "corridors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "from": {
                  "type": "string"
                },
                "to": {
                  "type": "string"
                },
                "rate": {
                  "type": "number"
                },
                "fee": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "fee_currency": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "stale": {
                  "type": "boolean",
                  "description": "True when this quote has not been checked (last_checked) for 24 hours before the moment the response describes: now on Team keys, as_of on free and Individual keys. Commercial-bank and central-bank quotes (provider_type commercial_bank or central_bank) age on a weekday clock: Saturday and Sunday (UTC) hours do not count, so Friday's close stays current through the weekend, while a missed weekday update still goes stale after 24 weekday hours. A stale quote is returned so you can see it and is never counted as a best rate. A corridor this provider has not had checked for 7 days, on the same clock, is not returned at all."
                },
                "last_checked": {
                  "type": "string",
                  "format": "date-time",
                  "description": "When we last confirmed this rate was still being quoted, whether or not the value moved. Read this to judge whether the data is current; stale is judged on it. On free and Individual keys a confirmation made after as_of is reported as as_of itself (the quote was provably still on offer then), so it is never later than as_of."
                },
                "last_changed": {
                  "type": "string",
                  "format": "date-time",
                  "description": "When the quote last changed: its rate moved, or its fee changed. A steady corridor can have an old last_changed and a recent last_checked — that means the price is flat, not that the feed is stale."
                },
                "last_updated": {
                  "type": "string",
                  "format": "date-time",
                  "deprecated": true,
                  "description": "Deprecated alias of last_changed, kept so existing clients do not break. Read last_checked for freshness."
                }
              }
            }
          },
          "count": {
            "type": "integer"
          },
          "stale_count": {
            "type": "integer",
            "description": "How many entries in corridors are stale. They are included in count."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          },
          "data_freshness": {
            "type": "string",
            "enum": [
              "hourly",
              "realtime"
            ],
            "description": "Freshness tier this response was served at: hourly (free/pro keys — rates as of the top of the current UTC hour) or realtime (enterprise keys)."
          },
          "as_of": {
            "type": "string",
            "format": "date-time",
            "description": "Present when data_freshness is hourly: the snapshot cutoff (top of the current UTC hour)."
          }
        }
      },
      "UsageResponse": {
        "type": "object",
        "description": "Account metering state. Calling this endpoint does not consume quota.",
        "properties": {
          "plan": {
            "type": "string",
            "enum": [
              "free",
              "pro",
              "enterprise"
            ]
          },
          "period_start": {
            "type": "string",
            "format": "date-time"
          },
          "period_end": {
            "type": "string",
            "format": "date-time"
          },
          "limit": {
            "type": "integer"
          },
          "used": {
            "type": "integer"
          },
          "remaining": {
            "type": "integer"
          },
          "reset": {
            "type": "integer",
            "description": "Epoch seconds of next UTC midnight"
          },
          "key": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "keys_today": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "key_id": {
                  "type": "string"
                },
                "name": {
                  "type": [
                    "string",
                    "null"
                  ]
                },
                "requests": {
                  "type": "integer"
                }
              }
            }
          },
          "daily_history": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "requests": {
                  "type": "integer"
                }
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "StatusResponse": {
        "type": "object",
        "description": "Public platform health. No API key required; never consumes quota.",
        "properties": {
          "status": {
            "type": "string"
          },
          "version": {
            "type": "string"
          },
          "corridors": {
            "type": "integer",
            "description": "Corridors with at least one quote checked in the last 7 days (weekday hours for commercial and central banks)."
          },
          "providers": {
            "type": "integer"
          },
          "last_rate_update": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The most recent check across every tracked quote (last_checked): the feed's liveness signal. null when there are no quotes."
          },
          "last_rate_change": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When any tracked rate last MOVED (the latest effective_from). Legitimately hours old on a calm day while the feed is healthy, so do not alert on it; watch last_rate_update."
          },
          "mid_feed": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "source": {
                "type": "string"
              },
              "last_fetched": {
                "type": "string",
                "format": "date-time"
              },
              "age_seconds": {
                "type": "integer"
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProviderTypeId": {
        "type": "string",
        "enum": [
          "imto",
          "fintech_psp",
          "commercial_bank",
          "central_bank",
          "non_bank_lp",
          "bureau_de_change",
          "crypto_venue",
          "aggregator"
        ],
        "description": "A provider type the taxonomy defines, in the order every response lists them: imto (international money transfer operators), fintech_psp (fintechs and payment service providers), commercial_bank, central_bank, non_bank_lp (non-bank liquidity providers), bureau_de_change, crypto_venue, aggregator (republishes another source's number rather than observing it)."
      },
      "ProviderTypeKey": {
        "type": "string",
        "enum": [
          "imto",
          "fintech_psp",
          "commercial_bank",
          "central_bank",
          "non_bank_lp",
          "bureau_de_change",
          "crypto_venue",
          "aggregator",
          "unclassified"
        ],
        "description": "A provider type as it appears in a by-type breakdown: one of the taxonomy's ids, or unclassified for providers with no type recorded. unclassified is not a filter value; provider_type cannot select it."
      },
      "RequestedProviderTypes": {
        "type": "array",
        "items": {
          "$ref": "#/components/schemas/ProviderTypeId"
        },
        "description": "Present only when the request named provider_type: the types applied, lower-cased, de-duplicated and in taxonomy order. Absent when no type was named, which means every type was returned."
      },
      "ProviderTypeError": {
        "type": "object",
        "description": "The 400 for an unknown provider_type. It is answered before the API key is checked, so the request spends no quota.",
        "properties": {
          "error": {
            "type": "string",
            "example": "Unknown provider_type: banks. Valid values: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator."
          },
          "invalid": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "banks"
            ],
            "description": "The provider_type values the taxonomy does not define, as sent: trimmed, and cut to 40 characters each."
          }
        },
        "required": [
          "error",
          "invalid"
        ]
      }
    }
  }
}
