{
  "openapi": "3.1.0",
  "info": {
    "title": "Crisis Helplines API",
    "version": "1.0.0",
    "summary": "Verified, source-graded crisis helplines for ~200 countries.",
    "description": "A read-only JSON API over a verified directory of crisis helplines. Records are returned verbatim — the API never rewrites a number, a URL, or an opening hour. Every record carries its source and independent letter grades for existence and accuracy.\n\nThis directory is informational and is not a substitute for local emergency services. Present contact values exactly as returned.",
    "contact": { "name": "Gabbatron Pty Ltd", "email": "hello@globalhelplines.org" },
    "x-audience": "Software developers, AI agents and assistants, trust and safety teams"
  },
  "servers": [{ "url": "https://globalhelplines.org", "description": "Production" }],
  "security": [{}, { "bearerAuth": [] }, { "apiKeyHeader": [] }],
  "tags": [
    { "name": "Service records", "description": "Look up helplines and individual service records." },
    { "name": "Discovery", "description": "Authoritative enumerations for populating filters and validating input. Free and unmetered." },
    { "name": "Health", "description": "Liveness." }
  ],
  "paths": {
    "/v1/helplines": {
      "get": {
        "operationId": "getHelplines",
        "tags": ["Service records"],
        "summary": "Find helplines by country and topic",
        "description": "Both country and topic are required — the pairing is what keeps this a lookup rather than a bulk export. A region query returns the region's own lines plus the national ones, so a state filter never hides a national crisis line. Results are ordered best-verified first: existence grade ascending (A+ before A before B, ungraded last), tiebroken by id. Records graded F for existence (looks fake or defunct) are withheld entirely rather than ordered last, and are not counted in total.",
        "parameters": [
          { "name": "country", "in": "query", "required": true, "description": "ISO 3166-1 alpha-2. Trimmed and uppercased. A well-formed but unknown code returns 200 with total 0.", "schema": { "type": "string", "pattern": "^[A-Za-z]{2}$" }, "example": "GB" },
          { "name": "topic", "in": "query", "required": true, "description": "Trimmed and lowercased. Anything outside the enum returns 400 listing every allowed value.", "schema": { "$ref": "#/components/schemas/Topic" }, "example": "suicide" },
          { "name": "region", "in": "query", "required": false, "description": "Sub-national code as a bare part (US states and DC, AU jurisdictions), e.g. CA or NSW. Returns the region's lines plus the national ones.", "schema": { "type": "string" }, "example": "CA" },
          { "name": "language", "in": "query", "required": false, "description": "ISO 639-1 code the service actually answers in. Validated by shape, not against a fixed enum — /v1/languages is the source of truth.", "schema": { "type": "string", "pattern": "^[A-Za-z]{2}$" }, "example": "es" },
          { "name": "contact_method", "in": "query", "required": false, "schema": { "$ref": "#/components/schemas/ContactMethod" } },
          { "name": "cost", "in": "query", "required": false, "schema": { "$ref": "#/components/schemas/Cost" } },
          { "name": "anonymous", "in": "query", "required": false, "description": "Filter to services usable without identifying yourself.", "schema": { "type": "boolean" } },
          { "name": "keyword", "in": "query", "required": false, "description": "Case-insensitive substring match within the country + topic slice. 2–100 characters after trimming.", "schema": { "type": "string", "minLength": 2, "maxLength": 100 } },
          { "name": "page", "in": "query", "required": false, "description": "Page size is fixed at 10. Pages past the end return 200, an empty results array, and the true total.", "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 1 } }
        ],
        "responses": {
          "200": {
            "description": "Matching services, best-verified first.",
            "headers": {
              "X-RateLimit-Limit": { "schema": { "type": "integer" } },
              "X-RateLimit-Remaining": { "schema": { "type": "integer" } },
              "X-RateLimit-Window": { "schema": { "type": "string" } }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HelplinesResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "$ref": "#/components/responses/InternalError" }
        }
      }
    },
    "/v1/services/{id}": {
      "get": {
        "operationId": "getService",
        "tags": ["Service records"],
        "summary": "Fetch one service by id",
        "description": "Returns the full record including validation provenance. Use it to re-verify a record you already hold — it is not a search endpoint.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "description": "Trimmed and uppercased. Malformed returns 400; well-formed but unknown returns 404.", "schema": { "type": "string", "pattern": "^[A-Za-z]{2}(-[A-Za-z]{2,3})?-\\d{3}$" }, "example": "US-015" }
        ],
        "responses": {
          "200": {
            "description": "One service record.",
            "content": { "application/json": { "schema": { "type": "object", "required": ["meta", "service"], "properties": { "meta": { "$ref": "#/components/schemas/Meta" }, "service": { "$ref": "#/components/schemas/Service" } } } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/topics": {
      "get": {
        "operationId": "listTopics",
        "tags": ["Discovery"],
        "summary": "The authoritative topic enum",
        "description": "The only valid values for the topic parameter. A constant — it does not vary with the data.",
        "responses": { "200": { "description": "Topic enum.", "content": { "application/json": { "schema": { "type": "object", "required": ["topics"], "properties": { "topics": { "type": "array", "items": { "$ref": "#/components/schemas/Topic" } } } } } } } }
      }
    },
    "/v1/contact-methods": {
      "get": {
        "operationId": "listContactMethods",
        "tags": ["Discovery"],
        "summary": "Valid contact_method values",
        "responses": { "200": { "description": "Contact-method enum.", "content": { "application/json": { "schema": { "type": "object", "required": ["contact_methods"], "properties": { "contact_methods": { "type": "array", "items": { "$ref": "#/components/schemas/ContactMethod" } } } } } } } }
      }
    },
    "/v1/costs": {
      "get": {
        "operationId": "listCosts",
        "tags": ["Discovery"],
        "summary": "Valid cost values",
        "description": "Note that unknown is a real member — cost is recorded, not assumed.",
        "responses": { "200": { "description": "Cost enum.", "content": { "application/json": { "schema": { "type": "object", "required": ["costs"], "properties": { "costs": { "type": "array", "items": { "$ref": "#/components/schemas/Cost" } } } } } } } }
      }
    },
    "/v1/countries": {
      "get": {
        "operationId": "listCountries",
        "tags": ["Discovery"],
        "summary": "Country codes present in the data",
        "description": "Data-driven and sorted — it grows as coverage does.",
        "responses": { "200": { "description": "Sorted ISO 3166-1 alpha-2 codes.", "content": { "application/json": { "schema": { "type": "object", "required": ["countries"], "properties": { "countries": { "type": "array", "items": { "type": "string", "pattern": "^[A-Z]{2}$" } } } }, "example": { "countries": ["AE", "AF", "AL", "AU", "BR", "GB", "US", "ZA"] } } } } }
      }
    },
    "/v1/regions": {
      "get": {
        "operationId": "listRegions",
        "tags": ["Discovery"],
        "summary": "Sub-national codes for one country",
        "description": "Returned as bare parts — exactly what the region parameter accepts, so a value can be fed straight back without slicing.",
        "parameters": [{ "name": "country", "in": "query", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z]{2}$" }, "example": "US" }],
        "responses": {
          "200": { "description": "Sorted subdivision parts.", "content": { "application/json": { "schema": { "type": "object", "required": ["regions"], "properties": { "regions": { "type": "array", "items": { "type": "string" } } } }, "example": { "regions": ["AK", "AL", "AR", "AZ", "CA", "CO", "WY"] } } } },
          "400": { "$ref": "#/components/responses/BadRequest" }
        }
      }
    },
    "/v1/languages": {
      "get": {
        "operationId": "listLanguages",
        "tags": ["Discovery"],
        "summary": "Languages services actually answer in",
        "description": "ISO 639-1 codes drawn from the records themselves — not the languages spoken in the country.",
        "responses": { "200": { "description": "Sorted ISO 639-1 codes.", "content": { "application/json": { "schema": { "type": "object", "required": ["languages"], "properties": { "languages": { "type": "array", "items": { "type": "string" } } } }, "example": { "languages": ["ar", "de", "en", "es", "fr", "ko", "pt", "zh"] } } } } }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "healthz",
        "tags": ["Health"],
        "summary": "Liveness",
        "description": "Unauthenticated and unmetered.",
        "security": [{}],
        "responses": { "200": { "description": "Worker is serving.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } }, "example": { "ok": true } } } } }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer", "description": "An API key as a bearer token. Optional — without one you are served on the anonymous tier, identified by IP. A key that is unknown or revoked returns 401; there is no silent fallback to anonymous." },
      "apiKeyHeader": { "type": "apiKey", "in": "header", "name": "x-api-key", "description": "Equivalent to the bearer form." }
    },
    "schemas": {
      "Topic": {
        "type": "string",
        "enum": ["suicide", "domestic_violence", "domestic_violence_women", "domestic_violence_men", "sexual_assault", "child_abuse", "child_helpline", "lgbtq", "substance_abuse", "mental_health", "eating_disorder", "bereavement", "elder_abuse", "human_trafficking", "victim_support", "gambling", "general_crisis", "homelessness", "refugee_migrant", "veteran_support", "veteran_crisis", "veterans", "youth"]
      },
      "ContactMethod": { "type": "string", "enum": ["phone", "sms", "whatsapp", "chat", "email", "app", "in_person"] },
      "Cost": { "type": "string", "enum": ["free", "free_from_landlines", "local_rate", "standard_rate", "paid", "unknown"] },
      "Grade": { "type": ["string", "null"], "enum": ["A+", "A", "B", "C", "D", "F", "unknown", null], "description": "Omitted or null rather than guessed. Absent means unverified — not 'probably fine'. NOTE: \"F\" (looks fake or defunct) is part of the grading vocabulary but is never returned by this API — F-graded records are withheld from every response, including a direct fetch by id, which answers 404. Do not write client handling for a received F." },
      "StringOrArray": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }, { "type": "null" }] },
      "Meta": {
        "type": "object",
        "required": ["dataset_version", "disclaimer"],
        "properties": {
          "dataset_version": { "type": "string", "example": "2026-08-07+gd7fb1d6" },
          "disclaimer": { "type": "string", "description": "Present this to end users alongside any contact detail." }
        }
      },
      "Contact": {
        "type": "object",
        "description": "Any field may be a single string or an array of strings. Present values verbatim.",
        "properties": {
          "phone": { "$ref": "#/components/schemas/StringOrArray" },
          "sms": { "$ref": "#/components/schemas/StringOrArray" },
          "whatsapp": { "$ref": "#/components/schemas/StringOrArray" },
          "chat_url": { "$ref": "#/components/schemas/StringOrArray" },
          "email": { "$ref": "#/components/schemas/StringOrArray" },
          "app": { "$ref": "#/components/schemas/StringOrArray" },
          "in_person": { "$ref": "#/components/schemas/StringOrArray" }
        }
      },
      "Service": {
        "type": "object",
        "required": ["id", "name", "categories", "contact", "coverage", "source_url", "source_type", "grades", "lastVerified"],
        "properties": {
          "id": { "type": "string", "description": "{ISO2}-{NNN}, or {ISO2}-{SUBDIVISION}-{NNN} where region-specific. Unique across the dataset.", "example": "US-015" },
          "name": { "type": "string", "description": "English name, transliterated where no English name exists." },
          "name_original": { "type": ["string", "null"], "description": "Name in the local language or script." },
          "description": { "type": ["string", "null"] },
          "notes": { "type": ["string", "null"], "description": "Edge cases: regional variations, partial coverage, known reachability problems, sub-services with a separate status." },
          "categories": { "type": "array", "items": { "$ref": "#/components/schemas/Topic" }, "description": "A service commonly sits in several." },
          "target_audience": { "type": "string", "example": "general" },
          "coverage": { "type": "string", "description": "national | regional:{name} | city:{name} | international.", "example": "national" },
          "region": { "type": ["string", "null"], "description": "Full ISO 3166-2 subdivision code when region-specific.", "example": "US-CA" },
          "cost": { "$ref": "#/components/schemas/Cost" },
          "anonymous": { "oneOf": [{ "type": "boolean" }, { "const": "unknown" }], "description": "Three-valued, not two." },
          "languages": { "type": "array", "items": { "type": "string" } },
          "contact": { "$ref": "#/components/schemas/Contact" },
          "hours": { "type": ["string", "null"], "description": "Free text in the local convention. Present as given." },
          "hours_structured": { "oneOf": [{ "type": "object" }, { "const": "24/7" }, { "type": "null" }], "description": "A weekday-to-range map, the literal string 24/7, or null. Branch on the shape." },
          "website": { "type": ["string", "null"] },
          "source_url": { "type": "string" },
          "source_type": { "type": "string", "enum": ["official_site", "government_directory", "ngo_directory", "wikipedia", "news_article", "academic", "social_media", "forum", "other"] },
          "grades": {
            "type": "object",
            "properties": {
              "existence": { "$ref": "#/components/schemas/Grade" },
              "accuracy": { "$ref": "#/components/schemas/Grade" },
              "quality": { "$ref": "#/components/schemas/Grade" }
            }
          },
          "grade_rationale": { "type": "string", "description": "What was actually checked, and why the grades landed where they did." },
          "validation_sources": { "type": "array", "items": { "type": "string" }, "description": "Every URL consulted during the validating pass." },
          "corrections": { "type": "object", "description": "Fields the validation pass changed, as {field: {raw, corrected}}. Records a decision, which was not always applied to the live field — if corrected differs from the stored value, read it as pending work." },
          "added": { "type": "string", "format": "date" },
          "lastVerified": { "type": "string", "format": "date", "description": "Most recent pass that successfully fetched a source. Updated every run, whether or not anything changed." },
          "lastUpdated": { "type": ["string", "null"], "format": "date", "description": "Most recent run that actually changed data." },
          "validated_at": { "type": "string", "format": "date", "description": "Back-compatible alias of lastVerified." }
        }
      },
      "HelplinesResponse": {
        "type": "object",
        "required": ["meta", "query", "page", "page_size", "total", "results"],
        "properties": {
          "meta": { "$ref": "#/components/schemas/Meta" },
          "query": { "type": "object", "description": "The parsed query, echoed back with unset filters as null." },
          "page": { "type": "integer" },
          "page_size": { "type": "integer", "const": 10 },
          "total": { "type": "integer", "description": "Total matches, not the count on this page." },
          "results": { "type": "array", "items": { "$ref": "#/components/schemas/Service" } }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "type": "string", "enum": ["invalid_parameter", "invalid_api_key", "not_found", "rate_limited", "internal_error"] },
              "message": { "type": "string" },
              "parameter": { "type": "string" },
              "allowed": { "type": "array", "items": { "type": "string" }, "description": "Present on enum failures — the full allowed list." },
              "tier": { "type": "string" },
              "limit": { "type": "integer" },
              "limit_kind": { "type": "string", "enum": ["requests", "records", "burst"] },
              "window": { "type": "string" },
              "retry_after_seconds": { "type": "integer" }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": { "description": "Missing or malformed parameter. Consumes no quota. Enum failures return the full allowed list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": { "code": "invalid_parameter", "message": "Unknown topic.", "parameter": "topic", "allowed": ["suicide", "domestic_violence"] } } } } },
      "Unauthorized": { "description": "Key unknown or revoked. Never falls back to the anonymous tier.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": { "code": "invalid_api_key", "message": "API key is not recognised." } } } } },
      "NotFound": { "description": "Well-formed service id with no record.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "RateLimited": { "description": "Request, record, or burst limit reached.", "headers": { "Retry-After": { "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": { "code": "rate_limited", "message": "Daily request limit reached.", "tier": "anonymous", "limit": 10, "limit_kind": "requests", "window": "day", "retry_after_seconds": 18400 } } } } },
      "InternalError": { "description": "Generic message only; details go to logs, never the response.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
    }
  }
}
