openapi: 3.1.0
info:
  title: 3am API
  version: 0.4.0
  description: |
    Screen Abuja (FCT) Certificates of Occupancy against a reference format, follow each check to its result,
    and top up your balance with Paystack.

    Authenticate every request with your API key in the `x-api-key` header. All money fields ending in `_naira`
    are integer **kobo** (₦1 = 100).

    A result is an automated screening, not proof of title, ownership or authenticity. Official registry
    verification remains necessary.
servers:
  - url: https://api.team3am.app
security:
  - apiKey: []
tags:
  - name: Verifications
    description: Submit a document and get its result.
  - name: Balance and top-ups
    description: Each check is charged from your balance. Top up with Paystack.
  - name: Webhooks
    description: Get notified when a check finishes.
components:
  securitySchemes:
    apiKey: { type: apiKey, in: header, name: x-api-key }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_request, unsupported_document, unauthorized, insufficient_credit,
                     company_suspended, not_found, duplicate_reference, payment_provider_error, internal_error]
            message: { type: string }
    Transaction:
      type: object
      required: [reference, amount_naira, fee_naira, total_naira, currency, status, paid_at, created_at]
      properties:
        reference: { type: string, example: WALLET_0482913 }
        amount_naira: { type: integer, description: Kobo added to your balance, example: 100000 }
        fee_naira: { type: integer, description: Kobo. Paystack's processing fee, example: 1523 }
        total_naira: { type: integer, description: Kobo paid (amount plus fee), example: 101523 }
        currency: { type: string, enum: [NGN] }
        status:
          type: string
          enum: [pending, success, failed]
          description: "`success` once the payment is confirmed and your balance has been credited."
        paid_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
    Metadata:
      type: object
      additionalProperties: false
      required: [client_reference, document_type, state, document_number]
      properties:
        client_reference: { type: string, pattern: '^[A-Za-z0-9._:/-]{1,64}$', description: Your own unique reference for this check, example: PZ-2026-000123 }
        document_type: { type: string, enum: [cofo] }
        state: { type: string, enum: [abj] }
        document_number: { type: string, minLength: 1, maxLength: 64, example: FCT/ABU/XX.0000 }
    Accepted:
      type: object
      required: [verification_id, client_reference, status, stage, cost_naira, balance_naira]
      properties:
        verification_id: { type: string, example: ver_Q2x9kLm0PzR7tVb1 }
        client_reference: { type: string }
        status: { type: string, const: queued }
        stage: { type: string, const: accepted }
        cost_naira: { type: integer, description: Kobo charged, example: 250 }
        balance_naira: { type: integer, description: Kobo remaining after the charge, example: 750 }
    Finding:
      type: object
      required: [check, field, expected, found, severity]
      properties:
        check: { type: string }
        field: { type: string }
        expected: { type: string }
        found: { type: string }
        severity: { type: string, enum: [low, medium, high] }
    Verification:
      type: object
      description: Result fields appear once `status` is `completed`.
      required: [verification_id, client_reference, document_type, state, status, created_at]
      properties:
        verification_id: { type: string }
        client_reference: { type: string }
        document_type: { type: string }
        state: { type: string }
        status: { type: string, enum: [queued, processing, completed, failed] }
        stage:
          type: string
          enum: [accepted, ai_queued, tamper_check, analysis]
          description: Progress while `queued` or `processing`.
        verdict:
          type: [string, 'null']
          enum: [authentic, suspicious, likely_forged, unsupported_document, null]
          description: "`authentic` means no anomalies were found. It is not proof of authenticity."
        score: { type: [number, 'null'], minimum: 0, maximum: 100, description: 'Risk score: 0 = no risk found, higher = more risk.' }
        summary: { type: [string, 'null'] }
        findings: { type: array, items: { $ref: '#/components/schemas/Finding' } }
        disclaimer: { type: string }
        error:
          type: object
          description: Present when `status` is `failed`. The charge for a failed check is refunded.
          required: [code, message]
          properties:
            code: { type: string, enum: [tamper_check_failed, processing_failed, timeout] }
            message: { type: string }
        created_at: { type: string, format: date-time, example: '2026-10-19T09:30:00Z' }
        completed_at: { type: [string, 'null'], format: date-time }
  responses:
    Unauthorized:
      description: "The `x-api-key` header is missing, or the key is invalid or revoked."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: "Missing or invalid API key." } }
    Suspended:
      description: "Your company is suspended. Contact 3am."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: company_suspended, message: "This company is suspended." } }
    InsufficientCredit:
      description: "Your balance is below the cost of a check. Top up and try again."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: insufficient_credit, message: "Not enough credit for this verification." } }
    DuplicateReference:
      description: "You already used this `client_reference`. You are not charged again; look up the existing verification instead."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: duplicate_reference, message: "A verification with this client_reference already exists." } }
    VerificationNotFound:
      description: "No verification with this ID or reference for your company."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: "Verification not found." } }
    ServerError:
      description: "Something went wrong on our side. Retry with the same `client_reference`: if the first attempt went through, you get 409 and are not charged twice."
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: internal_error, message: "Something went wrong." } }
paths:
  /v1/verifications:
    post:
      operationId: submitVerification
      tags: [Verifications]
      summary: Submit a document
      description: |
        The check runs in the background: poll the verification or set a webhook to get the result. A
        `client_reference` you have already used returns 409 and is not charged again.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, metadata]
              properties:
                file:
                  type: string
                  format: binary
                  description: PDF, JPEG or PNG, up to 10 MB.
                metadata:
                  type: string
                  description: JSON string matching the Metadata schema.
      responses:
        '202': { description: Accepted and charged, content: { application/json: { schema: { $ref: '#/components/schemas/Accepted' } } } }
        '400':
          description: The file or metadata is invalid, or the document type and state are not supported.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalid_request: { value: { error: { code: invalid_request, message: "File must be a PDF, JPEG or PNG." } } }
                unsupported_document: { value: { error: { code: unsupported_document, message: "This document type and state combination is not supported." } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredit' }
        '403': { $ref: '#/components/responses/Suspended' }
        '409': { $ref: '#/components/responses/DuplicateReference' }
        '500': { $ref: '#/components/responses/ServerError' }
    get:
      operationId: findVerification
      tags: [Verifications]
      summary: Find a verification by client_reference
      parameters:
        - { name: client_reference, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: The verification, content: { application/json: { schema: { $ref: '#/components/schemas/Verification' } } } }
        '400':
          description: "`client_reference` is missing or invalid."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_request, message: "Provide a valid `client_reference` query parameter." } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
        '404': { $ref: '#/components/responses/VerificationNotFound' }
  /v1/verifications/{verification_id}:
    get:
      operationId: getVerification
      tags: [Verifications]
      summary: Get a verification
      description: Its status, and its result once finished.
      parameters:
        - { name: verification_id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: The verification, content: { application/json: { schema: { $ref: '#/components/schemas/Verification' } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
        '404': { $ref: '#/components/responses/VerificationNotFound' }
  /v1/balance:
    get:
      operationId: getBalance
      tags: [Balance and top-ups]
      summary: Get your balance
      responses:
        '200':
          description: Current balance
          content: { application/json: { schema: { type: object, required: [balance_naira], properties: { balance_naira: { type: integer, description: Kobo, example: 750 } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
  /v1/transactions:
    post:
      operationId: startTopup
      tags: [Balance and top-ups]
      summary: Start a top-up
      description: |
        Returns an `access_code` for Paystack's checkout: open it with `@paystack/inline-js` using
        `new PaystackPop().resumeTransaction(access_code)`. Paystack's processing fee is added on top, so your
        balance goes up by the full `amount_naira`. Once paid, poll the transaction until `status` is `success`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_naira]
              properties:
                amount_naira: { type: integer, minimum: 10000, maximum: 10000000, description: 'Kobo to add to your balance (₦100 to ₦100,000)', example: 100000 }
      responses:
        '201':
          description: Top-up started
          content:
            application/json:
              schema:
                type: object
                required: [reference, access_code, amount_naira, fee_naira, total_naira, currency, status]
                properties:
                  reference: { type: string, example: WALLET_0482913 }
                  access_code: { type: string, description: Opens Paystack's checkout }
                  amount_naira: { type: integer, description: Kobo added to your balance once paid, example: 100000 }
                  fee_naira: { type: integer, description: Kobo. Paystack's processing fee, example: 1523 }
                  total_naira: { type: integer, description: Kobo to pay, example: 101523 }
                  currency: { type: string, enum: [NGN] }
                  status: { type: string, enum: [pending] }
        '400':
          description: "The amount is outside ₦100 to ₦100,000, or your company has no billing email on file."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_request, message: "Send `{ \"amount_naira\": <kobo> }` between 10000 (₦100) and 10000000 (₦100,000)." } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
        '502':
          description: "Paystack could not start the payment. Nothing was charged; try again."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: payment_provider_error, message: "Paystack could not start the payment. Please try again." } }
  /v1/transactions/{reference}:
    get:
      operationId: getTopup
      tags: [Balance and top-ups]
      summary: Get a top-up
      parameters:
        - { name: reference, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The top-up
          content: { application/json: { schema: { $ref: '#/components/schemas/Transaction' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
        '404':
          description: "No top-up with this reference for your company."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: not_found, message: "Transaction not found." } }
  /v1/webhook:
    put:
      operationId: setWebhook
      tags: [Webhooks]
      summary: Set your webhook URL
      description: |
        Setting a URL returns a new signing secret, shown only once. Send `{ "url": null }` to turn webhooks
        off. The URL must be public HTTPS.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [url]
              properties:
                url: { type: [string, 'null'], maxLength: 2048, example: 'https://hooks.example.com/3am' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url: { type: [string, 'null'] }
                  secret: { type: string, description: "Shown once, only when a URL was set", example: whsec_… }
        '400':
          description: "The body is not `{ \"url\": ... }`, or the URL is not public HTTPS."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: invalid_request, message: "Send `{ \"url\": \"https://...\" }` or `{ \"url\": null }`." } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Suspended' }
webhooks:
  verification:
    post:
      operationId: verificationWebhook
      tags: [Webhooks]
      summary: Verification finished
      description: |
        Sent when a verification is completed or failed. Check `x-3am-signature` before trusting the body:
        it is `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your signing
        secret. Use `x-3am-delivery` to ignore duplicates. Reply with any 2xx within 10 seconds, or the
        delivery is retried up to twice.
      parameters:
        - { name: x-3am-event, in: header, required: true, schema: { type: string, enum: [verification.completed, verification.failed] } }
        - { name: x-3am-delivery, in: header, required: true, schema: { type: string, example: dlv_ver_Q2x9kLm0PzR7tVb1 } }
        - { name: x-3am-signature, in: header, required: true, schema: { type: string, example: 'sha256=5d41402abc4b2a76b9719d911017c592' } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [event, sent_at, data]
              properties:
                event: { type: string, enum: [verification.completed, verification.failed] }
                sent_at: { type: string, format: date-time }
                data: { $ref: '#/components/schemas/Verification' }
      responses:
        '200': { description: Any 2xx acknowledges the delivery. }
