openapi: 3.1.0
info:
  title: Dev54 Decisioning API
  version: "1.0.0"
  description: |
    The decisioning brain between every African payment rail and its compliance obligations.

    ## One endpoint: `POST /v1/events`

    Every money-movement event — card spend, crypto sell, bank deposit, settlement,
    transfer, P2P, VAS — flows through a single endpoint. A `mode` field decides
    whether Dev54 acts as an inline gate, an advisory observer, or a firehose:

    | Mode | Use when | Returns | Side effects |
    |---|---|---|---|
    | `enforce` | You need a verdict **before** the money moves (withdrawal gate, payout, signup). | `allow` / `review` / `challenge` / `block` + score + actions, in <300ms. | RFI link, account hold, alerts, webhooks. |
    | `observe` | The flow can't block inline (card auth, settlement, webhook ingest) but you still want a verdict and full enrichment. | Verdict + `would_have` (advisory), no enforcement. | Persists event + decision row; no holds, no RFI. |
    | `ingest` | Pure firehose. The money already moved, you're feeding the brain. **Default**. | Async ack (`202`), no verdict in body. | Rules + screening run on a queue. |

    **Removed in v1:** `POST /v1/decisions`, `POST /v1/transactions`, and
    `POST /v1/transactions/batch` now return `410 Gone`. Replace with
    `POST /v1/events` plus a `mode` field — body shape is otherwise unchanged.
    Batch ingest moved to `POST /v1/events` with `{ mode: "ingest", events: [...] }`.

    If Dev54 doesn't see an event, it can't reason about it. Send every flow.

    **Auth:** OAuth2 client-credentials. Exchange `client_id` + `client_secret`
    at `POST /api/oauth/token` for a 1-hour Bearer JWT, then send
    `Authorization: Bearer <access_token>` on every API call.
  contact: { name: Dev54 Developer Support, email: developers@dev54.dev }

servers:
  - url: https://dev54.dev
    description: Production
security:
  - oauth2ClientCredentials: []
tags:
  - { name: OAuth, description: Token issuance }
  - { name: Events, description: "The one money-movement endpoint. Modes: enforce, observe, ingest." }
  - { name: Screening, description: Sanctions & PEP screening }
  - { name: KYC, description: Identity verification }
  - { name: Alerts, description: AML alerts }
  - { name: Cases, description: Investigations }
  - { name: SARs, description: Suspicious Activity Reports }


paths:
  /api/oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange client credentials for an access token
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type: { type: string, enum: [client_credentials] }
                client_id: { type: string, example: dev54_live_abc123def456 }
                client_secret: { type: string }
                scope: { type: string, example: "ingest:write alerts:read" }
      responses:
        "200":
          description: Access token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, example: 3600 }
                  scope: { type: string }
  /api/v1/customers:
    post:
      tags: [Events]
      summary: Upsert a customer
      security: [{ oauth2ClientCredentials: ["ingest:write"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomerInput" }
            example:
              external_id: user_98231
              full_name: Adaeze Okafor
              email: adaeze@example.com
              primary_phone: "+2348012345678"
              date_of_birth: "1992-04-17"
              country: NG
              kyc_tier: tier_2
              kyc_source: smile_id
              pep: false
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/IdResponse" } } } }
        "200": { description: Updated existing }
  /api/v1/events:
    post:
      tags: [Events]
      summary: Send a money-movement event (one endpoint, three modes)
      description: |
        - `mode: "enforce"` — inline gate. Verdict in <300ms, may hold/RFI/block.
        - `mode: "observe"` — verdict only, never enforces (use for card spend, settlements, webhook ingest).
        - `mode: "ingest"` — fire-and-forget firehose, async eval, returns 202. **Default.**

        Idempotent on `external_id`. On enforce/observe also accepts `Idempotency-Key`.
        Batch ingest: `{ "mode": "ingest", "events": [...] }` (max 500).
      security: [{ oauth2ClientCredentials: ["ingest:write", "decisions:write"] }]
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 120 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
            examples:
              enforce:
                value: { mode: enforce, external_id: txn_abc123, customer_external_id: user_98231, amount: 250000, currency: NGN, txn_type: withdrawal, channel: bank, counterparty_country: NG, occurred_at: "2026-06-12T14:32:11Z" }
              observe:
                value: { mode: observe, external_id: card_auth_998, customer_external_id: user_98231, amount: 12500, currency: NGN, txn_type: payment, channel: card }
              ingest:
                value: { mode: ingest, external_id: deposit_evt_42, customer_external_id: user_98231, amount: 100000, currency: NGN, txn_type: deposit, channel: bank }
              batch:
                value:
                  mode: ingest
                  events:
                    - { external_id: evt_1, customer_external_id: user_98231, amount: 5000, currency: NGN, txn_type: payment, channel: card }
                    - { external_id: evt_2, customer_external_id: user_44102, amount: 150000, currency: NGN, txn_type: transfer, channel: momo }
      responses:
        "200": { description: "Verdict (enforce/observe) or duplicate ingest replayed." }
        "201": { description: "Batch ingest accepted." }
        "202": { description: "Single ingest accepted, async evaluation queued." }
        "400": { description: Validation error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: Missing or invalid token }
        "403": { description: "Token lacks `ingest:write` or `decisions:write` scope" }
        "404": { description: "`customer_external_id` not found — upsert via `/v1/customers` first." }
  /api/v1/decisions:
    post:
      tags: [Events]
      summary: "[REMOVED] Use POST /v1/events with mode=\"enforce\""
      deprecated: true
      description: "Removed. Returns `410 Gone`. Replace with `POST /v1/events` and `mode: \"enforce\"` (or `\"observe\"`)."
      responses:
        "410": { description: "Endpoint removed. See `/v1/events`." }
  /api/v1/transactions:
    post:
      tags: [Events]
      summary: "[REMOVED] Use POST /v1/events (mode defaults to \"ingest\")"
      deprecated: true
      description: "Removed. Returns `410 Gone`. Replace with `POST /v1/events` — `mode` defaults to `\"ingest\"`."
      responses:
        "410": { description: "Endpoint removed. See `/v1/events`." }
  /api/v1/transactions/batch:
    post:
      tags: [Events]
      summary: "[REMOVED] Use POST /v1/events with { events: [...] }"
      deprecated: true
      description: "Removed. Returns `410 Gone`. Replace with `POST /v1/events` and `{ mode: \"ingest\", events: [...] }` (max 500)."
      responses:
        "410": { description: "Endpoint removed. See `/v1/events`." }
  /api/v1/screening/search:
    post:
      tags: [Screening]
      summary: Run name screening against sanctions, PEP, and adverse media lists
      security: [{ oauth2ClientCredentials: ["screening:run"] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: "Olusegun Adebayo" }
                country: { type: string, example: "NG", minLength: 2, maxLength: 2 }
                date_of_birth: { type: string, format: date }
                entity_type: { type: string, enum: [individual, organization], default: individual }
                threshold: { type: number, minimum: 0, maximum: 1, default: 0.85 }
      responses:
        "200":
          description: Match list (sorted by score)
          content:
            application/json:
              schema:
                type: object
                properties:
                  match_count: { type: integer }
                  matches:
                    type: array
                    items:
                      type: object
                      properties:
                        score: { type: number }
                        full_name: { type: string }
                        source: { type: string }
                        sanctions_program: { type: string }
                        pep_role: { type: string }
                        country: { type: string }
  /api/v1/alerts:
    get:
      tags: [Alerts]
      summary: List alerts
      security: [{ oauth2ClientCredentials: ["alerts:read"] }]
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [open, in_review, escalated, closed_confirmed, closed_false_positive] } }
        - { in: query, name: severity, schema: { type: string, enum: [low, medium, high, critical] } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { in: query, name: cursor, schema: { type: string, format: date-time } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/AlertList" } } } }
    post:
      tags: [Alerts]
      summary: Create an external alert
      security: [{ oauth2ClientCredentials: ["alerts:write"] }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/AlertCreate" } } } }
      responses: { "201": { description: Created } }
  /api/v1/alerts/{alertId}:
    get:
      tags: [Alerts]
      summary: Fetch an alert
      security: [{ oauth2ClientCredentials: ["alerts:read"] }]
      parameters: [{ in: path, name: alertId, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
    patch:
      tags: [Alerts]
      summary: Update alert disposition
      security: [{ oauth2ClientCredentials: ["alerts:write"] }]
      parameters: [{ in: path, name: alertId, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [open, in_review, escalated, closed_confirmed, closed_false_positive] }
                resolution_note: { type: string, maxLength: 2000 }
      responses: { "200": { description: Updated } }
  /api/v1/cases:
    get:
      tags: [Cases]
      summary: List cases
      security: [{ oauth2ClientCredentials: ["cases:read"] }]
      parameters:
        - { in: query, name: status, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, default: 50 } }
        - { in: query, name: cursor, schema: { type: string } }
      responses: { "200": { description: OK } }
  /api/v1/cases/{caseId}:
    get:
      tags: [Cases]
      summary: Fetch a case
      security: [{ oauth2ClientCredentials: ["cases:read"] }]
      parameters: [{ in: path, name: caseId, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
    patch:
      tags: [Cases]
      summary: Update a case
      security: [{ oauth2ClientCredentials: ["cases:write"] }]
      parameters: [{ in: path, name: caseId, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string }
                priority: { type: string, enum: [low, medium, high, critical] }
                resolution_summary: { type: string }
      responses: { "200": { description: Updated } }
  /api/v1/sars:
    get:
      tags: [SARs]
      summary: List SARs
      security: [{ oauth2ClientCredentials: ["sars:read"] }]
      responses: { "200": { description: OK } }
  /api/v1/sars/{sarId}:
    get:
      tags: [SARs]
      summary: Fetch a SAR
      security: [{ oauth2ClientCredentials: ["sars:read"] }]
      parameters: [{ in: path, name: sarId, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
webhooks:
  alertCreated:
    post:
      summary: An alert was created
      description: |
        Dev54 POSTs to your configured endpoint with HMAC-SHA256 signature in
        `X-Copilot-Signature: t=<unix>,v1=<hex>`. Compute
        `hex(hmac_sha256(secret, "<t>.<raw_body>"))` and compare.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event: { type: string, example: alert.created }
                occurred_at: { type: string, format: date-time }
                data: { type: object }
      responses: { "200": { description: Acknowledged } }
components:
  securitySchemes:
    oauth2ClientCredentials:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://dev54.dev/api/oauth/token
          scopes:
            "decisions:write": Call the real-time inline decisioning gate
            "ingest:write": Create customers, accounts, transactions
            "screening:run": Run on-demand name screening
            "kyc:initiate": Start KYC sessions
            "kyc:read": Read KYC session status
            "alerts:read": List/read alerts
            "alerts:write": Update alert disposition
            "cases:read": List/read cases
            "cases:write": Update cases
            "sars:read": List/read SARs

  schemas:
    IdResponse:
      type: object
      properties:
        id: { type: string, format: uuid }
        external_id: { type: string }
    CustomerInput:
      type: object
      required: [external_id, full_name]
      properties:
        external_id: { type: string, maxLength: 120 }
        full_name: { type: string, maxLength: 200 }
        email: { type: string, format: email }
        phone: { type: string, description: "Primary phone. Aliases: primary_phone, phone_number" }
        primary_phone: { type: string, description: "Alias of phone" }
        phone_number: { type: string, description: "Alias of phone" }
        country: { type: string, minLength: 2, maxLength: 2 }
        date_of_birth: { type: string, format: date, description: "YYYY-MM-DD. Alias: dob" }
        dob: { type: string, format: date, description: "Alias of date_of_birth" }
        nationality: { type: string, minLength: 2, maxLength: 2, description: "ISO-3166-1 alpha-2 of citizenship" }
        address: { type: string, maxLength: 500 }
        occupation: { type: string, maxLength: 120 }
        source_of_funds: { type: string, maxLength: 200, description: "e.g. salary, business_income, investments" }
        bvn: { type: string, description: "Nigeria Bank Verification Number" }
        nin: { type: string, description: "Nigeria National Identification Number" }
        kyc_tier: { type: string }
        kyc_source: { type: string }
        pep: { type: boolean }
        metadata: { type: object, additionalProperties: true, description: "Free-form attributes. Unknown top-level fields are also folded into this blob." }
    TransactionInput:
      type: object
      required: [external_id, customer_external_id, amount, currency]
      properties:
        external_id: { type: string }
        customer_external_id: { type: string }
        amount: { type: number }
        currency: { type: string, minLength: 3, maxLength: 3 }
        txn_type: { type: string, enum: [deposit, withdrawal, transfer, payment, fx, cash_in, cash_out] }
        channel: { type: string, enum: [card, bank, momo, crypto, cash, wire, wallet, other] }
        counterparty_name: { type: string }
        counterparty_country: { type: string, minLength: 2, maxLength: 2 }
        occurred_at: { type: string, format: date-time, description: "STRONGLY RECOMMENDED. Real-world timestamp of the transaction. Used for velocity / time-window rules; without it all events appear simultaneous and velocity rules degrade." }
    AlertCreate:
      type: object
      required: [external_id, alert_code, title]
      properties:
        external_id: { type: string }
        customer_external_id: { type: string }
        transaction_external_id: { type: string }
        alert_code: { type: string }
        title: { type: string }
        description: { type: string }
        severity: { type: string, enum: [low, medium, high, critical] }
    AlertList:
      type: object
      properties:
        data: { type: array, items: { type: object } }
        next_cursor: { type: [string, "null"] }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
    DecisionInput:
      type: object
      required: [customer_external_id, amount, currency]
      description: "Proposed transaction + context. The customer must already exist via `/v1/customers`."
      properties:
        external_id: { type: string, maxLength: 120, description: "Your reference for the proposed transaction (for cross-linking)." }
        customer_external_id: { type: string, maxLength: 120 }
        amount: { type: number, description: "In `currency` minor units NOT assumed — pass the human amount and the engine converts via FX." }
        currency: { type: string, minLength: 3, maxLength: 3 }
        txn_type: { type: string, enum: [deposit, withdrawal, transfer, payment, fx, cash_in, cash_out], default: payment }
        channel: { type: string, enum: [card, bank, momo, crypto, cash, wire, wallet, other] }
        counterparty_name: { type: string, maxLength: 200 }
        counterparty_country: { type: string, minLength: 2, maxLength: 2, description: "ISO-3166-1 alpha-2." }
        device_fingerprint: { type: string, maxLength: 200 }
        ip: { type: string, maxLength: 64 }
        session_id: { type: string, maxLength: 120 }
        occurred_at: { type: string, format: date-time, description: "Real-world timestamp. Required for accurate velocity windows." }
    DecisionResult:
      type: object
      required: [verdict, score, actions, mode, latency_ms]
      properties:
        decision_id: { type: [string, "null"], format: uuid }
        verdict:
          type: string
          enum: [allow, review, challenge, block]
          description: |
            - `allow` — proceed.
            - `review` — proceed, queue for analyst review.
            - `challenge` — block until the user clears an RFI (URL returned in `rfi.url`).
            - `block` — refuse. May also pause the account (see `hold_id`).
        score: { type: integer, minimum: 0, maximum: 1000 }
        actions:
          type: array
          items: { type: string, enum: [allow, review, require_rfi, challenge, pause_account, block] }
        mode:
          type: string
          enum: [shadow, enforce]
          description: "`shadow` = response forced to `allow`; the would-be verdict is in `shadow_verdict`."
        latency_ms: { type: integer }
        reasons:
          type: array
          items:
            type: object
            properties:
              rule_id: { type: [string, "null"], format: uuid }
              code: { type: string }
              msg: { type: string }
              weight: { type: number }
        shadow_verdict: { type: string, enum: [allow, review, challenge, block] }
        shadow_score: { type: integer }
        shadow_actions: { type: array, items: { type: string } }
        rfi:
          type: [object, "null"]
          properties:
            url: { type: string, format: uri, description: "Hosted RFI page. Redirect the customer here." }
            token: { type: string }
            expires_at: { type: string, format: date-time }
        hold_id: { type: [string, "null"], format: uuid, description: "Set when the verdict opened an account hold." }
        idempotent: { type: boolean, description: "`true` when this response was replayed from a prior `Idempotency-Key` match." }

