openapi: 3.1.0

info:
  title: Kdotovolal.cz Partner API
  version: 1.0.0
  summary: Reputace telefonních čísel pro partnerské aplikace.
  description: |
    Přes toto API lze dotazovat databázi Kdotovolal.cz. Je v ní
    328 tisíc telefonních čísel a 18 tisíc hodnocení od uživatelů.

    ## Pokrytí trhů

    Provozujeme víc národních webů, každý s vlastní doménou a jazykem.
    API to dělení nedědí. Ptáte se vždycky na jednu adresu a my dotaz
    doručíme tomu webu, kterému číslo patří, podle jeho předvolby.

    Dnes obsluhujeme české a slovenské předvolby. Až přibude další trh,
    vaše integrace se nezmění.

    Pole `url` v odpovědi vede na veřejný detail čísla na tom webu,
    který ho vlastní.

    ## Adresa

    Základ je `https://api.kdotovolal.cz/v1`.

    Tytéž endpointy odpoví i na `https://kdotovolal.cz/api/v1` a na starším
    `https://kdotovolal.cz/wp-json/kdotovolal/partner/v1`. Obě zůstanou
    funkční, ale v nových integracích použijte tu první.

    ## Autentizace

    Každý endpoint vyžaduje API klíč v hlavičce:

    ```
    Authorization: Bearer ktv_live_...
    ```

    Klíče vydáváme na vyžádání. Testovací klíč s prefixem `ktv_test_`
    dostanete i před podpisem smlouvy.

    ## Limity

    Limity jsou dva a platí každý zvlášť. Měsíční kvóta se řídí plánem
    a nuluje se prvního dne v měsíci o půlnoci UTC. Burst limit hlídá,
    kolik dotazů pošlete za sekundu.

    Kolik kvóty vám zbývá, vrací každá odpověď v hlavičkách
    `RateLimit-Limit`, `RateLimit-Remaining` a `RateLimit-Reset`.
    Zjistit se to dá i kdykoli přes `GET /usage`.

    ## Chyby

    Chyby vrací tvar podle [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
    s hlavičkou `Content-Type: application/problem+json`. Pole `type` je
    stabilní identifikátor, takže se na něj dá navázat kód.

    Texty chyb jsou česky a odpovědi posíláme v UTF-8 bez escapování.
    Pokud si potřebujete odpověď přečíst očima, přidejte k dotazu
    `?_pretty` a JSON se odsadí.

    ## Výmazy

    Když někdo požádá o odstranění svého čísla, `/lookup` ho přestane
    vracet a číslo se objeví v `/deletions`. Partner je pak smluvně povinen
    promazat ho ze své cache do 72 hodin. Cache doporučujeme držet
    nejvýš 24 hodin.

    ## Kompatibilita

    Ve verzi `v1` nic nerozbíjíme. Můžeme přidat nová pole, takže klient
    musí ignorovat ta, která nezná. Kdybychom verzi ukončovali, oznámíme
    to 6 měsíců předem hlavičkami `Deprecation` a `Sunset`.

  contact:
    name: Kdotovolal.cz
    url: https://kdotovolal.cz/api/docs/
    email: api@kdotovolal.cz
  license:
    name: Podmínky partnerské smlouvy
    url: https://kdotovolal.cz/api/docs/

servers:
  - url: https://kdotovolal.cz/api/v1
    description: Produkce, alternativní cesta
  - url: https://api.kdotovolal.cz/v1
    description: Produkce
  - url: https://dev.kdotovolal.cz/api/v1
    description: Dev

security:
  - bearerAuth: []

tags:
  - name: Lookup
    description: Vyhledání reputace čísla
  - name: Reports
    description: Hlášení čísel do databáze
  - name: Account
    description: Kvóty a provozní informace

paths:
  /lookup/{e164}:
    get:
      tags: [Lookup]
      operationId: lookupNumber
      summary: Reputace telefonního čísla
      description: |
        Vrátí verdikt, hodnocení a rozložení štítků pro jedno číslo.

        Hromadný dotaz na víc čísel schválně nenabízíme. Ptát se má smysl
        jen na číslo, které právě volá.

        U blokovaných a vymazaných čísel vrátí endpoint `404` úplně stejně
        jako u čísel, která v databázi nikdy nebyla.
      parameters:
        - name: e164
          in: path
          required: true
          description: Číslo ve formátu E.164. Plus se v URL kóduje jako `%2B`.
          schema:
            type: string
            pattern: '^\+[1-9]\d{6,14}$'
          example: '+420601234567'
        - name: include
          in: query
          required: false
          description: |
            Volitelná rozšíření payloadu, oddělená čárkou.

            | Hodnota | Vyžaduje scope |
            |---|---|
            | `reviews` | `lookup:reviews` |

            Pokud vám scope chybí, dostanete `403`. Nikdy nepřijde odpověď,
            ze které by pole jen tiše zmizelo.
          schema:
            type: string
          example: reviews
      responses:
        '200':
          description: Číslo nalezeno
          headers:
            RateLimit-Limit:
              schema: { type: integer }
              description: Měsíční kvóta
            RateLimit-Remaining:
              schema: { type: integer }
              description: Kolik zbývá v aktuálním období
            RateLimit-Reset:
              schema: { type: integer }
              description: Sekund do resetu kvóty
            Cache-Control:
              schema: { type: string }
              description: '`private, max-age=86400`'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LookupResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /reports:
    post:
      tags: [Reports]
      operationId: createReport
      summary: Nahlásit číslo
      description: |
        Přijme hlášení čísla. Volný text v reportu není, posílá se číslo,
        kategorie a volitelně štítek, který nastavil uživatel.

        Reporty jdou do moderační fronty. Do verdiktu se nezapočítají,
        dokud je člověk neschválí.

        ### Idempotence

        `external_id` je unikátní v rámci partnera. Pokud pošlete stejné
        `external_id` znovu, vrátíme původní záznam se stavem `200` a
        `status: duplicate`. Opakovat požadavek po timeoutu je proto
        bezpečné.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportInput'
            examples:
              marketing:
                summary: Telemarketingový hovor
                value:
                  e164: '+420601234567'
                  category: marketing
                  confidence: 0.92
                  occurred_at: '2026-09-06T10:14:00Z'
                  external_id: call_8f3a91
              legitimate:
                summary: Doručovatel, štítek nastavil uživatel
                value:
                  e164: '+420733112233'
                  category: doruceni
                  label: 2
                  confidence: 0.88
                  occurred_at: '2026-09-06T11:02:00Z'
                  external_id: call_8f3a92
      responses:
        '201':
          description: Report přijat
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportAccepted'
        '200':
          description: Duplicitní `external_id`. Vrací původní záznam
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportDuplicate'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          description: Web obsluhující danou zemi je nedostupný. Zkuste to znovu.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /usage:
    get:
      tags: [Account]
      operationId: getUsage
      summary: Spotřeba a kvóty
      description: Vrátí, kolik kvóty jste v probíhajícím období spotřebovali. Tento dotaz se do kvóty nepočítá.
      responses:
        '200':
          description: Stav kvót
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /deletions:
    get:
      tags: [Account]
      operationId: getDeletions
      summary: Čísla k promazání z cache
      description: |
        Čísla, u kterých proběhl výmaz na žádost, ze všech trhů dohromady.
        Partner je musí odstranit ze své cache do 72 hodin.

        Stránkuje se kurzorem. Vezměte `deleted_at` z posledního záznamu
        a pošlete ho jako `since` v dalším dotazu.
      parameters:
        - name: since
          in: query
          required: false
          description: ISO 8601. Výchozí je 30 dní zpět.
          schema:
            type: string
            format: date-time
          example: '2026-09-01T00:00:00Z'
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 200
      responses:
        '200':
          description: Seznam výmazů
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletionList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API klíč vydaný Kdotovolal.cz. Klíče začínají `ktv_live_`
        (produkce) nebo `ktv_test_` (testovací).

  schemas:
    Verdict:
      type: string
      enum: [bad, neutral, safe, unknown]
      description: |
        | Hodnota | Význam |
        |---|---|
        | `bad` | Nebrat. Nejméně 60 % negativních hodnocení |
        | `neutral` | Neutrální. Žádná strana nemá převahu |
        | `safe` | Bezpečné. Nejméně 60 % pozitivních hodnocení |
        | `unknown` | Zatím bez hodnocení |

    Category:
      type: string
      enum:
        - marketing
        - obchodni_nabidka
        - pruzkum
        - vymahani
        - doruceni
        - overeni_identity
        - robot
        - podvod
        - legitimni
      description: |
        Kategorie popisují, o jaký hovor šlo, ne jak byl špatný.

        Výjimka je `podvod`. To není popis, ale obvinění, používejte ji
        jen tam, kde máte jistotu.

    Label:
      type: integer
      enum: [1, 2, 3]
      description: '`1` Nebrat, `2` Neutrální, `3` Bezpečné'

    LookupResult:
      type: object
      required: [e164, country, verdict, reviews_count, labels]
      properties:
        e164:
          type: string
          example: '+420601234567'
        country:
          type: string
          description: ISO 3166-1 alpha-2
          example: CZ
        verdict:
          $ref: '#/components/schemas/Verdict'
        rating:
          type: [number, 'null']
          minimum: 1
          maximum: 5
          description: Průměr spočítaný ze štítků. Bez hodnocení je `null`.
          example: 1.4
        reviews_count:
          type: integer
          example: 23
        labels:
          type: object
          description: Rozložení štítků
          required: [bad, neutral, safe]
          properties:
            bad: { type: integer, example: 19 }
            neutral: { type: integer, example: 3 }
            safe: { type: integer, example: 1 }
        last_activity:
          type: [string, 'null']
          format: date-time
          description: Čas posledního hodnocení
          example: '2026-09-05T18:42:11Z'
        url:
          type: [string, 'null']
          format: uri
          description: Veřejný detail čísla
        reviews:
          type: array
          description: Pouze s `include=reviews` a scope `lookup:reviews`.
          items:
            $ref: '#/components/schemas/Review'

    Review:
      type: object
      properties:
        label:
          $ref: '#/components/schemas/Label'
        content:
          type: string
        upvotes:
          type: integer
        created_at:
          type: string
          format: date-time

    ReportInput:
      type: object
      required: [e164, category, external_id]
      properties:
        e164:
          type: string
          pattern: '^\+[1-9]\d{6,14}$'
          example: '+420601234567'
        category:
          $ref: '#/components/schemas/Category'
        label:
          $ref: '#/components/schemas/Label'
          description: |
            Posílejte ho jen tehdy, když ho uživatel nastavil sám.
            Bez něj se štítek odvodí z kategorie na naší straně.
        confidence:
          type: number
          minimum: 0
          maximum: 1
          description: Jistota klasifikace. Nízké hodnoty filtrujeme.
          example: 0.92
        occurred_at:
          type: string
          format: date-time
          description: Kdy hovor proběhl
        external_id:
          type: string
          maxLength: 48
          description: Idempotenční klíč, unikátní v rámci partnera
          example: call_8f3a91

    ReportAccepted:
      type: object
      properties:
        status: { type: string, const: accepted }
        report_id: { type: integer }
        external_id: { type: string }
        moderation:
          type: string
          const: pending
          description: Report čeká na schválení a zatím neovlivňuje verdikt.

    ReportDuplicate:
      type: object
      properties:
        status: { type: string, const: duplicate }
        report_id: { type: integer }
        external_id: { type: string }

    Usage:
      type: object
      properties:
        partner: { type: string }
        plan: { type: string, example: partner_basic }
        scopes:
          type: array
          items: { type: string }
          example: ['lookup:read', 'reports:write', 'deletions:read']
        period: { type: string, example: '2026-09' }
        reset_at: { type: string, format: date-time }
        lookups: { $ref: '#/components/schemas/QuotaCounter' }
        reports: { $ref: '#/components/schemas/QuotaCounter' }
        rate_limit_rps: { type: integer, example: 10 }

    QuotaCounter:
      type: object
      properties:
        used: { type: integer, example: 1284 }
        quota:
          type: [integer, 'null']
          description: '`null` znamená neomezeno'
          example: 5000
        remaining:
          type: [integer, 'null']
          example: 3716

    DeletionList:
      type: object
      properties:
        since: { type: string, format: date-time }
        count: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              e164: { type: string, example: '+420601234567' }
              deleted_at: { type: string, format: date-time }

    Problem:
      type: object
      description: RFC 9457 Problem Details
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: Stabilní identifikátor typu chyby
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }

  responses:
    BadRequest:
      description: Neplatný požadavek
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://kdotovolal.cz/api/errors/invalid-request
            title: Invalid request
            status: 400
            detail: 'Neplatný formát čísla. Očekává se E.164, například +420601234567.'

    Unauthorized:
      description: Klíč chybí nebo je neplatný
      headers:
        WWW-Authenticate:
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://kdotovolal.cz/api/errors/unauthorized
            title: Unauthorized
            status: 401
            detail: 'Chybí nebo je neplatný API klíč. Použijte hlavičku Authorization: Bearer <klíč>.'

    InsufficientScope:
      description: Klíč nemá potřebný scope
      headers:
        WWW-Authenticate:
          schema: { type: string }
          description: 'Např. `Bearer error="insufficient_scope", scope="lookup:reviews"`'
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Problem'
              - type: object
                properties:
                  required_scope: { type: string }
          example:
            type: https://kdotovolal.cz/api/errors/insufficient-scope
            title: Insufficient scope
            status: 403
            detail: 'Parametr include vyžaduje scope lookup:reviews.'
            required_scope: 'lookup:reviews'

    NotFound:
      description: Číslo v databázi není, nebo bylo vymazáno
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://kdotovolal.cz/api/errors/not-found
            title: Not found
            status: 404
            detail: 'Číslo v databázi není.'

    RateLimited:
      description: Došel burst limit nebo měsíční kvóta
      headers:
        Retry-After:
          schema: { type: integer }
          description: Sekund do dalšího pokusu
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          examples:
            burst:
              summary: Burst limit
              value:
                type: https://kdotovolal.cz/api/errors/rate-limit-exceeded
                title: Rate limit exceeded
                status: 429
                detail: 'Překročen limit 10 požadavků za sekundu.'
            quota:
              summary: Měsíční kvóta
              value:
                type: https://kdotovolal.cz/api/errors/quota-exceeded
                title: Quota exceeded
                status: 429
                detail: 'Měsíční kvóta 5000 vyčerpána. Obnova 1. 10. 2026 UTC.'
                quota: 5000
                period: '2026-09'
