openapi: 3.1.0
info:
  title: 0Fraud API
  version: 1.0.0-draft
  description: |
    Cardholder-in-the-loop authorisation decisioning for card issuers.
    Your processor calls 0Fraud on each authorisation; 0Fraud answers approve, decline or challenge.
    A challenge goes to the cardholder's phone for approval with biometrics (or the device passcode);
    the answer is signed by a hardware key on the phone, and the merchant's retry is then approved.

    Rules: 0Fraud never receives card numbers (card IDs and processor tokens only), and it fails open:
    if it cannot answer in time, the answer is approve.
servers:
  - url: https://sandbox.api.0fraud.si
    description: Sandbox (test keys, simulated processor)
  - url: http://localhost:8080
    description: Local development
security:
  - apiKey: []
tags:
  - name: decisions
    description: Decide on an authorisation and read the audit log.
  - name: cards
    description: Cardholders, card tokens and per-card controls.
  - name: devices
    description: Phone enrolment with hardware-bound keys, and signed location updates.
  - name: challenges
    description: Payments waiting for the cardholder, and their signed answers.
  - name: health
    description: Operational counters to alert on.
  - name: adapters
    description: Processor-facing endpoints, authenticated by the processor's webhook signature.
paths:
  /v1/decisions:
    post:
      operationId: createDecision
      tags: [decisions]
      summary: Decide on an authorisation (for issuers or banks calling from their own switch)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DecisionRequest" }
      responses:
        "200":
          description: Decision. Same authorization_id always returns the same decision.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Decision" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
    get:
      operationId: listDecisions
      tags: [decisions]
      summary: List recent decisions (audit log)
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Decisions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Decision" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/health/fail-open:
    get:
      operationId: getFailOpenCounts
      tags: [health]
      summary: Fail-open counts for the caller's issuer
      description: |
        How often 0Fraud answered approve because it could not decide (ADR 0001, ADR 0004), by
        processor and reason. Counts cover only the caller's issuer. Calls naming an unknown issuer
        belong to no issuer and are not included. `last_hour` has minute resolution. Counts are
        held in memory per API instance and reset when it restarts (`started_at`).
        Any non-zero `last_hour` count means some authorisations were not protected; alert on it.
      responses:
        "200":
          description: Fail-open counts
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FailOpenSummary" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/cardholders:
    post:
      operationId: upsertCardholder
      tags: [cards]
      summary: Register a cardholder and link card IDs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, card_ids]
              properties:
                id: { type: string, description: Your cardholder ID }
                card_ids:
                  type: array
                  items: { type: string, description: Processor card token. Never a card number. }
      responses:
        "201": { description: Registered }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/cards/{card_id}/controls:
    parameters:
      - in: path
        name: card_id
        required: true
        schema: { type: string }
    get:
      operationId: getCardControls
      tags: [cards]
      summary: Get a card's controls
      responses:
        "200":
          description: Controls
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardControls" }
        "401": { $ref: "#/components/responses/Error" }
    put:
      operationId: updateCardControls
      tags: [cards]
      summary: Set a card's controls (partial update)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CardControls" }
      responses:
        "200":
          description: Updated controls
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardControls" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/devices/enrollments:
    post:
      operationId: enrolDevice
      tags: [devices]
      summary: Enrol a cardholder's phone (called by the issuer app through the SDK)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cardholder_id, platform, public_key, attestation]
              properties:
                cardholder_id: { type: string }
                platform: { type: string, enum: [ios, android] }
                public_key:
                  type: string
                  description: |
                    Biometric key. P-256 public key (SPKI DER, base64) generated in Secure Enclave or StrongBox,
                    usable only after a biometric check (iOS `.biometryCurrentSet`, Android `BIOMETRIC_STRONG`).
                passcode_public_key:
                  type: string
                  description: |
                    Optional passcode key, for cardholders who don't use biometrics or when biometrics fail.
                    A second P-256 key in the same secure hardware, usable after biometrics or the device
                    passcode (iOS `.userPresence`, Android `BIOMETRIC_STRONG | DEVICE_CREDENTIAL`).
                    Which key signed a response proves how the cardholder unlocked it (ADR 0006).
                attestation:
                  type: object
                  required: [type, token]
                  properties:
                    type: { type: string, enum: [app_attest, play_integrity, dev], description: "dev is refused in production" }
                    token: { type: string }
      responses:
        "201":
          description: Enrolled
          content:
            application/json:
              schema:
                type: object
                properties:
                  device_id: { type: string }
                  trust: { type: string, enum: [trusted, degraded] }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/devices/{device_id}/locations:
    post:
      operationId: updateDeviceLocation
      tags: [devices]
      summary: Signed location update from the phone
      description: |
        signature = ECDSA P-256 / SHA-256 (DER, base64) over the UTF-8 string
        `0fraud.location.v1|{device_id}|{lat}|{lng}|{at}`
      parameters:
        - in: path
          name: device_id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lat, lng, at, signature]
              properties:
                lat: { type: number }
                lng: { type: number }
                at: { type: string, format: date-time }
                signature: { type: string }
      responses:
        "204": { description: Stored }
        "401": { $ref: "#/components/responses/Error" }
  /v1/challenges/{challenge_id}:
    get:
      operationId: getChallenge
      tags: [challenges]
      summary: Get a challenge to show the cardholder
      parameters:
        - $ref: "#/components/parameters/ChallengeId"
      responses:
        "200":
          description: Challenge
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Challenge" }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/challenges/{challenge_id}/responses:
    post:
      operationId: respondToChallenge
      tags: [challenges]
      summary: Cardholder's approve or decline, signed by the device key
      description: |
        signature = ECDSA P-256 / SHA-256 (DER, base64) over the UTF-8 string
        `0fraud.challenge.v1|{challenge_id}|{decision}|{nonce}`

        `method` names the key that signed: `biometric` (default) is verified against the device's
        `public_key`, `passcode` against its `passcode_public_key`. An approval is refused when the
        card's `approval_methods` doesn't allow its method: 403 `passcode_not_allowed` on a
        biometrics-only card, 403 `biometric_not_allowed` on a passcode-only card. A decline is
        always accepted, whichever key signed it.
      parameters:
        - $ref: "#/components/parameters/ChallengeId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, decision, signature]
              properties:
                device_id: { type: string }
                decision: { type: string, enum: [approve, decline] }
                method: { type: string, enum: [biometric, passcode], default: biometric }
                signature: { type: string }
      responses:
        "200":
          description: Resolved challenge
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Challenge" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /adapters/lithic/{issuer_id}:
    post:
      operationId: lithicAuthorization
      tags: [adapters]
      security: []
      summary: Lithic Auth Stream Access endpoint (field mapping to be verified against Lithic docs)
      description: |
        Always answers 200 with the processor's response format. A call with a bad signature or an
        unknown issuer_id is answered with approve without being decided (fail open, ADR 0004).
        So is a body that is empty, not JSON, not a JSON object or missing required fields; the
        response then echoes the request's token only if it had one.
      parameters:
        - $ref: "#/components/parameters/IssuerId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, description: Lithic ASA request }
      responses:
        "200":
          description: Lithic ASA response
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, description: Echoed from the request; omitted only if the request had no usable token }
                  result: { type: string, enum: [APPROVED, DECLINED] }
  /adapters/marqeta/{issuer_id}:
    post:
      operationId: marqetaAuthorization
      tags: [adapters]
      security: []
      summary: Marqeta endpoint (stub mapping, to be verified against Marqeta docs)
      description: |
        Always answers 200 with the processor's response format. A call with a bad signature or an
        unknown issuer_id is answered with approve without being decided (fail open, ADR 0004).
        So is a body that is empty, not JSON, not a JSON object or missing required fields; the
        response then echoes the request's token only if it had one.
      parameters:
        - $ref: "#/components/parameters/IssuerId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Marqeta response }
webhooks:
  challenge.created:
    post:
      operationId: challengeCreated
      summary: Send a push notification to the cardholder
      description: |
        Signed with header `0fraud-signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">`.
        Reject events older than 5 minutes.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "200": { description: Received }
  challenge.resolved:
    post:
      operationId: challengeResolved
      summary: The cardholder approved or declined, with the unlock method
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "200": { description: Received }
  decision.recorded:
    post:
      operationId: decisionRecorded
      summary: Every decision, for your own records and dashboards
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
      responses:
        "200": { description: Received }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Secret API key: sk_test_... (sandbox) or sk_live_... (production)"
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema: { type: string }
      description: Optional; authorization_id is already used as the idempotency key for decisions.
    ChallengeId:
      in: path
      name: challenge_id
      required: true
      schema: { type: string }
    IssuerId:
      in: path
      name: issuer_id
      required: true
      schema: { type: string }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
  schemas:
    RuleMode:
      type: string
      enum: [off, shadow, enforce]
      description: shadow = record what would happen, never affect the payment
    CardControls:
      type: object
      properties:
        card_id: { type: string, readOnly: true }
        keyed_entry_block: { $ref: "#/components/schemas/RuleMode" }
        location_match: { $ref: "#/components/schemas/RuleMode" }
        online_approval: { $ref: "#/components/schemas/RuleMode" }
        scam_check:
          $ref: "#/components/schemas/RuleMode"
          description: |
            Scam check. Scores risk signals 3D Secure can't see. Runs on every payment, including
            3DS-authenticated ones. Defaults to shadow. Signals and thresholds are configured with
            0Fraud during onboarding and are not part of the public contract.
        location_radius_km: { type: number, default: 50 }
        approval_methods:
          type: string
          enum: [biometric, passcode, any]
          default: any
          description: |
            How the cardholder may approve a challenge on this card: `biometric` (biometrics only),
            `passcode` (device passcode only, for cardholders who keep biometrics off) or `any` (both).
            Every approval is still signed by a hardware key on the phone; only the unlock method
            differs. Supersedes `passcode_fallback`.
        passcode_fallback:
          type: boolean
          default: true
          deprecated: true
          description: |
            Deprecated: use `approval_methods`. `false` sets `approval_methods` to `biometric`;
            `true` allows the passcode (`any`, or `passcode` if already passcode-only). Returned as
            `true` whenever the passcode is allowed. If both fields are sent, `approval_methods` wins.
    DecisionRequest:
      type: object
      required: [authorization_id, card_id, amount_minor, currency, merchant, entry_mode, card_present]
      properties:
        authorization_id: { type: string }
        card_id: { type: string }
        amount_minor: { type: integer, description: Amount in minor units }
        currency: { type: string, minLength: 3, maxLength: 3 }
        merchant:
          type: object
          required: [id]
          properties:
            id: { type: string }
            name: { type: string }
            mcc: { type: string }
            country: { type: string }
        entry_mode:
          type: string
          enum: [chip, contactless, magstripe, keyed, ecommerce, moto, credential_on_file, unknown]
        card_present: { type: boolean }
        three_ds_authenticated: { type: boolean, default: false }
        terminal_location:
          type: object
          properties:
            lat: { type: number }
            lng: { type: number }
    Decision:
      type: object
      properties:
        id: { type: string }
        authorization_id: { type: string }
        card_id: { type: string }
        outcome: { type: string, enum: [approve, decline, challenge] }
        shadow_outcome: { type: string, enum: [approve, decline, challenge] }
        reason_code:
          type: string
          enum: [ok, keyed_entry_blocked, location_mismatch, cardholder_approval_required, scam_risk, cardholder_approved, fail_open]
        challenge_id: { type: [string, "null"] }
        fail_open: { type: boolean }
        rules:
          type: array
          items:
            type: object
            properties:
              rule: { type: string }
              wanted: { type: string }
              mode: { $ref: "#/components/schemas/RuleMode" }
              reason: { type: string }
        latency_ms: { type: number }
        created_at: { type: string, format: date-time }
    Challenge:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, approved, declined, expired] }
        card_id: { type: string }
        merchant_name: { type: [string, "null"] }
        amount_minor: { type: integer }
        currency: { type: string }
        nonce: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        approval_expires_at: { type: [string, "null"], format: date-time }
        method:
          type: [string, "null"]
          enum: [biometric, passcode, null]
          description: How the cardholder unlocked the signing key. Null until answered.
    FailOpenCount:
      type: object
      properties:
        processor: { type: string, description: "Adapter name (lithic, marqeta) or direct for POST /v1/decisions" }
        reason: { type: string, enum: [bad_signature, deadline_missed, error, malformed_body] }
        count: { type: integer, minimum: 1 }
    FailOpenSummary:
      type: object
      properties:
        issuer_id: { type: string }
        started_at: { type: string, format: date-time, description: When this API instance started counting }
        generated_at: { type: string, format: date-time }
        last_hour:
          type: array
          items: { $ref: "#/components/schemas/FailOpenCount" }
        since_start:
          type: array
          items: { $ref: "#/components/schemas/FailOpenCount" }
    WebhookEvent:
      type: object
      required: [id, type, issuer_id, created_at, data]
      properties:
        id: { type: string, description: "Unique event id (evt_...). Use it to drop duplicates." }
        type: { type: string, enum: [challenge.created, challenge.resolved, decision.recorded] }
        issuer_id: { type: string }
        created_at: { type: string, format: date-time }
        issuerId: { type: string, deprecated: true, description: Same as issuer_id. Kept for compatibility. }
        createdAt: { type: string, format: date-time, deprecated: true, description: Same as created_at. Kept for compatibility. }
        data:
          oneOf:
            - $ref: "#/components/schemas/ChallengeCreatedData"
            - $ref: "#/components/schemas/ChallengeResolvedData"
            - $ref: "#/components/schemas/DecisionRecordedData"
    ChallengeCreatedData:
      type: object
      description: "data for challenge.created: send a push to the cardholder's app."
      properties:
        challenge_id: { type: string }
        cardholder_id: { type: string }
        card_id: { type: string }
        merchant_name: { type: [string, "null"] }
        amount_minor: { type: integer }
        currency: { type: string }
        expires_at: { type: string, format: date-time }
    ChallengeResolvedData:
      type: object
      description: "data for challenge.resolved"
      properties:
        challenge_id: { type: string }
        status: { type: string, enum: [approved, declined] }
        method: { type: string, enum: [biometric, passcode], description: How the cardholder unlocked the signing key (ADR 0006) }
    DecisionRecordedData:
      type: object
      description: "data for decision.recorded"
      properties:
        decision_id: { type: string }
        authorization_id: { type: string }
        outcome: { type: string, enum: [approve, decline, challenge] }
        shadow_outcome: { type: string, enum: [approve, decline, challenge] }
        reason_code: { type: string }
