openapi: 3.1.0
info:
  title: Verify by Zecible - Identity verification API (KYC / KYB)
  version: 1.0.0
  description: >
    REST identity verification API for France.

    - **KYC** (`POST /api/person`): verifies an individual against a database of
      ~322M French individuals.

    - **KYB** (`POST /api/business`): verifies a company and its legal
      representatives against a database of ~58M French establishments.

    - **Usage** (`GET /api/usage`): reporting of billed consumption (pretax EUR)
      over a period.


    Each field is scored individually from 0 to 100 with an explicit status
    (`match`, `partial`, `no_match`, `not_searched`, `missing`). Scoring
    combines Levenshtein, Jaro-Winkler and Soundex for text fields, and a
    strict comparison for identifiers (SIRET, SIREN, NAF, email, phone, etc.).


    **Hashed input (MD5 / SHA256).** The identifier fields `email`, `mobile`,
    `landline`, `phone` (Person and Business) and `siren` (Business) accept
    either the clear-text value or its hexadecimal MD5 (32 characters) or
    SHA256 (64 characters) digest, detected automatically. Useful when you only
    hold anonymized data. A hashed field returns only `match` or `no_match`
    (never `partial`). The digest must be computed on the exact canonical form
    of the field (otherwise no match):

    - `email`: lowercase email (e.g. `jean.dupont@example.com`).

    - `mobile` / `landline` / `phone`: 10-digit French national number with the
      leading 0, without spaces or international prefix (e.g. `0612345678`).

    - `siren`: 9 raw digits (e.g. `908177587`).


    Normalization applied before hashing: strip spaces then lowercase. The
    `website` field is not supported in hashed form.
  contact:
    name: Zecible
    email: contact-web@zecible.pro
    url: https://www.zecible.fr/

servers:
  - url: https://{host}
    description: API host
    variables:
      host:
        default: verify.zecible.fr
        description: >
          Production host. Override this variable to target another
          environment.

security:
  - ApiKeyAuth: []

tags:
  - name: KYC
    description: Individual verification
  - name: KYB
    description: Company and executive verification
  - name: Usage
    description: Consumption reporting

paths:
  /api/person:
    post:
      tags: [KYC]
      summary: Verify an individual (KYC)
      description: >
        Verifies an individual's identity. `first_name` and `last_name` are
        required, plus at least one discriminating criterion (`birth_date`,
        full address, `email`, `mobile`, `landline` or `phone`).
      operationId: verifyPerson
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequest'
            examples:
              minimal:
                summary: Name + birth date
                value:
                  reference: tx-001
                  person:
                    first_name: Jean
                    last_name: Dupont
                    birth_date: '1980-05-15'
              complet:
                summary: Multi-channel
                value:
                  reference: tx-002
                  person:
                    first_name: Jean
                    last_name: Dupont
                    gender: M
                    birth_date: '1980-05-15'
                    address:
                      street: 12 rue de la Paix
                      city: PARIS
                      postal_code: '75001'
                    email: jean.dupont@example.com
                    mobile: '0612345678'
      responses:
        '200':
          description: Verification performed
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
            X-Credit-Limit: { $ref: '#/components/headers/X-Credit-Limit' }
            X-Credit-Used: { $ref: '#/components/headers/X-Credit-Used' }
            X-Credit-Remaining: { $ref: '#/components/headers/X-Credit-Remaining' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponse'
              example:
                transaction_id: verify_5dd7f1ad
                reference: tx-001
                score: 92
                ambiguous: false
                matches:
                  first_name: { status: match, score: 100 }
                  last_name: { status: partial, score: 87 }
                  birth_date: { status: match, score: 100 }
                timing_ms: 42
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/business:
    post:
      tags: [KYB]
      summary: Verify a company and its executives (KYB)
      description: >
        Verifies a company. At least `siret` OR `siren` OR
        (`name` + `address.city` OR `address.postal_code`) is required.
        Up to 10 executives per request: if the company is identified
        unambiguously, each executive is compared against all contacts
        attached to the SIRET (best-match).
      operationId: verifyBusiness
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BusinessRequest'
            examples:
              siret:
                summary: By SIRET
                value:
                  reference: tx-biz-001
                  business:
                    siret: '00000000000000'
              complet:
                summary: Legal name + address + executive
                value:
                  reference: tx-biz-002
                  business:
                    name: ACME SAS
                    legal_form: SAS
                    naf: 7311Z
                    address:
                      street: 12 rue de la Paix
                      city: PARIS
                      postal_code: '75001'
                    website: https://acme.example.com
                  executives:
                    - first_name: Jean
                      last_name: Dupont
                      role: président
      responses:
        '200':
          description: Verification performed
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
            X-Credit-Limit: { $ref: '#/components/headers/X-Credit-Limit' }
            X-Credit-Used: { $ref: '#/components/headers/X-Credit-Used' }
            X-Credit-Remaining: { $ref: '#/components/headers/X-Credit-Remaining' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BusinessResponse'
              example:
                transaction_id: verify_6a1ecede
                reference: tx-biz-002
                score: 100
                ambiguous: false
                matches:
                  business:
                    name: { status: match, score: 100 }
                    naf: { status: match, score: 100 }
                    city: { status: match, score: 100 }
                    website: { status: match, score: 100 }
                  executives:
                    - first_name: { status: match, score: 100 }
                      last_name: { status: match, score: 100 }
                      role: { status: partial, score: 80 }
                timing_ms: 67
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/usage:
    get:
      tags: [Usage]
      summary: Billed consumption over a period
      description: >
        Returns the consumption breakdown (per product) and the pretax total in
        EUR for the calling account. Default period: current month.
      operationId: getUsage
      parameters:
        - name: start
          in: query
          required: false
          schema: { type: string, format: date }
          description: Period start (YYYY-MM-DD). Default = first day of the current month.
        - name: stop
          in: query
          required: false
          schema: { type: string, format: date }
          description: Period end (YYYY-MM-DD). Default = last day of the current month.
      responses:
        '200':
          description: Consumption for the period
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
              example:
                period: { start: '2026-06-01', stop: '2026-06-30' }
                requests: 1240
                succeeded: 1198
                failed: 42
                total_pretax: 214.80
                currency: EUR
                products:
                  - { key: contact, label: 'Identity', count: 1198, cpm_pretax: 0.20, total_pretax: 143.76 }
                  - { key: email, label: 'Email', count: 473, cpm_pretax: 0.15, total_pretax: 71.04 }
                timing_ms: 54
        '401': { $ref: '#/components/responses/Unauthorized' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: >
        Personal API key (`vkey_live_...`). The calling IP must also be in the
        account's allowlist.

  headers:
    X-RateLimit-Limit:
      description: Requests-per-minute quota (0 = unlimited).
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window (-1 = unlimited).
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix UTC timestamp of the next window reset.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying (present on 429).
      schema: { type: integer }
    X-Credit-Limit:
      description: Credit cap of the most constraining budget (pretax EUR).
      schema: { type: string }
    X-Credit-Used:
      description: Credits already consumed over the period (pretax EUR).
      schema: { type: string }
    X-Credit-Remaining:
      description: Credits remaining over the period (pretax EUR).
      schema: { type: string }

  schemas:
    Address:
      type: object
      properties:
        street: { type: string, example: 12 rue de la Paix }
        city: { type: string, example: PARIS }
        postal_code: { type: string, example: '75001' }

    PersonRequest:
      type: object
      required: [person]
      properties:
        reference:
          type: string
          maxLength: 256
          description: Client identifier returned as-is in the response.
        match_rule:
          type: object
          description: >
            Optional boolean matching rule (Person only). Nested tree: a node is
            {"and":[...]} or {"or":[...]}; operands are field names (first_name,
            last_name, gender, birth_date, email, mobile, landline, phone, street,
            city, postal_code, or the alias address = street AND city AND postal_code)
            or sub-nodes. E.g. {"and":["first_name","last_name","phone",{"or":["birth_date","address"]}]}.
            A field is satisfied if its score >= min_score. Returns a rule.passed
            block. Malformed -> 400 invalid_match_rule.
        min_score:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
          description: >
            Threshold (1-100); a field counts as satisfied in match_rule when its
            score >= min_score. Default 50. Considered only if match_rule is
            provided. Out of range -> 400 invalid_min_score.
        person:
          type: object
          required: [first_name, last_name]
          properties:
            first_name: { type: string }
            last_name: { type: string }
            gender: { type: string, description: 'Civility (e.g. M, F).' }
            birth_date: { type: string, format: date, description: 'Strict ISO 8601 format (YYYY-MM-DD).' }
            address: { $ref: '#/components/schemas/Address' }
            email: { type: string, description: 'Clear-text email OR auto-detected MD5 (32 hex) / SHA256 (64 hex) digest.' }
            mobile: { type: string, description: 'Strict French mobile (06/07), clear-text OR MD5/SHA256 digest of the 10-digit national number.' }
            landline: { type: string, description: 'Strict French landline (01-05, 09), clear-text OR MD5/SHA256 digest of the 10-digit national number.' }
            phone: { type: string, description: 'French phone, type auto-detected (returns resolved_as). Clear-text OR MD5/SHA256 digest of the 10-digit national number (matched against mobile or landline).' }

    BusinessRequest:
      type: object
      required: [business]
      properties:
        reference:
          type: string
          maxLength: 256
        business:
          type: object
          properties:
            siret: { type: string, description: '14 digits.' }
            siren: { type: string, description: '9 digits, clear-text OR auto-detected MD5 (32 hex) / SHA256 (64 hex) digest.' }
            vat_intracom: { type: string, description: 'FR + 11 digits (derived and verified from the SIREN).' }
            name: { type: string, description: 'Legal name or trade name (fuzzy).' }
            legal_form: { type: string, example: SAS }
            naf: { type: string, example: 7311Z }
            address: { $ref: '#/components/schemas/Address' }
            email: { type: string, description: 'Clear-text email OR auto-detected MD5 (32 hex) / SHA256 (64 hex) digest.' }
            mobile: { type: string, description: 'Clear-text OR MD5/SHA256 digest of the 10-digit national number.' }
            landline: { type: string, description: 'Clear-text OR MD5/SHA256 digest of the 10-digit national number.' }
            phone: { type: string, description: 'Clear-text OR MD5/SHA256 digest of the 10-digit national number (matched against mobile or landline).' }
            website: { type: string }
        executives:
          type: array
          maxItems: 10
          items:
            type: object
            properties:
              first_name: { type: string }
              last_name: { type: string }
              role: { type: string, example: président }

    Match:
      type: object
      description: Field verification result.
      properties:
        status:
          type: string
          enum: [match, partial, no_match, not_searched, missing, invalid]
          description: >
            `match` (identical) · `partial` (similar) · `no_match`
            (different) · `not_searched` (the target lacks this field) ·
            `missing` (not provided by the client) · `invalid` (value provided
            but syntactically incorrect - field ignored for the search,
            `score: null`, `error` set).
        score:
          type: [integer, 'null']
          minimum: 0
          maximum: 100
        resolved_as:
          type: string
          enum: [mobile, landline]
          description: Type detected for the `phone` field only.

    PersonResponse:
      type: object
      properties:
        transaction_id: { type: string }
        reference: { type: [string, 'null'] }
        score: { type: [integer, 'null'], minimum: 0, maximum: 100, description: 'Capped at 50 if ambiguous=true.' }
        ambiguous: { type: boolean }
        matches:
          type: object
          properties:
            first_name: { $ref: '#/components/schemas/Match' }
            last_name: { $ref: '#/components/schemas/Match' }
            gender: { $ref: '#/components/schemas/Match' }
            birth_date: { $ref: '#/components/schemas/Match' }
            street: { $ref: '#/components/schemas/Match' }
            city: { $ref: '#/components/schemas/Match' }
            postal_code: { $ref: '#/components/schemas/Match' }
            email: { $ref: '#/components/schemas/Match' }
            mobile: { $ref: '#/components/schemas/Match' }
            landline: { $ref: '#/components/schemas/Match' }
            phone: { $ref: '#/components/schemas/Match' }
        timing_ms: { type: integer }
        rule:
          type: object
          description: Present only if match_rule is provided in the request. Rule verdict (does not alter matches or score).
          properties:
            passed: { type: boolean }
            min_score: { type: integer, description: Effective threshold applied (default 50). }

    BusinessResponse:
      type: object
      properties:
        transaction_id: { type: string }
        reference: { type: [string, 'null'] }
        score: { type: [integer, 'null'], minimum: 0, maximum: 100 }
        ambiguous: { type: boolean }
        matches:
          type: object
          properties:
            business:
              type: object
              properties:
                siret: { $ref: '#/components/schemas/Match' }
                siren: { $ref: '#/components/schemas/Match' }
                vat_intracom: { $ref: '#/components/schemas/Match' }
                name: { $ref: '#/components/schemas/Match' }
                legal_form: { $ref: '#/components/schemas/Match' }
                naf: { $ref: '#/components/schemas/Match' }
                street: { $ref: '#/components/schemas/Match' }
                city: { $ref: '#/components/schemas/Match' }
                postal_code: { $ref: '#/components/schemas/Match' }
                email: { $ref: '#/components/schemas/Match' }
                mobile: { $ref: '#/components/schemas/Match' }
                landline: { $ref: '#/components/schemas/Match' }
                phone: { $ref: '#/components/schemas/Match' }
                website: { $ref: '#/components/schemas/Match' }
            executives:
              type: array
              items:
                type: object
                properties:
                  first_name: { $ref: '#/components/schemas/Match' }
                  last_name: { $ref: '#/components/schemas/Match' }
                  role: { $ref: '#/components/schemas/Match' }
        timing_ms: { type: integer }

    UsageResponse:
      type: object
      properties:
        period:
          type: object
          properties:
            start: { type: string, format: date }
            stop: { type: string, format: date }
        requests: { type: integer }
        succeeded: { type: integer }
        failed: { type: integer }
        total_pretax: { type: number }
        currency: { type: string, example: EUR }
        products:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              count: { type: integer }
              cpm_pretax: { type: number }
              total_pretax: { type: number }
        timing_ms: { type: integer }

    Error:
      type: object
      properties:
        error: { type: string, description: 'Machine error code.' }
        message: { type: string, description: 'Human-readable message.' }
      required: [error, message]

  responses:
    BadRequest:
      description: >
        Invalid request. Codes: `invalid_body`, `invalid_reference`,
        `missing_required_fields`, `insufficient_data`, `invalid_birth_date`,
        `invalid_mobile`, `invalid_landline`, `invalid_phone`,
        `invalid_email`, `invalid_gender`, `invalid_siret`, `invalid_siren`,
        `invalid_match_rule`, `invalid_min_score`, `too_many_executives`.

        Note: since per-field tolerant validation, the codes
        `invalid_mobile`, `invalid_landline`, `invalid_phone`,
        `invalid_email`, `invalid_gender`, `invalid_siret`, `invalid_siren`
        and `invalid_birth_date` no longer produce a global 400 for optional
        fields. The offending field is returned with `status: invalid` (and
        `error` set) in `matches`, and the search continues with the
        remaining valid fields. A 400 `insufficient_data` occurs only if no
        valid discriminating criterion remains after filtering.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: insufficient_data, message: 'Insufficient data to run a verification (at least one discriminating criterion required: birth date, phone, email or address).' }
    Unauthorized:
      description: >
        Authentication failed. Codes: `missing_api_key`, `invalid_api_key`,
        `account_disabled`, `ip_not_allowed`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: invalid_api_key, message: 'Invalid API key.' }
    QuotaExceeded:
      description: Credit cap reached (monthly or depletable wallet).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: quota_exceeded, message: 'Monthly credit limit reached.' }
    MethodNotAllowed:
      description: Incorrect HTTP method (POST expected for person/business, GET for usage).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: method_not_allowed, message: 'POST expected.' }
    PayloadTooLarge:
      description: Request body larger than 32 KB.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: body_too_large, message: 'Request body too large (max 32768 bytes).' }
    RateLimited:
      description: Requests-per-minute quota exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: rate_limit_exceeded, message: 'Rate limit of 60 requests per minute reached. Try again in 30s.' }
