openapi: 3.1.0
info:
  title: Carrier Lookup API
  version: "1.0.0"
  summary: Phone-carrier intel for emerging markets.
  description: |
    Carrier-identification API specialised for IN / VN / BD / PK / EG / NG / PH where
    Twilio Lookup has known gaps. 5× cheaper than Twilio Lookup for carrier-ID at
    $0.001/call (prefix) or $0.005/call (HLR-verified).

    ## Authentication

    All `/v1/*` endpoints require a Bearer token. Issue keys at the dashboard:
    `Authorization: Bearer carrier_live_<32-char-base32>`.

    Keys are shown **once** on issuance; only a salted SHA-256 hash is stored.

    ## Two-category error model

    Errors come back in one of two shapes:

    - **Gateway errors** (auth, rate-limit, quota, malformed body, infrastructure) —
      HTTP status carries the category; body has `error` envelope at the TOP LEVEL.
    - **Worker errors** (input validation, plan restrictions) — HTTP `200` with
      `payload` absent and `error` populated INSIDE the body.

    Integration recipe: check the HTTP status first; if 200, additionally check
    whether `error` is populated before reading `payload`.

    ## Rate limits and quotas

    Per-minute request limits enforced per API key (not per IP). Monthly unit caps
    enforced per UTC calendar month. See plan tiers below.
  contact:
    name: Mohammed
    email: support@carrier-lookup.com
    url: https://github.com/Unluckyathecking/carrier-lookup
  license:
    name: Proprietary
    identifier: LicenseRef-Carrier-Lookup-Proprietary
servers:
  - url: https://api.carrier-lookup.com
    description: Production
  - url: https://88.99.84.253.sslip.io
    description: Test environment (transient — do NOT use for production traffic)

security:
  - bearerAuth: []

tags:
  - name: lookups
    description: Phone-number → carrier intel
  - name: account
    description: Read-only account state
  - name: ops
    description: Operational endpoints (no auth)

paths:
  /healthz:
    get:
      tags: [ops]
      summary: Process liveness probe
      description: |
        Returns 200 if the gateway process is accepting connections. Does NOT check
        downstream dependencies — use `/readyz` for "ready to serve customer traffic."
      security: []
      responses:
        "200":
          description: Process alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                required: [status]
              example:
                status: ok

  /readyz:
    get:
      tags: [ops]
      summary: Service readiness probe
      description: |
        Probes Postgres, Redis, and the worker in parallel (each with a 2s budget).
        Returns 200 only when all three are reachable.

        Side-effect-free: doesn't consume quota, doesn't go through auth, doesn't
        appear in the request_duration histogram. `Cache-Control: no-store` so
        load balancers don't cache the response.

        Failure messages use a controlled vocabulary (`unreachable`, `timeout`) —
        never raw connection strings or internal hostnames.
      security: []
      responses:
        "200":
          description: All probes healthy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReadinessView" }
              example:
                status: ok
                checks: { postgres: ok, redis: ok, worker: ok }
        "503":
          description: At least one probe failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReadinessView" }
              example:
                status: degraded
                checks: { postgres: ok, redis: timeout, worker: ok }
                error: "redis: timeout"

  /v1/lookup:
    post:
      tags: [lookups]
      summary: Single-number lookup
      description: |
        Identifies carrier, country, and mobile-vs-landline for one E.164 (or national-
        formatted with `country` hint) phone number.

        **Billing**: 1 unit per prefix lookup; 5 units per HLR-verified lookup
        (`verify: true`, paid plans only).

        Invalid numbers cost 0 units (the worker emits `INVALID_NUMBER` and the gateway
        does not record a usage event).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LookupRequest" }
            examples:
              IN:
                summary: India mobile (E.164)
                value: { number: "+919876543210" }
              PK_national:
                summary: Pakistan, national format with country hint
                value: { number: "03001234567", country: PK }
              PK_verified:
                summary: HLR-verified (paid plans only)
                value: { number: "+923001234567", verify: true }
      responses:
        "200":
          description: Lookup result (may contain a worker `error` envelope).
          headers:
            X-Billable-Units:
              schema: { type: integer, minimum: 1 }
              description: Units this call consumed. Absent on worker error.
            X-Units-Label:
              schema: { type: string }
              description: 'Always "lookups" for this product.'
            X-RateLimit-Limit:
              schema: { type: integer, minimum: 1 }
              description: Per-minute request cap for the caller's plan.
            X-RateLimit-Remaining:
              schema: { type: integer, minimum: 0 }
              description: Requests left in the current 60s window AFTER this call.
            X-RateLimit-Reset:
              schema: { type: integer, format: int64 }
              description: |
                Unix-seconds when the sliding window's oldest entry slides off
                — conservative ceiling (`now + 60`) so a polite client retry
                always succeeds.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/LookupSuccess"
                  - $ref: "#/components/schemas/WorkerErrorEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimitedOrQuotaExceeded" }
        "502": { $ref: "#/components/responses/WorkerUnreachable" }

  /v1/lookup/bulk:
    post:
      tags: [lookups]
      summary: Bulk lookup (≤ 1000 numbers per call)
      description: |
        Identical semantics to `/v1/lookup`, batched over an array. The worker caps
        at 1000 numbers per call.

        **Billing**: sum of per-row units. A prefix bulk of 50 numbers bills 50;
        a verified bulk of 50 numbers bills 250. Invalid rows in the batch bill 0;
        an all-invalid batch still bills 1 (Crucible contract floor).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkRequest" }
      responses:
        "200":
          description: Bulk result + summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkSuccess" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimitedOrQuotaExceeded" }
        "502": { $ref: "#/components/responses/WorkerUnreachable" }

  /v1/usage:
    get:
      tags: [account]
      summary: Read-only self-check
      description: |
        Returns the customer's current plan, units used this calendar month (UTC),
        and remaining headroom. Read-only — does not bill.

        Counter resets at UTC month boundary. The handler reads the Redis counter
        and the wall clock from a single time sample so a poll at the boundary
        never reports inconsistent month/counter state.

        `Cache-Control: no-store` — counters change on every billable call.
      responses:
        "200":
          description: Current usage snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimitedOrQuotaExceeded" }

  /webhooks/stripe:
    post:
      tags: [ops]
      summary: Stripe webhook receiver (Stripe → gateway only — not for customer use)
      description: |
        Receives Stripe webhook events for subscription lifecycle (`customer.subscription.*`,
        `invoice.payment_succeeded`). Verifies the HMAC signature with 5-minute replay window.
        Idempotent per `event_id`.

        **Not part of the customer-facing API surface** — listed here so the schema is
        complete, but customers should never call this endpoint.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      parameters:
        - in: header
          name: Stripe-Signature
          required: true
          schema: { type: string }
      responses:
        "200": { description: Event processed (or de-duped) }
        "400": { description: Malformed body or bad signature }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: carrier_live_<32-base32-chars>

  schemas:
    LookupRequest:
      type: object
      required: [number]
      properties:
        number:
          type: string
          description: |
            The phone number. Accepts E.164 (starts with +) or national format
            (combine with `country`).
          examples: ["+919876543210", "03001234567"]
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: |
            ISO 3166-1 alpha-2 hint for parsing national-format input. Ignored
            when `number` starts with `+`.
          example: PK
        verify:
          type: boolean
          default: false
          description: |
            `false` → prefix lookup (1 unit). `true` → HLR-backed accuracy (5 units,
            paid plans only — free tier rejects with PLAN_RESTRICTION).

    BulkRequest:
      type: object
      required: [numbers]
      properties:
        numbers:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string }
          example: ["+919876543210", "+2348012345678", "03001234567"]
        country:
          type: string
          minLength: 2
          maxLength: 2
        verify:
          type: boolean
          default: false

    LookupResult:
      type: object
      properties:
        input: { type: string, description: "The number as you sent it" }
        e164: { type: string, description: "Normalized E.164 form (empty if input invalid)" }
        valid: { type: boolean }
        country: { type: string, description: "ISO 3166-1 alpha-2" }
        country_calling_code: { type: integer }
        carrier: { type: string, description: "Carrier brand name" }
        carrier_type:
          type: string
          enum:
            - mobile
            - landline
            - mobile_or_landline
            - voip
            - toll_free
            - premium_rate
            - shared_cost
            - personal
            - pager
            - uan
            - voicemail
            - unknown
        is_mobile:
          type: boolean
          description: "Convenience flag: true iff carrier_type is mobile or mobile_or_landline."
        is_mvno:
          type: boolean
          description: "True if the carrier is a Mobile Virtual Network Operator."
        verified:
          type: boolean
          description: "True when carrier was confirmed via HLR (only with `verify: true` AND the override exists)."
        source:
          type: string
          enum: [prefix, hlr, "cache:prefix", "cache:hlr"]
          description: "Where the answer came from."
        error:
          type: string
          description: "Per-row error in bulk responses (e.g. INVALID_NUMBER for one row out of 999)."
      required: [input, valid, is_mobile, verified, source]

    LookupSuccess:
      type: object
      required: [payload, billable_units, units_label]
      properties:
        payload: { $ref: "#/components/schemas/LookupResult" }
        billable_units:
          type: integer
          minimum: 1
          description: "How many units this call cost (1 for prefix, 5 for verified)."
        units_label:
          type: string
          enum: [lookups]

    BulkSuccess:
      type: object
      required: [payload, billable_units, units_label]
      properties:
        payload:
          type: object
          required: [results, summary]
          properties:
            results:
              type: array
              items: { $ref: "#/components/schemas/LookupResult" }
            summary:
              type: object
              required: [total, valid, invalid, billed_units]
              properties:
                total: { type: integer }
                valid: { type: integer }
                invalid: { type: integer }
                billed_units: { type: integer }
        billable_units: { type: integer, minimum: 1 }
        units_label: { type: string, enum: [lookups] }

    UsageView:
      type: object
      required: [customer_id, plan, units_used, monthly_unit_cap, rate_limit_per_minute, billing_month_utc]
      properties:
        customer_id: { type: string, format: uuid }
        plan:
          type: string
          enum: [free, starter, pro, business]
        units_used:
          type: integer
          format: int64
          minimum: 0
        monthly_unit_cap:
          type: integer
          format: int64
          description: "0 = unlimited (business tier)."
        units_remaining:
          type: integer
          format: int64
          description: "monthly_unit_cap − units_used, clamped at 0. Omitted when cap is 0."
        rate_limit_per_minute: { type: integer, minimum: 1 }
        billing_month_utc:
          type: string
          pattern: '^\d{4}-\d{2}$'
          example: "2026-05"

    ReadinessView:
      type: object
      required: [status, checks]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        checks:
          type: object
          required: [postgres, redis, worker]
          properties:
            postgres: { $ref: "#/components/schemas/ReadinessCheck" }
            redis:    { $ref: "#/components/schemas/ReadinessCheck" }
            worker:   { $ref: "#/components/schemas/ReadinessCheck" }
        error:
          type: string
          description: "First failing-check error, when degraded. Controlled vocab only."

    ReadinessCheck:
      type: string
      enum: [ok, timeout, unreachable]

    GatewayError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable]
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - RATE_LIMITED
                - QUOTA_EXCEEDED
                - BAD_REQUEST
                - WORKER_UNREACHABLE
                - WORKER_BAD_RESPONSE
                - INTERNAL
            message: { type: string }
            retryable: { type: boolean }

    WorkerErrorEnvelope:
      type: object
      required: [error, billable_units]
      properties:
        error:
          type: object
          required: [code, message, retryable]
          properties:
            code:
              type: string
              enum: [INVALID_NUMBER, BAD_REQUEST, PLAN_RESTRICTION]
            message: { type: string }
            retryable: { type: boolean }
        billable_units:
          type: integer
          minimum: 0
          description: "0 for invalid numbers (worker doesn't bill these)."
        units_label:
          type: string
          enum: [lookups]
          description: |
            Present on some worker error responses (the worker's InvokeResponse
            struct keeps the field at the top level alongside the error envelope,
            with `omitempty` — so it can be absent on errors but isn't guaranteed
            absent. Treat as optional. Minimax PR #33 P2.

  responses:
    Unauthorized:
      description: Missing, malformed, non-Bearer, or unknown/revoked key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/GatewayError" }
          example:
            error: { code: UNAUTHORIZED, message: "missing or invalid api key", retryable: false }

    BadRequest:
      description: Malformed JSON body or missing required field at the gateway layer.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/GatewayError" }

    RateLimitedOrQuotaExceeded:
      description: |
        Either RATE_LIMITED (per-minute) or QUOTA_EXCEEDED (monthly cap). Disambiguate via `error.code`.
        Rate-limit responses include `Retry-After: 60`.
      headers:
        Retry-After:
          schema: { type: integer }
          description: "Present on RATE_LIMITED (seconds)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/GatewayError" }

    WorkerUnreachable:
      description: Worker subprocess didn't respond — transient, retry with backoff.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/GatewayError" }
          example:
            error: { code: WORKER_UNREACHABLE, message: "worker unavailable", retryable: true }
