{
  "openapi": "3.1.0",
  "info": {
    "title": "speakerstrail API",
    "version": "1.0.0",
    "summary": "Verified conference speaking history for European AI and tech events.",
    "description": "An API over the speakerstrail directory: AI and tech conference speakers in Europe,\nbuilt from public event agendas. Every talk record carries the `source_url` of the\nagenda it was extracted from, so any claim this API makes can be checked against a\nthird party without trusting us.\n\nEverything under `/api/v1/` reads. One endpoint writes: `POST /api/waitlist`, which\nis how a caller — a person or an agent acting for one — asks for access to the free\nbeta. It is the only thing here that changes anything.\n\n**No authentication.** All endpoints are public and unkeyed.\n\n**Limits.** Lists page at 25 by default, 100 maximum. There is no\nbulk-export endpoint. Read responses are cached at the edge for one hour;\n`POST /api/waitlist` is rate limited to 5 submissions per\nIP per 10 minutes and is never cached.\n\n**Error shapes differ by endpoint.** Reads use the nested `Error` envelope; the\nwaitlist uses a flat `{ error, code }`. Each operation names the one it returns.\n\n**Versioning and deprecation.** The version is in the path. A breaking change — a\nfield removed, a type changed, a default altered — gets a new path (`/api/v2`), and\n`/api/v1` keeps answering as it always did; it is never changed underneath a caller.\nAdditive changes (new endpoints, new optional fields) do happen within `v1`, so parse\nleniently and ignore fields you do not recognise.\n\nNo version has been deprecated yet. When one is, it will say so in its own responses:\n`Deprecation` and `Sunset` headers (RFC 9745 and RFC 8594) naming the retirement date,\nfor at least six months before the endpoint stops answering, and an entry in\n`https://speakerstrail.com/changelog`. An agent can therefore detect a retirement from the response\nalone, without watching this page.\n\n**What this API does not have:** speaker contact details beyond an occasional LinkedIn\nlink-out, speaker fees, availability, nationality or residence, and any coverage outside\nEurope. If you need those, this is the wrong source and we would rather you knew now.",
    "license": {
      "name": "Data available for use with attribution",
      "identifier": "CC-BY-4.0"
    },
    "contact": {
      "name": "speakerstrail",
      "url": "https://speakerstrail.com/docs/api"
    }
  },
  "servers": [
    {
      "url": "https://speakerstrail.com",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "speakers",
      "description": "People with verified speaking history."
    },
    {
      "name": "events",
      "description": "Conferences the dataset was built from."
    },
    {
      "name": "taxonomy",
      "description": "Topics, countries and cities the directory is browsable by."
    },
    {
      "name": "meta",
      "description": "Dataset coverage."
    },
    {
      "name": "waitlist",
      "description": "Asking for access to the free beta. Addressed to organizers."
    },
    {
      "name": "privacy",
      "description": "Removal and correction of a speaker entry. Documented because an agent acting for a data subject is a legitimate caller — llms.txt invites agents in, and a right that is only exercisable by finding a form is a weaker right."
    },
    {
      "name": "claims",
      "description": "A speaker claiming the profile built about them from a public agenda. Addressed to the speaker, not to a buyer — the other side of this market, added 2026-09-10."
    }
  ],
  "paths": {
    "/api/v1/speakers": {
      "get": {
        "operationId": "listSpeakers",
        "tags": [
          "speakers"
        ],
        "summary": "List and filter speakers",
        "description": "Returns speakers with their verified talks, most recently active first. Combine filters to build a shortlist: `topic` plus `country` plus `spoke_after` answers \"who has spoken about LLMs on a German stage since January?\" — the question this dataset exists for.",
        "parameters": [
          {
            "name": "topic",
            "in": "query",
            "required": false,
            "description": "Topic slug from the fixed vocabulary. See GET /api/v1/topics.",
            "schema": {
              "type": "string"
            },
            "example": "llm"
          },
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "ISO 3166-1 alpha-2 country of the EVENT, not of the speaker. See GET /api/v1/countries.",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 2
            },
            "example": "DE"
          },
          {
            "name": "event",
            "in": "query",
            "required": false,
            "description": "Event slug. See GET /api/v1/events.",
            "schema": {
              "type": "string"
            },
            "example": "data-natives-2025"
          },
          {
            "name": "spoke_after",
            "in": "query",
            "required": false,
            "description": "Only speakers whose most recent talk is on or after this ISO date.",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-01-01"
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Case-insensitive substring match on the speaker name and their talk titles. Headlines are deliberately not searched — matching an employer name reads as a false positive.",
            "schema": {
              "type": "string"
            },
            "example": "kubernetes"
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-indexed page number.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            },
            "example": 1
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page. Values above 100 are rejected rather than clamped, so a caller never silently skips records. There is no bulk-export endpoint.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "example": 25
          }
        ],
        "responses": {
          "200": {
            "description": "A page of speakers.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Speaker"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A parameter was malformed or out of range.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/speakers/{slug}": {
      "get": {
        "operationId": "getSpeaker",
        "tags": [
          "speakers"
        ],
        "summary": "Get one speaker",
        "description": "Returns a single speaker with every talk we can evidence. Use this to verify a claim about a named person: each talk carries the agenda URL that proves it.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Speaker slug, lowercase and hyphenated.",
            "schema": {
              "type": "string"
            },
            "example": "jane-doe"
          }
        ],
        "responses": {
          "200": {
            "description": "The speaker.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Speaker"
                }
              }
            }
          },
          "404": {
            "description": "No speaker with that slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/events": {
      "get": {
        "operationId": "listEvents",
        "tags": [
          "events"
        ],
        "summary": "List conferences",
        "description": "Returns the conferences the dataset was built from, most recent first, each with the agenda URL it was sourced from.",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "ISO 3166-1 alpha-2 country the event was held in.",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 2
            },
            "example": "DE"
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "description": "1-indexed page number.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            },
            "example": 1
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Rows per page. Values above 100 are rejected rather than clamped, so a caller never silently skips records. There is no bulk-export endpoint.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "example": 25
          }
        ],
        "responses": {
          "200": {
            "description": "A page of events.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Event"
                      }
                    },
                    "pagination": {
                      "$ref": "#/components/schemas/Pagination"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A parameter was malformed or out of range.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/events/{slug}": {
      "get": {
        "operationId": "getEvent",
        "tags": [
          "events"
        ],
        "summary": "Get one conference and its speakers",
        "description": "Returns a single event together with every speaker verified against its published agenda.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Event slug.",
            "schema": {
              "type": "string"
            },
            "example": "data-natives-2025"
          }
        ],
        "responses": {
          "200": {
            "description": "The event, with a `speakers` array.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Event"
                    },
                    {
                      "type": "object",
                      "required": [
                        "speakers"
                      ],
                      "properties": {
                        "speakers": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Speaker"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No event with that slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/topics": {
      "get": {
        "operationId": "listTopics",
        "tags": [
          "taxonomy"
        ],
        "summary": "List topics",
        "description": "The fixed topic vocabulary with speaker counts. Only topics with at least three speakers are published, so every slug returned is a usable filter for GET /api/v1/speakers.",
        "responses": {
          "200": {
            "description": "Every published topic.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Topic"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/countries": {
      "get": {
        "operationId": "listCountries",
        "tags": [
          "taxonomy"
        ],
        "summary": "List countries speakers have spoken in",
        "description": "Countries whose stages the directory can evidence, with speaker counts. These are event countries, never speaker nationalities.",
        "responses": {
          "200": {
            "description": "Every published country.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Country"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/cities": {
      "get": {
        "operationId": "listCities",
        "tags": [
          "taxonomy"
        ],
        "summary": "List cities speakers have spoken in",
        "description": "Cities whose stages the directory can evidence, with speaker and event counts. These are event cities, never where a speaker lives — the dataset holds no address data. Only cities with at least three verified speakers are published.",
        "responses": {
          "200": {
            "description": "Every published city.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/City"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/stats": {
      "get": {
        "operationId": "getStats",
        "tags": [
          "meta"
        ],
        "summary": "Dataset coverage",
        "description": "Counts for the whole dataset. Call this first to decide whether the directory covers your region and recency window before paging any list.",
        "responses": {
          "200": {
            "description": "Coverage counts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          }
        }
      }
    },
    "/api/removal": {
      "post": {
        "operationId": "requestRemoval",
        "tags": [
          "privacy"
        ],
        "summary": "Request removal or correction of an entry",
        "description": "Submits a GDPR removal or correction request for one profile. A removal takes the entry out of the directory and out of future rebuilds; a correction is checked against the event's own agenda first. Nothing is emailed back to the submitted address — an unauthenticated endpoint that mails whatever address it is handed is a spam relay — so the response body is the acknowledgement, and a human replies from a real mailbox within 7 days. This endpoint answers with the shared `/api/v1/*` error envelope rather than the flatter waitlist shape.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RemovalSubmission"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The request was delivered to the owner. Reply within 7 days.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistAccepted"
                }
              }
            }
          },
          "400": {
            "description": "The body would not parse, a required field is missing, a field is over its cap, or the address is malformed (`invalid_parameter`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "The method was not POST.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Delivery failed (`internal_error`). The Impressum address does not depend on this endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Email sending is not configured on this deployment (`unavailable`). A request by email is just as valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/claim": {
      "post": {
        "operationId": "claimSpeakerProfile",
        "tags": [
          "claims"
        ],
        "summary": "Claim a speaker profile",
        "description": "Lets the subject of a profile tell us it is them, what they will speak on, and how an organizer may reach them. NOTHING PUBLISHES: there is no auth on this site, so a claim is a request that a human checks against the event's own agenda before any page changes — an endpoint that let anyone rewrite anyone's profile would be an impersonation tool, not a feature. `speakerSlug` must be a profile that exists; an unknown one and a malformed one return the same body on purpose, so this cannot be used to enumerate slugs. Nothing is emailed back to the submitted address, for the same reason the waitlist endpoint sends nothing. `contactable` is the only field that decides anything: false, and the contact link is never passed to an organizer.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimSubmission"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The claim was recorded and is awaiting review. It has changed nothing that is published.",
            "headers": {
              "RateLimit-Limit": {
                "description": "Requests allowed per window. Currently 5.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests left in the current window.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Unix seconds at which the window resets.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Limit": {
                "description": "Same value as RateLimit-Limit.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Same value as RateLimit-Remaining.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "description": "Same value as RateLimit-Reset.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistAccepted"
                }
              }
            }
          },
          "400": {
            "description": "The body would not parse (`invalid_body`); a required field is missing or a field is over its cap (`validation_error`); or `speakerSlug` is not a profile this directory holds (`unknown_profile`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "405": {
            "description": "The method was not POST.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "409": {
            "description": "This profile has already been claimed from this address (`already_claimed`). Not a failure — treat it as 200 and show the same confirmation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "429": {
            "description": "More than 3 submissions from one IP inside 10 minutes (`rate_limited`). Tighter than the waitlist deliberately: a spurious claim targets a named person's page.",
            "headers": {
              "RateLimit-Limit": {
                "description": "Requests allowed per window. Currently 5.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests left in the current window.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Unix seconds at which the window resets.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Limit": {
                "description": "Same value as RateLimit-Limit.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Same value as RateLimit-Remaining.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "description": "Same value as RateLimit-Reset.",
                "schema": {
                  "type": "integer"
                }
              },
              "Retry-After": {
                "description": "Seconds to wait, never below 1. Sent on the 429 only — a Retry-After on a success tells a client to back off from a request that worked.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "500": {
            "description": "The insert failed (`storage_error`). Retry once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "503": {
            "description": "The claim store is not reachable (`service_unavailable`). Nothing was recorded, so the same submission can be retried unchanged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          }
        }
      }
    },
    "/api/waitlist": {
      "post": {
        "operationId": "joinWaitlist",
        "tags": [
          "waitlist"
        ],
        "summary": "Ask for access to the free beta",
        "description": "Adds an address to the beta waitlist. This is the only endpoint that writes, and the only thing on this site an agent can do on someone's behalf: the directory is free to read without it. Send `email` and, if you have them, `company`, `role` and `useCase` — the last one is what decides who is let in first, so a sentence about the event being planned is worth more than the other two together. Nothing is emailed back to the submitted address; an unauthenticated endpoint that mails whatever address it is handed is a spam relay. The reply is the response body, and a human follows up from there.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WaitlistSubmission"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "On the list. Sending the same address again returns 409, not 200.",
            "headers": {
              "RateLimit-Limit": {
                "description": "Requests allowed per window. Currently 5.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests left in the current window.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Unix seconds at which the window resets.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Limit": {
                "description": "Same value as RateLimit-Limit.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Same value as RateLimit-Remaining.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "description": "Same value as RateLimit-Reset.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistAccepted"
                }
              }
            }
          },
          "400": {
            "description": "The body would not parse (`invalid_body`), or a field failed validation (`validation_error`: `email` missing, malformed, or a field over its cap). Length is checked before format, so an over-long address is reported as too long rather than as malformed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "405": {
            "description": "The method was not POST. Every other verb gets this JSON body — with a `hint` field — rather than an HTML error page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "409": {
            "description": "That address is already on the list (`duplicate_email`). Not a failure: treat it the same as 200 and show the same confirmation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "429": {
            "description": "More than 5 submissions from one IP inside 10 minutes (`rate_limited`). Wait for `Retry-After` seconds. Added in v0.14.0.",
            "headers": {
              "RateLimit-Limit": {
                "description": "Requests allowed per window. Currently 5.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Remaining": {
                "description": "Requests left in the current window.",
                "schema": {
                  "type": "integer"
                }
              },
              "RateLimit-Reset": {
                "description": "Unix seconds at which the window resets.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Limit": {
                "description": "Same value as RateLimit-Limit.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "description": "Same value as RateLimit-Remaining.",
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "description": "Same value as RateLimit-Reset.",
                "schema": {
                  "type": "integer"
                }
              },
              "Retry-After": {
                "description": "Seconds to wait, never below 1. Sent on the 429 only — a Retry-After on a success tells a client to back off from a request that worked.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "500": {
            "description": "The insert failed for a reason that was not a duplicate (`internal_error`). Retry once, then stop — a second failure is ours to fix, not yours to loop on.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          },
          "503": {
            "description": "The waitlist store is not reachable (`service_unavailable`). Nothing was recorded, so the same submission can be retried later unchanged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WaitlistError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Talk": {
        "type": "object",
        "description": "One talk, at one event, on one date, with the public agenda that proves it.",
        "required": [
          "title",
          "event_slug",
          "event_name",
          "date",
          "topic",
          "source_url",
          "url"
        ],
        "properties": {
          "title": {
            "type": "string",
            "description": "The session title as printed on the agenda."
          },
          "event_slug": {
            "type": "string",
            "description": "Slug of the event, usable at /api/v1/events/{slug}."
          },
          "event_name": {
            "type": "string",
            "description": "Display name of the conference."
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "ISO date the talk was given."
          },
          "topic": {
            "type": [
              "string",
              "null"
            ],
            "description": "Primary topic slug from the fixed vocabulary, or null if unclassified."
          },
          "source_url": {
            "type": "string",
            "format": "uri",
            "description": "The public conference agenda this record was extracted from. Present on every talk without exception — this is the evidence the directory exists to provide."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "This event on speakerstrail."
          }
        }
      },
      "Speaker": {
        "type": "object",
        "description": "A speaker as the public directory knows them: evidence only, no contact data.",
        "required": [
          "slug",
          "full_name",
          "headline",
          "topics",
          "countries_spoken_in",
          "linkedin_url",
          "last_spoke_at",
          "verified_talk_count",
          "url",
          "talks"
        ],
        "properties": {
          "slug": {
            "type": "string",
            "description": "Stable identifier, lowercase and hyphenated."
          },
          "full_name": {
            "type": "string",
            "description": "Name as printed on the agendas."
          },
          "headline": {
            "type": [
              "string",
              "null"
            ],
            "description": "Role and employer as stated on an agenda, or null when none published one."
          },
          "topics": {
            "type": "array",
            "description": "Topics assigned from a fixed 15-term vocabulary.",
            "items": {
              "type": "object",
              "required": [
                "slug",
                "label"
              ],
              "properties": {
                "slug": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                }
              }
            }
          },
          "countries_spoken_in": {
            "type": "array",
            "description": "Countries whose stages this person verifiably took. This is the country of the EVENT, never of the speaker — the dataset holds no nationality or residence data.",
            "items": {
              "type": "object",
              "required": [
                "code",
                "name"
              ],
              "properties": {
                "code": {
                  "type": "string",
                  "description": "ISO 3166-1 alpha-2."
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "linkedin_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "Link-out only, present on a minority of records. We never scrape LinkedIn and hold no other contact details."
          },
          "last_spoke_at": {
            "type": "string",
            "format": "date",
            "description": "Date of the most recent talk."
          },
          "verified_talk_count": {
            "type": "integer",
            "minimum": 1
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "This speaker on speakerstrail."
          },
          "talks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Talk"
            },
            "description": "Most recent first."
          }
        }
      },
      "Event": {
        "type": "object",
        "description": "A conference whose public agenda the dataset was built from.",
        "required": [
          "slug",
          "name",
          "city",
          "country",
          "starts_on",
          "website",
          "agenda_source_url",
          "organizer",
          "speaker_count",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "city": {
            "type": "string"
          },
          "country": {
            "type": "object",
            "required": [
              "code",
              "name"
            ],
            "properties": {
              "code": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "starts_on": {
            "type": "string",
            "format": "date"
          },
          "website": {
            "type": "string",
            "format": "uri",
            "description": "The conference's own site."
          },
          "agenda_source_url": {
            "type": "string",
            "format": "uri",
            "description": "The agenda page the speaker records were extracted from."
          },
          "organizer": {
            "type": [
              "string",
              "null"
            ]
          },
          "speaker_count": {
            "type": "integer",
            "minimum": 0
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "This event on speakerstrail."
          }
        }
      },
      "Topic": {
        "type": "object",
        "required": [
          "slug",
          "label",
          "speaker_count",
          "url"
        ],
        "properties": {
          "slug": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "speaker_count": {
            "type": "integer",
            "minimum": 0
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "Country": {
        "type": "object",
        "required": [
          "code",
          "name",
          "slug",
          "speaker_count",
          "url"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2."
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "URL segment, e.g. \"germany\"."
          },
          "speaker_count": {
            "type": "integer",
            "minimum": 0
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "City": {
        "type": "object",
        "required": [
          "name",
          "slug",
          "country",
          "speaker_count",
          "event_count",
          "url"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "The city as the agendas spell it, e.g. \"Berlin\"."
          },
          "slug": {
            "type": "string",
            "description": "URL segment, e.g. \"berlin\"."
          },
          "country": {
            "type": "object",
            "required": [
              "code",
              "name"
            ],
            "properties": {
              "code": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "speaker_count": {
            "type": "integer",
            "minimum": 0
          },
          "event_count": {
            "type": "integer",
            "minimum": 0
          },
          "url": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "Stats": {
        "type": "object",
        "required": [
          "speakers",
          "events",
          "talks",
          "countries",
          "topics",
          "sourced_percent",
          "latest_talk_date"
        ],
        "properties": {
          "speakers": {
            "type": "integer",
            "minimum": 0
          },
          "events": {
            "type": "integer",
            "minimum": 0
          },
          "talks": {
            "type": "integer",
            "minimum": 0
          },
          "countries": {
            "type": "integer",
            "minimum": 0
          },
          "topics": {
            "type": "integer",
            "minimum": 0
          },
          "sourced_percent": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Share of talk records carrying a source_url. The build fails below 100."
          },
          "latest_talk_date": {
            "type": "string",
            "format": "date"
          }
        }
      },
      "Pagination": {
        "type": "object",
        "description": "Where the returned slice sits in the full result set.",
        "required": [
          "page",
          "limit",
          "total",
          "total_pages",
          "has_more"
        ],
        "properties": {
          "page": {
            "type": "integer",
            "minimum": 1,
            "description": "1-indexed."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100
          },
          "total": {
            "type": "integer",
            "minimum": 0,
            "description": "Rows matching the filters, not on this page."
          },
          "total_pages": {
            "type": "integer",
            "minimum": 0
          },
          "has_more": {
            "type": "boolean"
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Every non-2xx response from /api/v1/* has this shape. No HTML error pages. POST /api/waitlist predates this envelope and answers with the flatter WaitlistError instead.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "hint",
              "documentation_url"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable, branchable identifier.",
                "enum": [
                  "invalid_parameter",
                  "not_found",
                  "method_not_allowed",
                  "not_acceptable",
                  "unavailable",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string",
                "description": "What went wrong, naming the offending value."
              },
              "hint": {
                "type": "string",
                "description": "What to send instead."
              },
              "documentation_url": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      },
      "RemovalSubmission": {
        "type": "object",
        "required": [
          "speakerName",
          "email",
          "profileUrl"
        ],
        "properties": {
          "speakerName": {
            "type": "string",
            "maxLength": 200
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Where the owner replies. Never published."
          },
          "profileUrl": {
            "type": "string",
            "maxLength": 500,
            "description": "The directory page the request is about."
          },
          "reason": {
            "type": "string",
            "maxLength": 2000,
            "description": "Optional."
          }
        }
      },
      "ClaimSubmission": {
        "type": "object",
        "required": [
          "speakerSlug",
          "claimedName",
          "email"
        ],
        "description": "A speaker's own assertions about themselves. None of it is evidence, and none of it is rendered as though the event had published it — data/METHODOLOGY.md draws that line for talk titles and it holds here.",
        "properties": {
          "speakerSlug": {
            "type": "string",
            "maxLength": 200,
            "description": "The profile being claimed, e.g. \"jane-doe\". Must already exist."
          },
          "claimedName": {
            "type": "string",
            "maxLength": 200
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Never published. Used to reply about the claim."
          },
          "topics": {
            "type": "string",
            "maxLength": 500,
            "description": "What they will speak on next — the one thing no agenda can tell us, since a page can only infer topics from talks already given."
          },
          "languages": {
            "type": "string",
            "maxLength": 200
          },
          "contactUrl": {
            "type": "string",
            "maxLength": 500,
            "description": "A link an organizer can reach them through. Only shared if `contactable`."
          },
          "note": {
            "type": "string",
            "maxLength": 2000,
            "description": "Anything wrong on the page, checked against the source."
          },
          "contactable": {
            "type": "boolean",
            "default": false,
            "description": "Explicit opt-in to having the contact link passed to organizers. Anything other than boolean true reads as false."
          }
        }
      },
      "WaitlistSubmission": {
        "type": "object",
        "description": "A request to join the free beta. Only the address is required — the other three fields are what makes a signup worth reading, not what makes it valid.",
        "required": [
          "email"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "Where we reply. Checked for shape only — an @ and a dot — and stored lowercased and trimmed. 320 characters is the RFC 5321 maximum address length.",
            "examples": [
              "ada@example.com"
            ]
          },
          "company": {
            "type": "string",
            "maxLength": 200,
            "description": "Who you work for. Optional; an empty value is stored as null, not as \"\".",
            "examples": [
              "Northwind Events"
            ]
          },
          "role": {
            "type": "string",
            "maxLength": 200,
            "description": "What you do there. Optional.",
            "examples": [
              "Programme lead"
            ]
          },
          "useCase": {
            "type": "string",
            "maxLength": 1000,
            "description": "What you are trying to book, in your own words. Optional, and the field that decides who gets let in first.",
            "examples": [
              "Two AI keynotes for a 400-person conference in Munich next spring."
            ]
          },
          "sourceContext": {
            "type": "string",
            "maxLength": 500,
            "description": "Page attribution — which page the signup came from. Set automatically by the site; agents may pass it to indicate which surface referred the user.",
            "examples": [
              "Landing page: join (join)"
            ]
          }
        }
      },
      "WaitlistAccepted": {
        "type": "object",
        "description": "The row landed. Nothing is emailed to the submitted address — see the operation.",
        "required": [
          "success"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "WaitlistError": {
        "type": "object",
        "description": "What POST /api/waitlist returns on every non-2xx. Flat, and deliberately not the shape /api/v1/* uses — these bodies predate this document and the form branches on them.",
        "required": [
          "error",
          "code"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A sentence written for the person who filled the form, not for a log."
          },
          "code": {
            "type": "string",
            "description": "Stable, branchable identifier. Branch on this, never on `error`.",
            "enum": [
              "invalid_body",
              "validation_error",
              "duplicate_email",
              "rate_limited",
              "service_unavailable",
              "internal_error",
              "method_not_allowed"
            ]
          },
          "hint": {
            "type": "string",
            "description": "What to send instead. Present on the 405 only."
          }
        }
      }
    }
  }
}
