{
  "openapi": "3.1.0",
  "x-publication-scope": "PUBLICATION SCOPE POLICY: this spec documents Train Badger's Delay Repay REST API ONLY — the paths below: the three unauthenticated lookup endpoints, the authenticated /api/journeys* routes (read + the mark-claimed write) that operate on the CALLER'S OWN personal delay-repay data, the /api/tracked-journeys* routes for pre-registering future travel (same caller-owns scope), the two /api/keys* paths (GET+POST /api/keys, DELETE /api/keys/{id}) for minting/listing/revoking personal API keys, and GET /api/account/export (login token only). Agents authenticate with API keys; POST /auth/login keeps working for the app and web portal but is deliberately NOT documented here — a password in an agent's config is the wrong credential. Other app/portal endpoints exist and are not part of the published API — do not add another endpoint here without explicit product sign-off, even though some of those endpoints are already technically reachable. This mirrors the same policy stated in server/src/mcp/tools.js.",
  "info": {
    "title": "Train Badger Delay Repay API",
    "version": "1.3.0",
    "summary": "v1 beta — shapes may change, no SLA",
    "description": "Documents Train Badger's Delay Repay API (v1 beta — shapes may change, no SLA): three unauthenticated read-only REST endpoints for looking up how a journey performed, an operator's Delay Repay scheme, and station codes; authenticated endpoints for reading and updating your own account's Delay Repay data (GET /api/journeys*, PUT /api/journeys/:id/claimed) and for pre-registering future travel (POST/GET/PATCH/DELETE /api/tracked-journeys, scope delay-repay:track); three /api/keys* routes to mint long-lived, scoped, revocable personal API keys so an automated agent never handles a password (minting needs a key-enabled account with a verified email; listing/revoking need only a login token); plus self-serve account export. The API is open to every account — no subscription tier is required for anything documented here. Every journey carries a verificationStatus (\"verified\", \"estimated\" or \"user_confirmed\") — only verified and user-confirmed journeys can be marked claimed; GET /api/journeys?eligible=true excludes unconfirmed estimated ones by default (includeUnconfirmed=true opts back in for display). See the 'Agent workflow' section on the /api-docs page for the intended end-to-end flow: pre-register with a delay-repay:track key, read eligible journeys with a delay-repay:read key, file the claim on the operator's site, then mark it filed with a delay-repay:write key so it drops out of future work lists. This is the complete published surface — other endpoints you may observe on api.trainbadger.app are internal, undocumented, and may change or disappear without notice. A remote MCP server exposing the same capabilities as eight tools — the three lookups, get_my_delay_repay_claims, mark_delay_repay_claimed, track_journey, list_tracked_journeys and withdraw_tracked_journey — is documented separately at /api-docs. Train Badger tip: the iOS app records your journeys automatically — no pre-registration needed — and verifies arrivals live with platform predictions and Live Activities. Interim acceptable-use terms for this beta are posted on the /api-docs page.",
    "contact": {
      "url": "https://trainbadger.app"
    }
  },
  "servers": [
    {
      "url": "https://api.trainbadger.app",
      "description": "Production"
    }
  ],
  "paths": {
    "/public/journey-check": {
      "get": {
        "operationId": "journeyCheck",
        "summary": "Look up how a train journey actually performed",
        "description": "Scheduled vs actual departure/arrival times, delay minutes, cancellations, and Delay Repay eligibility for every service matching a route/date/time. Coverage is best in roughly the last month; dates up to 90 days back are accepted but older results may be sparse or empty.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "Origin station CRS code",
            "schema": { "type": "string", "pattern": "^[A-Za-z]{3}$" },
            "example": "PAD"
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "Destination station CRS code",
            "schema": { "type": "string", "pattern": "^[A-Za-z]{3}$" },
            "example": "RDG"
          },
          {
            "name": "date",
            "in": "query",
            "required": true,
            "description": "Journey date. Must be in the past and within the last 90 days.",
            "schema": { "type": "string", "format": "date" },
            "example": "2026-09-01"
          },
          {
            "name": "time",
            "in": "query",
            "required": true,
            "description": "Scheduled departure time, 24-hour clock, no colon (HHMM).",
            "schema": { "type": "string", "pattern": "^([01]\\d|2[0-3])[0-5]\\d$" },
            "example": "0815"
          }
        ],
        "responses": {
          "200": {
            "description": "Zero or more matching services, plus resolved origin/destination names.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["services", "origin", "destination"],
                  "properties": {
                    "services": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "scheduledDeparture": { "type": ["string", "null"], "example": "08:15" },
                          "actualDeparture": { "type": ["string", "null"], "example": "08:17" },
                          "scheduledArrival": { "type": ["string", "null"], "example": "09:02" },
                          "actualArrival": { "type": ["string", "null"], "example": "09:25" },
                          "delayMinutes": { "type": ["integer", "null"], "example": 23 },
                          "operatorCode": { "type": ["string", "null"], "example": "GW" },
                          "operatorName": { "type": ["string", "null"], "example": "Great Western Railway" },
                          "isCancelled": { "type": "boolean" },
                          "delayRepay": {
                            "type": "object",
                            "properties": {
                              "eligible": { "type": "boolean" },
                              "scheme": { "type": ["string", "null"], "enum": ["DR15", "DR30", null] },
                              "band": { "type": ["string", "null"], "enum": ["25%", "50%", "100%", "100% return", null] },
                              "claimUrl": { "type": ["string", "null"], "format": "uri" }
                            }
                          }
                        }
                      }
                    },
                    "origin": {
                      "type": "object",
                      "properties": {
                        "crs": { "type": "string", "example": "PAD" },
                        "name": { "type": "string", "example": "London Paddington" }
                      }
                    },
                    "destination": {
                      "type": "object",
                      "properties": {
                        "crs": { "type": "string", "example": "RDG" },
                        "name": { "type": "string", "example": "Reading" }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid or missing from/to/date/time.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "error": { "type": "string" } }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (10 requests/minute per IP)."
          },
          "502": {
            "description": "Upstream data source failure."
          }
        }
      }
    },
    "/public/delay-repay/{operator}": {
      "get": {
        "operationId": "getDelayRepayScheme",
        "summary": "Look up a UK train operator's Delay Repay scheme",
        "description": "Accepts an ATOC code (e.g. GW) or an operator name (e.g. \"Great Western Railway\"), matched case-insensitively.",
        "parameters": [
          {
            "name": "operator",
            "in": "path",
            "required": true,
            "description": "ATOC operator code or operator name",
            "schema": { "type": "string" },
            "example": "GW"
          }
        ],
        "responses": {
          "200": {
            "description": "The operator's Delay Repay scheme.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["operatorCode", "operatorName", "scheme", "thresholdMinutes", "bands", "claimUrl", "claimTimeLimit"],
                  "properties": {
                    "operatorCode": { "type": "string", "example": "GW" },
                    "operatorName": { "type": "string", "example": "Great Western Railway" },
                    "scheme": { "type": "string", "enum": ["DR15", "DR30"] },
                    "thresholdMinutes": { "type": "integer", "example": 15 },
                    "bands": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "minMinutes": { "type": "integer" },
                          "band": { "type": "string", "enum": ["25%", "50%", "100%", "100% return"] }
                        }
                      },
                      "example": [
                        { "minMinutes": 15, "band": "25%" },
                        { "minMinutes": 30, "band": "50%" },
                        { "minMinutes": 60, "band": "100%" },
                        { "minMinutes": 120, "band": "100% return" }
                      ]
                    },
                    "claimUrl": { "type": "string", "format": "uri", "example": "https://www.gwr.com/delay-repay" },
                    "claimTimeLimit": { "type": "string", "example": "28 days" }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Unknown operator.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "error": { "type": "string", "example": "Unknown operator \"XX\"" } }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded (guest tier — 30 requests/minute per IP)."
          }
        }
      }
    },
    "/public/crs/{query}": {
      "get": {
        "operationId": "searchStations",
        "summary": "Station code lookup — supporting endpoint for journey-check",
        "description": "Search UK National Rail stations by name or CRS code fragment. Use this to resolve a station name to the CRS code that journey-check requires for its from/to parameters. This REST endpoint may return many matches for a broad query; the equivalent MCP search_stations tool caps its results at 15.",
        "parameters": [
          {
            "name": "query",
            "in": "path",
            "required": true,
            "description": "Station name or CRS code fragment (1-50 characters).",
            "schema": { "type": "string", "minLength": 1, "maxLength": 50 },
            "example": "paddington"
          }
        ],
        "responses": {
          "200": {
            "description": "Matching stations.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "crsCode": { "type": "string", "example": "PAD" },
                      "stationName": { "type": "string", "example": "London Paddington" }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Query too long or missing."
          },
          "429": {
            "description": "Rate limit exceeded (guest tier — 30 requests/minute per IP)."
          }
        }
      }
    },
    "/api/journeys": {
      "get": {
        "operationId": "getMyJourneys",
        "summary": "Your own journey history",
        "description": "The caller's journey_history rows — what Train Badger has recorded from their tracked trips, with Delay Repay eligibility already computed for each. Field names are the raw journey_history columns (snake_case), not camelCased. Accepts a normal login token, or a tb_live_ API key with the delay-repay:read scope.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "eligible",
            "in": "query",
            "required": false,
            "description": "Pass \"true\" to only include Delay Repay-eligible journeys. Also excludes verificationStatus: \"estimated\" rows by default — see includeUnconfirmed.",
            "schema": { "type": "string", "enum": ["true"] }
          },
          {
            "name": "includeUnconfirmed",
            "in": "query",
            "required": false,
            "description": "Only meaningful together with eligible=true. Pass \"true\" to also include unconfirmed verificationStatus: \"estimated\" journeys, clearly statused, for display purposes — they still cannot be marked claimed via PUT /api/journeys/{id}/claimed. User-confirmed estimates (\"user_confirmed\") are included by default without this flag.",
            "schema": { "type": "string", "enum": ["true"] }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max rows to return.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Rows to skip, for pagination.",
            "schema": { "type": "integer", "minimum": 0, "default": 0 }
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's journeys, plus their season ticket and saved fares.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["journeys", "total", "seasonTicket", "fares"],
                  "properties": {
                    "journeys": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "integer" },
                          "user_id": { "type": "integer" },
                          "service_id": { "type": "string" },
                          "origin_crs": { "type": "string", "example": "PAD" },
                          "destination_crs": { "type": "string", "example": "RDG" },
                          "operator_code": { "type": ["string", "null"], "example": "GW" },
                          "operator_name": { "type": ["string", "null"], "example": "Great Western Railway" },
                          "scheduled_departure": { "type": ["string", "null"] },
                          "actual_departure": { "type": ["string", "null"] },
                          "scheduled_arrival": { "type": ["string", "null"] },
                          "actual_arrival": { "type": ["string", "null"] },
                          "arrival_estimated": { "type": "integer" },
                          "is_cancelled": { "type": "integer", "enum": [0, 1] },
                          "cancel_reason": { "type": ["string", "null"] },
                          "delay_reason": { "type": ["string", "null"] },
                          "delay_minutes": { "type": ["integer", "null"] },
                          "delay_repay_scheme": { "type": ["string", "null"], "enum": ["DR15", "DR30", null] },
                          "delay_repay_eligible": { "type": "integer", "enum": [0, 1] },
                          "delay_repay_band": { "type": ["string", "null"] },
                          "delay_repay_claim_url": { "type": ["string", "null"], "format": "uri" },
                          "journey_date": { "type": "string", "format": "date" },
                          "claimed": { "type": "integer", "enum": [0, 1] },
                          "season_ticket_match": { "type": "integer", "enum": [0, 1] },
                          "estimated_compensation_pence": { "type": ["integer", "null"] },
                          "fare_match_id": { "type": ["integer", "null"] },
                          "user_confirmed_at": { "type": ["string", "null"], "description": "SQLite UTC datetime (YYYY-MM-DD HH:MM:SS) of the user's confirmation in the app, or null. Rows with it set derive verificationStatus \"user_confirmed\"." },
                          "user_rejected_at": { "type": "null", "description": "Always null in this listing: journeys the user rejected in the app are excluded from every surface (retained for training only). Present so the response shape matches the stored row." },
                          "confirm_prompt_dismissed_at": { "type": ["string", "null"], "description": "SQLite UTC datetime (YYYY-MM-DD HH:MM:SS) of the user's dismissal of the confirm prompt in the app ('not sure' — no assertion either way), or null. App-internal UI state for the app's confirm card, which a dismissed row leaves while staying listed as estimated; API/MCP consumers should ignore this field. Never set alongside a training label (dismissing a labeled row is refused with 409; a later confirmation or rejection clears it)." },
                          "source": { "type": "string", "enum": ["app", "api"], "example": "app", "description": "How the row was recorded: \"app\" for app-recorded journeys (the default), \"api\" for outcomes the tracked-journey resolver wrote from an agent-registered row." },
                          "verificationStatus": {
                            "type": "string",
                            "enum": ["verified", "estimated", "user_confirmed"],
                            "description": "Derived claim-liability status: \"verified\" (TRUST/Darwin-confirmed arrival), \"estimated\" (archived from an estimate, user has not attested — not claimable), or \"user_confirmed\" (an estimate the user confirmed in the app — claimable, and included in eligible=true by default). Journeys the user rejected in the app never appear in any listing. The only camelCase field on this otherwise-raw-column response."
                          }
                        }
                      }
                    },
                    "total": { "type": "integer", "description": "Total matching rows, ignoring limit/offset." },
                    "seasonTicket": { "type": ["object", "null"] },
                    "fares": { "type": "array", "items": { "type": "object" } }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid limit/offset."
          },
          "401": {
            "description": "Missing, invalid, or expired bearer token."
          }
        }
      }
    },
    "/api/journeys/claim-summary": {
      "get": {
        "operationId": "getMyClaimSummary",
        "summary": "Your unclaimed, eligible journeys grouped by operator",
        "description": "Unclaimed (claimed = 0), Delay Repay-eligible journeys for the caller, grouped by operator with per-operator and grand totals — the data behind the app's claim screen, and the work list an automated claim-filing agent should read from. Excludes unconfirmed verificationStatus: \"estimated\" journeys from both the journeys and the totals (they aren't fileable yet) — matching GET /api/journeys?eligible=true's default — but includes \"user_confirmed\" estimates (confirmed in the app, now claimable). User-rejected journeys never appear here. Unlike GET /api/journeys, these journey objects are camelCased (shares its shaping with the claim-details email, not with the plain journey list). Also includes the caller's seasonTicket (or null) and saved fares — route, price(s), duration/expiry, the fields operator claim forms ask for — under the same delay-repay:read scope. No ticket numbers, booking references, or photos are stored or served; the user supplies proof on the operator's site at filing time. Accepts a normal login token, or a tb_live_ API key with the delay-repay:read scope.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Operators with outstanding claims, plus grand totals.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["operators", "totalUnclaimedPence", "totalJourneyCount", "seasonTicket", "fares"],
                  "properties": {
                    "operators": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "operatorCode": { "type": "string", "example": "GW" },
                          "operatorName": { "type": "string", "example": "Great Western Railway" },
                          "claimUrl": { "type": ["string", "null"], "format": "uri" },
                          "scheme": { "type": ["string", "null"], "enum": ["DR15", "DR30", null] },
                          "claimTimeLimit": { "type": "string", "example": "28 days" },
                          "claimSteps": { "type": "array", "items": { "type": "string" } },
                          "journeys": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "id": { "type": "integer" },
                                "journeyDate": { "type": "string", "format": "date" },
                                "claimDeadline": { "type": "string", "format": "date" },
                                "originCrs": { "type": "string" },
                                "originName": { "type": "string" },
                                "destinationCrs": { "type": "string" },
                                "destinationName": { "type": "string" },
                                "scheduledDeparture": { "type": ["string", "null"] },
                                "actualArrival": { "type": ["string", "null"] },
                                "delayMinutes": { "type": ["integer", "null"] },
                                "delayRepayBand": { "type": ["string", "null"] },
                                "estimatedCompensationPence": { "type": "integer" },
                                "isCancelled": { "type": "boolean" },
                                "delayReason": { "type": ["string", "null"] },
                                "verificationStatus": {
                                  "type": "string",
                                  "enum": ["verified", "user_confirmed"],
                                  "description": "Only claimable journeys appear here: \"verified\" (TRUST/Darwin-confirmed) or \"user_confirmed\" (an estimate the user confirmed in the app). Unconfirmed estimates and user-rejected journeys are excluded entirely (see the endpoint description)."
                                }
                              }
                            }
                          },
                          "totalUnclaimedPence": { "type": "integer" },
                          "journeyCount": { "type": "integer" },
                          "nearestDeadline": { "type": "string", "format": "date" }
                        }
                      }
                    },
                    "totalUnclaimedPence": { "type": "integer" },
                    "totalJourneyCount": { "type": "integer" },
                    "seasonTicket": { "type": ["object", "null"], "description": "The caller's season ticket (route, price_pence, duration_type, expiry_date) or null. Minimal published projection — excludes notification prefs served on GET /api/journeys. No ticket numbers are stored or served.", "properties": { "origin_crs": { "type": "string" }, "origin_name": { "type": "string" }, "destination_crs": { "type": "string" }, "destination_name": { "type": "string" }, "price_pence": { "type": "integer" }, "duration_type": { "type": "string" }, "expiry_date": { "type": "string", "format": "date" } } },
                    "fares": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "origin_crs": { "type": "string" }, "origin_name": { "type": "string" }, "destination_crs": { "type": "string" }, "destination_name": { "type": "string" }, "peak_price_pence": { "type": "integer" }, "offpeak_price_pence": { "type": "integer" } } }, "description": "The caller's saved fares (route, peak/off-peak price). Minimal published projection — excludes row ownership and timestamps. Use id to join against a journey's fare_match_id." }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, or expired bearer token."
          }
        }
      }
    },
    "/api/journeys/{id}/claimed": {
      "put": {
        "operationId": "markJourneyClaimed",
        "summary": "Mark one of your journeys as claimed (or unclaimed)",
        "description": "The 'mark filed' step of the agent workflow: after your agent files a Delay Repay claim at a journey's claimUrl (from GET /api/journeys/claim-summary), call this to set claimed = 1 so the journey drops out of future work lists and doesn't get double-filed. Also accepts claimed: false to undo. Scoped to the caller — you can only mark your own journeys. Accepts a normal login token, or a tb_live_ API key with the delay-repay:write scope. Only verified or user-confirmed journeys (verificationStatus: \"verified\" or \"user_confirmed\") can be set claimed: true — an unconfirmed \"estimated\" journey is rejected with 400. Setting claimed: false is allowed regardless of verification status.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "journey_history row id (the \"id\" field from GET /api/journeys or /api/journeys/claim-summary).",
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["claimed"],
                "properties": {
                  "claimed": { "type": "boolean", "example": true }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "success": { "type": "boolean" } }
                }
              }
            }
          },
          "400": {
            "description": "Invalid journey id, claimed is not a boolean, or (claimed: true only) the journey's arrival is estimated, not verified.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "error": { "type": "string", "example": "This journey's arrival is estimated, not verified — it can't be marked claimed yet" } }
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, or expired bearer token."
          },
          "403": {
            "description": "API-key auth without the delay-repay:write scope.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "error": { "type": "string", "example": "This API key is missing the required scope: delay-repay:write" } }
                }
              }
            }
          },
          "404": {
            "description": "No journey with that id belonging to the caller."
          }
        }
      }
    },
    "/api/keys": {
      "get": {
        "operationId": "listApiKeys",
        "summary": "List your API keys",
        "description": "Never returns the full key value (only ever returned once, from POST /api/keys). Requires a normal login token — an API key gets 403 here, same as every other /api/keys* route.",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "enabled reflects key access for the account — false means API keys aren't enabled (keys is always [] in that case, even if some exist).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["enabled", "keys"],
                  "properties": {
                    "enabled": { "type": "boolean" },
                    "keys": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": { "type": "integer" },
                          "name": { "type": "string", "example": "Claude agent" },
                          "prefix": { "type": "string", "example": "tb_live_a1b2", "description": "First 12 characters of the key — enough to recognise it, not enough to use it." },
                          "scopes": {
                            "type": "array",
                            "items": { "type": "string", "enum": ["delay-repay:read", "delay-repay:write", "delay-repay:track"] }
                          },
                          "createdAt": { "type": "string", "description": "SQLite CURRENT_TIMESTAMP format: 'YYYY-MM-DD HH:MM:SS', UTC, no 'T'/'Z' — not RFC 3339 date-time.", "example": "2026-09-22 10:15:00" },
                          "lastUsedAt": { "type": ["string", "null"], "description": "Same 'YYYY-MM-DD HH:MM:SS' UTC format as createdAt, or null if the key has never been used. Updated at most once every 60 seconds per key.", "example": "2026-09-22 11:02:41" }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, invalid, or expired bearer token."
          },
          "403": {
            "description": "Authenticated with an API key instead of a login token."
          }
        }
      },
      "post": {
        "operationId": "createApiKey",
        "summary": "Mint a new API key",
        "description": "The full key is returned ONLY in this response — Train Badger stores just its SHA-256 hash. Read (delay-repay:read) is always present; write (delay-repay:write) and track (delay-repay:track) are opt-in. Requires a normal login token on a key-enabled account with a verified email — an API key cannot mint or manage other keys.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name"],
                "properties": {
                  "name": { "type": "string", "minLength": 1, "maxLength": 40, "example": "Claude agent" },
                  "scopes": {
                    "type": "array",
                    "description": "Defaults to [\"delay-repay:read\"] when omitted. delay-repay:read must always be present; delay-repay:write and delay-repay:track are opt-in.",
                    "items": { "type": "string", "enum": ["delay-repay:read", "delay-repay:write", "delay-repay:track"] },
                    "example": ["delay-repay:read", "delay-repay:write", "delay-repay:track"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new key. Save it now — it is never shown again.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["id", "name", "key", "prefix", "scopes", "createdAt"],
                  "properties": {
                    "id": { "type": "integer" },
                    "name": { "type": "string" },
                    "key": { "type": "string", "pattern": "^tb_live_[0-9a-f]{64}$", "example": "tb_live_5b1d3c2a0000400080000000000000005b1d3c2a0000400080000000000000" },
                    "prefix": { "type": "string", "example": "tb_live_5b1d" },
                    "scopes": { "type": "array", "items": { "type": "string" } },
                    "createdAt": { "type": "string", "description": "SQLite CURRENT_TIMESTAMP format: 'YYYY-MM-DD HH:MM:SS', UTC, no 'T'/'Z' — not RFC 3339 date-time.", "example": "2026-09-22 10:15:00" }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid name (must be 1-40 characters) or scopes."
          },
          "401": {
            "description": "Missing, invalid, or expired bearer token."
          },
          "403": {
            "description": "The account isn't enabled for API keys, the account email isn't verified, or auth was an API key instead of a login token."
          },
          "409": {
            "description": "Already at the 10-key-per-account limit."
          },
          "429": {
            "description": "Rate limit exceeded (10 key-creation requests/hour per IP, on top of the standard API rate limit)."
          }
        }
      }
    },
    "/api/keys/{id}": {
      "delete": {
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "Immediate — no grace period. Scoped to the caller; another account's key id returns 404, same as an unknown id. Requires a normal login token.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The key's id, from GET /api/keys or the POST /api/keys response.",
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "204": { "description": "Revoked." },
          "400": { "description": "Invalid key id." },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "403": { "description": "Authenticated with an API key instead of a login token." },
          "404": { "description": "No key with that id belonging to the caller." }
        }
      }
    },
    "/api/tracked-journeys": {
      "post": {
        "operationId": "trackJourney",
        "summary": "Pre-register a future journey",
        "description": "Register a specific train BEFORE its scheduled departure (London time) so Train Badger resolves its actual performance after travel. Body: origin/destination CRS codes, serviceDate within today..today+30 days (London), exact scheduledDeparture (HH:MM), ticket with a valid type, optional operatorCode (2 letters — inferred from the timetable when the match is unique) and clientReference (max 64 chars). The service must exist in the timetable — a miss returns 422 no_such_service with the nearest scheduled departures. Anti-abuse limits: one train per directed route per day, at most 4 legs and 20 row-creating writes per day, one audited replacement per row. Accepts a normal login token, or a tb_live_ API key with the delay-repay:track scope.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["origin", "destination", "serviceDate", "scheduledDeparture", "ticket"],
                "properties": {
                  "origin": { "type": "string", "example": "EUS", "description": "Origin station CRS code" },
                  "destination": { "type": "string", "example": "COV", "description": "Destination station CRS code" },
                  "serviceDate": { "type": "string", "format": "date", "example": "2026-10-15", "description": "Service date, within today..today+30 days (London time)" },
                  "scheduledDeparture": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", "example": "08:15", "description": "Exact scheduled departure, HH:MM" },
                  "operatorCode": { "type": "string", "example": "VT", "description": "2-letter operator code (optional)" },
                  "ticket": {
                    "type": "object",
                    "required": ["type"],
                    "properties": {
                      "type": { "type": "string", "enum": ["single", "return", "season", "flexi", "other"] },
                      "pricePence": { "type": "integer", "minimum": 0, "maximum": 1000000, "example": 1250, "description": "Fare paid in integer pence (optional)" }
                    }
                  },
                  "clientReference": { "type": "string", "maxLength": 64, "description": "Your own reference for this journey (optional)" }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The tracked row (status pending), plus the travel attestation your registration confirms. app carries the one-line app pointer, but only on the account's first-ever tracked journey.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["journey", "attestation", "terms", "beta"],
                  "properties": {
                    "journey": {
                      "type": "object",
                      "properties": {
                        "id": { "type": "integer" },
                        "origin": { "type": "string", "example": "EUS" },
                        "destination": { "type": "string", "example": "COV" },
                        "serviceDate": { "type": "string", "format": "date" },
                        "scheduledDeparture": { "type": "string", "example": "08:15" },
                        "operatorCode": { "type": ["string", "null"], "example": "VT" },
                        "ticket": { "type": "object", "properties": { "type": { "type": "string" }, "pricePence": { "type": ["integer", "null"] } } },
                        "clientReference": { "type": ["string", "null"] },
                        "status": { "type": "string", "enum": ["pending", "awaiting_data", "resolved", "no_such_service", "unverifiable", "withdrawn"] },
                        "replacedFrom": { "type": ["integer", "null"] },
                        "createdAt": { "type": "string" },
                        "updatedAt": { "type": "string" },
                        "journey": { "type": ["object", "null"], "description": "The linked outcome once resolved (same shape as a GET /api/journeys row); null while pending." }
                      }
                    },
                    "attestation": { "type": "string", "example": "By registering this journey you confirm that you will travel on the service stated. If this journey becomes a claim, your agent must show the claim to you for approval before filing it. See the API terms: https://trainbadger.app/terms" },
                    "terms": { "type": "string", "format": "uri", "example": "https://trainbadger.app/terms" },
                    "beta": { "type": "boolean", "example": true },
                    "app": { "type": "string", "description": "One-line pointer to the iOS app; present only on the account's first-ever tracked journey." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid CRS, date, time, operator, ticket or clientReference — see the error code."
          },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "403": {
            "description": "API-key auth without the delay-repay:track scope.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "error": { "type": "string", "example": "This API key is missing the required scope: delay-repay:track" } }
                }
              }
            }
          },
          "409": {
            "description": "departure_passed (already departed — record before departure), duplicate_journey, overlapping_legs, legs_not_chained or too_many_legs — see the error code."
          },
          "422": {
            "description": "No such scheduled service at this station and time — nearest lists the closest scheduled departures (±15 min) so the time can be corrected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "code": { "type": "string", "example": "no_such_service" },
                    "nearest": { "type": "array", "items": { "type": "object" } }
                  }
                }
              }
            }
          },
          "429": {
            "description": "At most 20 tracked-journey writes per day (code write_quota_exceeded). Quota resets at the next London midnight: the Retry-After response header and the retryAfterSeconds body field give the wait in seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "code": { "type": "string", "example": "write_quota_exceeded" },
                    "retryAfterSeconds": { "type": "integer", "description": "Seconds until the next London midnight, when the quota resets (mirrors the Retry-After header)." }
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listTrackedJourneys",
        "summary": "List your pre-registered journeys",
        "description": "The agent's work list: what it registered and what state each row is in. Accepts a normal login token, or a tb_live_ API key with the delay-repay:read scope.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Only include journeys in this resolution state.",
            "schema": { "type": "string", "enum": ["pending", "awaiting_data", "resolved", "no_such_service", "unverifiable", "withdrawn"] }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Only include journeys on or after this service date.",
            "schema": { "type": "string", "format": "date" }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Only include journeys on or before this service date.",
            "schema": { "type": "string", "format": "date" }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max rows to return.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Rows to skip, for pagination.",
            "schema": { "type": "integer", "minimum": 0, "default": 0 }
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's tracked journeys (same row shape as POST above) plus the total.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["journeys", "total"],
                  "properties": {
                    "journeys": { "type": "array", "items": { "type": "object" } },
                    "total": { "type": "integer", "description": "Total matching rows, ignoring limit/offset." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid status filter, date range or pagination."
          },
          "401": { "description": "Missing, invalid, or expired bearer token." }
        }
      }
    },
    "/api/tracked-journeys/{id}": {
      "get": {
        "operationId": "getTrackedJourney",
        "summary": "Fetch one pre-registered journey",
        "description": "A single tracked row (same shape as POST /api/tracked-journeys above), caller's own only. Accepts a normal login token, or a tb_live_ API key with the delay-repay:read scope.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The tracked journey id.",
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "The tracked journey.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["journey"],
                  "properties": { "journey": { "type": "object" } }
                }
              }
            }
          },
          "400": {
            "description": "Invalid journey id."
          },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "404": {
            "description": "No tracked journey with that id belonging to the caller."
          }
        }
      },
      "patch": {
        "operationId": "changeTrackedJourney",
        "summary": "Change a pre-registered journey",
        "description": "Ticket/client-reference-only edits apply in place and are allowed until the linked journey is marked filed (409 already_claimed after that). An operator-only change applies in place while the row is pending and pre-departure (timetable-checked). Changing origin/destination/serviceDate/scheduledDeparture is a one-time audited replacement — pending and pre-departure only, burns one write of the daily quota, and returns the new row (new id) with replacedFrom pointing at the old id. Accepts a normal login token, or a tb_live_ API key with the delay-repay:track scope.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The tracked journey id.",
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "origin": { "type": "string", "example": "EUS" },
                  "destination": { "type": "string", "example": "COV" },
                  "serviceDate": { "type": "string", "format": "date" },
                  "scheduledDeparture": { "type": "string", "example": "08:15" },
                  "operatorCode": { "type": ["string", "null"] },
                  "ticket": {
                    "type": "object",
                    "properties": {
                      "type": { "type": "string", "enum": ["single", "return", "season", "flexi", "other"] },
                      "pricePence": { "type": "integer", "minimum": 0, "maximum": 1000000 }
                    }
                  },
                  "clientReference": { "type": ["string", "null"], "maxLength": 64 }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated journey (or the replacement row, with replacedFrom set).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "journey": { "type": "object" },
                    "replacedFrom": { "type": "integer", "description": "Present only on the replacement path: the old row's id." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Unknown field or invalid value — see the error code."
          },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "403": {
            "description": "API-key auth without the delay-repay:track scope."
          },
          "404": {
            "description": "No tracked journey with that id belonging to the caller."
          },
          "409": {
            "description": "not_pending, already_replaced, already_claimed, departure_passed, duplicate_journey, overlapping_legs, legs_not_chained or too_many_legs — see the error code."
          },
          "422": {
            "description": "No such scheduled service under the new details (code no_such_service, with nearest hints)."
          },
          "429": {
            "description": "At most 20 tracked-journey writes per day (code write_quota_exceeded). Quota resets at the next London midnight: the Retry-After response header and the retryAfterSeconds body field give the wait in seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "code": { "type": "string", "example": "write_quota_exceeded" },
                    "retryAfterSeconds": { "type": "integer", "description": "Seconds until the next London midnight, when the quota resets (mirrors the Retry-After header)." }
                  }
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "withdrawTrackedJourney",
        "summary": "Withdraw a pre-registered journey",
        "description": "A journey still pending before departure is deleted outright ({ deleted: true }); anything else is marked withdrawn and the row is kept for the daily cap and normal retention. Accepts a normal login token, or a tb_live_ API key with the delay-repay:track scope.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The tracked journey id.",
            "schema": { "type": "integer", "minimum": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "Either { deleted: true, id } or the withdrawn journey row.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": { "type": "boolean" },
                    "id": { "type": "integer" },
                    "journey": { "type": "object", "description": "Present on the withdrawn path." }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid journey id."
          },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "403": {
            "description": "API-key auth without the delay-repay:track scope."
          },
          "404": {
            "description": "No tracked journey with that id belonging to the caller."
          }
        }
      }
    },
    "/api/account/export": {
      "get": {
        "operationId": "exportAccount",
        "summary": "Export everything held on your account",
        "description": "UK GDPR Art 15/20 self-serve data export: JSON of everything held on the account (profile, consent, tracked journeys, journey history, favourites, season ticket, fares, saved trains, briefs, detection events, feedback, subscriptions, pins, device/Live-Activity metadata without push credentials, contact/beta rows, key metadata without hashes, audit log). Secrets are never exported. Login token only — API keys get 403, so a leaked key can never exfiltrate the whole account. Rate-limited to 5 exports/hour per account; served as a download (Content-Disposition attachment, Cache-Control no-store).",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The account export.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["exportedAt", "profile", "tracked_journeys", "journey_history", "api_keys", "account_events"],
                  "properties": {
                    "exportedAt": { "type": "string", "format": "date-time" },
                    "profile": { "type": "object" },
                    "marketing_consent": { "type": "object" },
                    "tracked_journeys": { "type": "array", "items": { "type": "object" } },
                    "journey_history": { "type": "array", "items": { "type": "object" } },
                    "favourite_routes": { "type": "array", "items": { "type": "object" } },
                    "season_ticket": { "type": ["object", "null"] },
                    "saved_fares": { "type": "array", "items": { "type": "object" } },
                    "saved_trains": { "type": "array", "items": { "type": "object" } },
                    "user_brief": { "type": ["object", "null"] },
                    "detection_events": { "type": "array", "items": { "type": "object" } },
                    "user_feedback": { "type": "array", "items": { "type": "object" } },
                    "subscription_transactions": { "type": "array", "items": { "type": "object" } },
                    "pinned_services": { "type": "array", "items": { "type": "object" } },
                    "devices": { "type": "array", "items": { "type": "object" }, "description": "Metadata only — push credentials never leave the server." },
                    "live_activity_tokens": { "type": "array", "items": { "type": "object" }, "description": "Metadata only." },
                    "contact_messages": { "type": "array", "items": { "type": "object" } },
                    "beta_signups": { "type": "array", "items": { "type": "object" } },
                    "api_keys": { "type": "array", "items": { "type": "object" }, "description": "Key metadata only — never hashes or key values." },
                    "account_events": { "type": "array", "items": { "type": "object" } }
                  }
                }
              }
            }
          },
          "401": { "description": "Missing, invalid, or expired bearer token." },
          "403": {
            "description": "Authenticated with an API key instead of a login token."
          },
          "404": {
            "description": "Account not found."
          },
          "429": {
            "description": "Export rate limit exceeded (5/hour per account)."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Either a login token (\"Authorization: Bearer <token>\", tied to one account, expires 30 days after issue), or a long-lived tb_live_ API key from POST /api/keys (never expires, scoped to delay-repay:read plus opt-in delay-repay:write and/or delay-repay:track, revocable via DELETE /api/keys/:id). API keys are accepted ONLY on the /api/journeys* and /api/tracked-journeys* routes documented above that say so, and are always rejected on /api/keys* and /api/account/export — those always require a login token. Every other /api/* route not documented on this page rejects API key auth outright (403)."
      }
    }
  }
}
