openapi: 3.1.0
info:
  title: SafeClick Enforcement Layer API
  version: 1.0.0
  description: >
    Evaluate a transaction or intended action before a customer system signs,
    broadcasts, or executes. The canonical integration route is
    POST /scan/transaction. Supporting wallet, contract, and intelligence
    routes reuse detector capabilities. Team API keys are tenant-scoped. A
    caller continues only after a final ALLOW or FINAL_ALLOW. Customer
    responses use the bounded public-scan-v1 summary; internal scores, policy
    slugs, provider diagnostics, provenance, and route topology remain private.
servers:
  - url: https://safeclick.dev/api/rest/v1
    description: SafeClick customer API through the App Hosting BFF
security:
  - ApiKeyHeader: []
  - BearerAuth: []
tags:
  - name: Enforcement
    description: Canonical transaction decision and result reads.
  - name: Supporting analysis
    description: Detector and intelligence routes used by supporting tools and runtime integrations.
  - name: Team configuration
    description: Organization policy, webhook, and usage operations.
  - name: Validation
    description: Advanced external validation evidence intake.
  - name: Webhooks
    description: Signed events SafeClick POSTs to your registered endpoints.
components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: x-api-key
      description: Team API key. Keep it in a server-side secret store.
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_live
      description: "Team API key sent as Authorization: Bearer."
    SessionAuth:
      type: http
      scheme: bearer
      bearerFormat: sc_sess
      description: "Signed Portal Team session used by the in-console access request workflow."
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, minLength: 1, maxLength: 255 }
      description: >
        Opaque key retained by the client and reused for retries of the same
        request. SafeClick derives the request fingerprint server-side. While
        SafeClick still holds the first request under this key, a retry does
        not rerun the scan, charge a second grant, or re-emit its webhook. A
        successful retry returns HTTP 202: the original response for a
        completed wallet or contract scan, or the original scan_id for
        a transaction scan. Read status and action on every response, because
        the body can differ from the first one; GET /scans/{id} has the
        latest state. A retry before SafeClick has settled the first request
        can return 409 with reason in_flight; the first request may still be
        running or be held after a failure. Retry later with the same key,
        and contact SafeClick if it persists. A 409 with reason
        payload_mismatch means the key was used with a different body. If the first request failed before producing a
        decision, SafeClick may release the key, and the retry then runs as a
        new scan. If omitted, the server generates a one-request key and
        retries are not deduplicated.
    WebhookSignature:
      name: x-safeclick-signature
      in: header
      required: true
      schema: { type: string, pattern: '^sha256=[0-9a-f]{64}$' }
      description: "`sha256=` and the hex HMAC-SHA256 of `${x-safeclick-timestamp}.${raw body}`, keyed with the endpoint's whsec_ secret."
    WebhookTimestamp:
      name: x-safeclick-timestamp
      in: header
      required: true
      schema: { type: string, pattern: '^[0-9]+$' }
      description: Unix seconds when this attempt was signed. Reject values more than 300 seconds from your clock.
    WebhookEventType:
      name: x-safeclick-event
      in: header
      required: true
      schema: { type: string }
      description: The event type, equal to `type` in the body.
    WebhookDelivery:
      name: x-safeclick-delivery
      in: header
      required: true
      schema: { type: string }
      description: The event id, equal to `id` in the body and the same on every retry.
  requestBodies:
    WebhookEventBody:
      required: true
      content:
        application/json:
          schema: { $ref: '#/components/schemas/WebhookEvent' }
  responses:
    AddressScanDecision:
      description: >
        Decision returned in this response. The request waits for the
        decision, so status is final or pending_validation, never processing,
        and there is nothing to poll. Read status and action rather than the
        HTTP code. transaction_commitment and execution_authorized refer to
        this address check only and never authorize a transaction.
        coverage_status is unknown in this response; GET /scans/{id} reads
        the stored scan later. During the beta a WARN or HOLD
        (pending_validation) is not finalized later.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/PublicScanSummary' }
          examples:
            allow:
              summary: Final ALLOW
              value:
                scan_id: 0f9e4a52-8d1c-4b7e-9a35-2c6d8e1f4b70
                status: final
                action: ALLOW
                validation_required: false
                risk_tier: low
                reason_categories: [policy_rule]
                coverage_status: unknown
                transaction_commitment: "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
                execution_authorized: true
                response_schema_version: public-scan-v1
            block:
              summary: Final BLOCK
              value:
                scan_id: b3a1c9d4-6e2f-4a8b-8c7d-5e9f0a1b2c3d
                status: final
                action: BLOCK
                validation_required: false
                risk_tier: critical
                reason_categories: [contract_risk]
                coverage_status: unknown
                transaction_commitment: "0xefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef"
                execution_authorized: false
                response_schema_version: public-scan-v1
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      description: >
        Error body. `error` is a stable code, for example invalid_request,
        unsupported_network,
        unauthenticated, key_paused, key_revoked, insufficient_scope,
        access_not_granted, rate_limited, pipeline_unavailable, or
        access_grant_unavailable. access_not_granted carries `reason`
        instead of `message`.
      properties:
        error: { type: string }
        message: { type: string }
        reason: { type: string, enum: [absent, expired, exhausted, disabled] }
        scan_id:
          type: string
          format: uuid
          description: Present on some pipeline_unavailable responses. It is not a decision.
    SupportingScanConflict:
      type: object
      required: [error, reason]
      properties:
        error: { type: string, enum: [idempotency_conflict] }
        reason: { type: string, enum: [payload_mismatch, in_flight] }
    AddressScanRequest:
      type: object
      additionalProperties: true
      required: [address]
      description: >
        Body for POST /scan/wallet and POST /scan/contract. Send at most one
        chain hint, or hints that name the same chain; disagreeing hints
        return HTTP 400. An EVM chain outside the supported networks returns
        HTTP 400 unsupported_network. Without a hint, SafeClick infers the
        chain family from the address format (EVM when the format does not
        tell) and uses that family's mainnet, Ethereum for EVM.
      properties:
        address:
          type: string
          minLength: 20
          maxLength: 128
          pattern: '^[0-9a-zA-Z]+$'
          description: Wallet or contract address, letters and digits only (for example an EVM 0x address).
        network_key:
          type: string
          minLength: 1
          maxLength: 48
          description: Chain as a CAIP-2 ID, for example eip155:1.
        chain:
          type: string
          pattern: '^[a-z0-9-]{3,8}:[a-zA-Z0-9_.-]{1,64}$'
          description: Alternative CAIP-2 chain hint.
        chain_id:
          type: integer
          minimum: 1
          description: EVM chain ID, as an alternative to network_key.
        origin_url: { type: string, format: uri }
        metadata: { type: object }
      example:
        address: "0x52908400098527886E0F7030069857D2E4169EE7"
        network_key: "eip155:1"
    PolicyWriteRequest:
      type: object
      required: [version, rules]
      properties:
        version: { type: string, minLength: 1, maxLength: 32 }
        rules: { type: object }
        active: { type: boolean }
    WebhookRegistrationRequest:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          uniqueItems: true
          items:
            type: string
            enum: [scan.completed, decision.block, validation.required, validation.final_allow, validation.final_block]
    WebhookTestRequest:
      type: object
      properties:
        secret: { type: string, minLength: 1 }
        deliver: { type: boolean }
        event_type:
          type: string
          enum: [scan.completed, decision.block, validation.required, validation.final_allow, validation.final_block]
    WebhookDeliveryPage:
      type: object
      required: [deliveries, pagination]
      properties:
        deliveries:
          type: array
          items:
            type: object
            required: [id, event_id, event_type, delivery_id, url, status, attempt_count, created_at, updated_at]
            properties:
              id: { type: string, format: uuid }
              event_id: { type: string }
              event_type: { type: string }
              delivery_id: { type: string }
              url: { type: string, format: uri }
              status: { type: string }
              attempt_count: { type: integer, minimum: 0 }
              next_attempt_at: { type: [string, 'null'], format: date-time }
              last_status_code: { type: integer }
              last_error:
                type: string
                description: Most recent failure. `secret_lookup_failed` means the signing secret could not be read or decrypted; the event is retried up to the attempt cap and is never sent unsigned.
              created_at: { type: string, format: date-time }
              updated_at: { type: string, format: date-time }
        pagination:
          type: object
          required: [limit, offset, total, has_more]
          properties:
            limit: { type: integer, minimum: 1, maximum: 100 }
            offset: { type: integer, minimum: 0 }
            total: { type: integer, minimum: 0 }
            has_more: { type: boolean }
    WebhookTestResult:
      type: object
      required: [delivered, stub, configured_webhooks, sample_event, signature]
      properties:
        delivered: { type: boolean }
        stub: { type: boolean }
        configured_webhooks: { type: integer, minimum: 0 }
        matched_webhooks: { type: integer, minimum: 0 }
        sample_event:
          description: Sample envelope. Its data is WebhookTestData, not a real scan.
          allOf: [{ $ref: '#/components/schemas/WebhookEvent' }]
        signature:
          type: object
          required: [algorithm, headers, payload, timestamp]
          properties:
            algorithm: { type: string, const: hmac-sha256 }
            headers: { type: object, additionalProperties: { type: string } }
            payload: { type: string, description: Exact signed body (compact JSON with sorted keys). }
            timestamp: { type: integer, description: Unix seconds bound into the signature. }
        test_secret: { type: string, description: Ephemeral secret used only when no `secret` was sent. }
        test_secret_note: { type: string }
        deliveries:
          type: array
          items:
            type: object
            required: [webhook_id, url, ok, attempts]
            properties:
              webhook_id: { type: string, format: uuid }
              url: { type: string, format: uri }
              ok: { type: boolean }
              attempts: { type: integer, minimum: 0 }
              status: { type: integer }
              error: { type: string }
    WebhookEvent:
      type: object
      additionalProperties: false
      required: [id, type, organization_id, created_at, data]
      description: >
        Envelope SafeClick POSTs to a registered endpoint. The body is compact
        JSON with object keys sorted; verify the raw bytes. It carries no
        transaction_commitment or execution_authorized, so it never
        authorizes execution: read GET /scans/{id} first.
      properties:
        id: { type: string, pattern: '^evt_', description: Opaque event id. The same on every retry of this event. }
        type: { type: string, enum: [scan.completed, decision.block, validation.required, validation.final_allow, validation.final_block] }
        organization_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        data:
          oneOf:
            - $ref: '#/components/schemas/WebhookDecisionData'
            - $ref: '#/components/schemas/WebhookTestData'
      example:
        created_at: "2026-10-28T14:03:12.511Z"
        data:
          action: BLOCK
          reason_category: threat_intelligence
          scan_id: 6d5b6c44-37d3-4d31-b6e1-3902b5c81616
          status: final
        id: evt_3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c
        organization_id: 9b1f7c2e-4d5a-4e8b-a6c3-1f2e3d4c5b6a
        type: decision.block
    WebhookDecisionData:
      type: object
      required: [scan_id, action, status]
      description: data of a real decision event.
      properties:
        scan_id: { type: string, format: uuid }
        action: { type: string, enum: [ALLOW, WARN, HOLD, BLOCK, FINAL_ALLOW, FINAL_BLOCK] }
        status: { type: string, enum: [final, pending_validation] }
        reason_category: { type: string, enum: [approval_risk, asset_outflow, identity_mismatch, replay_risk, contract_risk, threat_intelligence, compliance_risk, evidence_incomplete, policy_rule, other_risk] }
    WebhookTestData:
      type: object
      required: [test, scan_id, action, note]
      description: data of a sample sent by POST /webhooks/test. It is not a real scan.
      properties:
        test: { type: boolean, const: true }
        scan_id: { type: string, format: uuid, description: Placeholder id. }
        action: { type: string }
        note: { type: string }
    DomainIntelligence:
      type: object
      required: [domain, known]
      properties:
        domain: { type: string }
        known: { type: boolean }
        first_seen: { type: string, format: date-time }
        reputation: {}
    AddressIntelligence:
      type: object
      required: [address, known]
      properties:
        address: { type: string }
        known: { type: boolean }
        wallet: { type: object }
        contract: { type: object }
    PolicyRule:
      type: object
      required: [id, version, active, rules, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        version: { type: string }
        active: { type: boolean }
        rules: { type: object }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Webhook:
      type: object
      required: [id, url, events, active]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        active: { type: boolean }
        secret: { type: string, description: The whsec_ signing secret. Returned only on create or rotate. }
        delivery_enabled:
          type: boolean
          description: Returned on create or rotate. False means SafeClick cannot sign deliveries for this endpoint, and its events are recorded as skipped_no_secret.
        delivery_note: { type: string, description: Present when delivery_enabled is false. }
        rotated: { type: boolean, const: true, description: Returned on rotate. }
    UsageSummary:
      type: object
      required: [organization_id, window_start, window_end, metrics]
      properties:
        organization_id: { type: string, format: uuid }
        window_start: { type: [string, 'null'], format: date-time }
        window_end: { type: [string, 'null'], format: date-time }
        metrics:
          type: array
          items:
            type: object
            required: [metric, count]
            properties:
              metric: { type: string }
              count: { type: integer, minimum: 0 }
    AccessRequestResponse:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status: { type: string, const: requested }
    AccessGrantStatus:
      type: object
      additionalProperties: false
      required: [organizationId, granted, canScan, role, canFailOpenLiveDemo, status, limit, used, reserved, startsAt, expiresAt, activationDeadline, expanded]
      properties:
        organizationId: { type: string, format: uuid }
        canScan: { type: boolean, description: Whether this credential has the scan.execute capability. }
        role: { type: [string, 'null'], description: Live membership role for session callers; null for API keys. }
        canFailOpenLiveDemo: { type: boolean, description: Whether this credential may fail-open an absent or pending grant for first-party Live Demo transaction scans. }
        granted: { type: boolean }
        status: { type: string, enum: [absent, pending, active, disabled, expired, exhausted] }
        limit: { type: [integer, 'null'], minimum: 0 }
        used: { type: integer, minimum: 0 }
        reserved: { type: integer, minimum: 0 }
        startsAt: { type: [string, 'null'], format: date-time }
        expiresAt: { type: [string, 'null'], format: date-time }
        activationDeadline: { type: [string, 'null'], format: date-time }
        expanded: { type: boolean }
        reason: { type: [string, 'null'], enum: [absent, pending, active, disabled, expired, exhausted, null] }
    Uint256Quantity:
      oneOf:
        - type: string
          minLength: 1
          maxLength: 78
          pattern: '^[0-9]+$'
          description: Decimal uint256; values above 2^256-1 are rejected.
        - type: string
          minLength: 3
          maxLength: 66
          pattern: '^0x[0-9a-fA-F]+$'
          description: 0x-prefixed uint256 with at most 64 hex digits.
    UserOperation:
      type: object
      required: [sender, callData]
      properties:
        sender: { type: string }
        callData: { type: string }
        callDataGasLimit: { $ref: '#/components/schemas/Uint256Quantity' }
        callGasLimit: { $ref: '#/components/schemas/Uint256Quantity' }
        initCode: { type: string }
        signature: { type: string }
        sig: { type: string }
    PublicScanSummary:
      type: object
      additionalProperties: false
      required: [scan_id, status, action, validation_required, risk_tier, reason_categories, coverage_status, transaction_commitment, execution_authorized, response_schema_version]
      properties:
        scan_id: { type: string, format: uuid }
        status: { type: string, enum: [processing, pending_validation, final, failed] }
        action: { type: [string, 'null'], enum: [ALLOW, WARN, HOLD, BLOCK, FINAL_ALLOW, FINAL_BLOCK, null] }
        validation_required: { type: boolean }
        risk_tier: { type: string, enum: [unknown, low, moderate, high, critical] }
        reason_categories:
          type: array
          uniqueItems: true
          items: { type: string, enum: [approval_risk, asset_outflow, identity_mismatch, replay_risk, contract_risk, threat_intelligence, compliance_risk, evidence_incomplete, policy_rule, other_risk] }
        coverage_status: { type: string, enum: [unknown, complete, partial, unavailable] }
        failure_reason:
          type: string
          enum: [processing_timeout, validation_expired, processing_failed]
          description: >
            Present only when status is failed. Operational failure only;
            action is null and execution is not authorized.
            processing_timeout: no final decision within 960 seconds of
            acceptance, including a WARN or HOLD that was not resolved in time.
            validation_expired: required validation was not completed before
            the scan deadline.
            processing_failed: SafeClick could not process the transaction.
            To try again, submit a new scan with a new Idempotency-Key.
        sandbox_follow_up_id: { type: string, format: uuid }
        transaction_commitment:
          type: [string, 'null']
          description: >
            Existing canonical transaction commitment for this scan (`0x` and
            64 lowercase hex digits), or null when that commitment is absent.
            Echoed from the scan decision. Not recomputed, not signed, and
            not paired with a verdict expiry. An ALLOW or FINAL_ALLOW with
            null is not execution authority. On a wallet or contract scan it
            binds that address check, not a transaction.
        execution_authorized:
          type: boolean
          description: >
            True for initial ALLOW when transaction_commitment is present.
            True for FINAL_ALLOW only when that commitment is also bound to a
            verified SP1 #2 final-enforcement statement (chain, policy, and
            session). False means this response is not execution authority.
            On a wallet or contract scan it refers to that address check only
            and never authorizes signing or sending a transaction; scan the
            transaction with POST /scan/transaction. SafeClick does not sign
            or settle.
        response_schema_version: { type: string, const: public-scan-v1 }
    ValidationEvidence:
      type: object
      required: [schema_version, evidence_id, evidence_kind, issuer_id, sandbox2_id, organization_id, decision_commitment, validation_plan_commitment, transaction_commitment, decision, issued_at, key_id, signature_base64url]
      properties:
        schema_version: { type: string, const: sandbox2-evidence-v1 }
        evidence_id: { type: string, description: Empty is valid; a well-formed Unicode scalar string of at most 256 UTF-8 bytes; U+0000 and lone UTF-16 surrogates are rejected before PostgreSQL persistence. }
        evidence_kind: { type: string, enum: [user_interaction, organization_approval] }
        issuer_id: { type: string }
        sandbox2_id: { type: string, format: uuid }
        organization_id: { type: string, format: uuid, description: Must bind to the authenticated organization }
        decision_commitment: { type: string, pattern: '^0x[0-9a-f]{64}$' }
        validation_plan_commitment: { type: string, pattern: '^0x[0-9a-f]{64}$' }
        transaction_commitment: { type: [string, 'null'] }
        decision: { type: string, enum: [acknowledge, approve, reject] }
        issued_at: { type: string, format: date-time }
        key_id: { type: string }
        signature_base64url: { type: string, description: Canonical unpadded base64url Ed25519 signature }
    ValidationEvidenceEnvelope:
      type: object
      required: [evidence]
      properties:
        evidence: { $ref: '#/components/schemas/ValidationEvidence' }
      description: received_at is server-owned and must not be supplied by customers.
    EvidenceIngestionResult:
      type: object
      required: [accepted, evidence_id, received_at, legacy_sequential_finalization, executable, enforcement_action, replacement_mpc_sp1_verified]
      properties:
        accepted: { type: boolean, const: true }
        evidence_id: { type: string }
        received_at: { type: string, format: date-time, description: Server-owned canonical receipt time }
        legacy_sequential_finalization: { type: string, const: "disabled" }
        executable: { type: boolean, const: false }
        enforcement_action: { type: string, const: "DO_NOT_BROADCAST" }
        replacement_mpc_sp1_verified: { type: boolean, const: false }
    TransactionScanRequest:
      type: object
      anyOf:
        - required: [to]
        - required: [toAddress]
        - required: [raw]
        - required: [rawSignedTx]
        - required: [signedTransaction]
        - required: [rawTransaction]
        - required: [serializedTransaction]
        - required: [data]
        - required: [input]
        - required: [calldata]
        - required: [callData]
        - required: [xdr]
        - required: [transaction]
        - required: [envelope]
        - required: [envelopeXdr]
        - required: [action]
        - required: [userOperation]
        - required: [userOp]
      description: >
        Public POST /v1/scan/transaction envelope. Adaptive-band fields are
        caller-supplied scan-time facts (no FX oracle). Dropping them here
        prevents $100–$5k confirm and >$5k block bands on the REST path.
        Nested `payload` and documented top-level transaction fields are
        one transaction: they are merged and must agree. Unequal serialized
        aliases, structured `to`/`data` beside different signed bytes, or
        disagreeing UserOperation / Hyperliquid representations return
        HTTP 400. Nested `payload` does not take precedence.
      properties:
        from: { type: string }
        to: { type: string }
        toAddress: { type: string }
        value: { $ref: '#/components/schemas/Uint256Quantity' }
        data: { type: string }
        input: { type: string }
        calldata: { type: string }
        callData: { type: string }
        gas: { $ref: '#/components/schemas/Uint256Quantity' }
        nonce: { type: integer, minimum: 0 }
        chain_id: { type: integer, minimum: 1 }
        chain: { type: string }
        raw:
          description: >
            Serialized transaction. EVM 0x-hex, Solana base58/base64
            (legacy or v0), Stellar TransactionEnvelope XDR, or a
            Hyperliquid L1 /exchange signed-action object (or JSON string).
          oneOf:
            - type: string
            - type: object
        rawSignedTx: {}
        signedTransaction: {}
        rawTransaction: {}
        serializedTransaction: {}
        action:
          type: object
          description: Hyperliquid L1 signed-action body when not nested under `raw`.
        signature:
          type: object
          description: Hyperliquid L1 ECDSA `{r,s,v}` when flattened.
        vaultAddress:
          type: string
          description: Optional Hyperliquid vault / subaccount address.
        expiresAfter:
          type: integer
          description: Optional Hyperliquid action expiry (ms).
        xdr:
          type: string
          description: Stellar TransactionEnvelope XDR (classic payment or Soroban invoke).
        transaction:
          type: string
          description: Alias for a serialized Solana or Stellar transaction blob.
        envelope:
          type: string
          description: Alias for a Stellar TransactionEnvelope XDR.
        envelopeXdr:
          type: string
          description: Alias for a Stellar TransactionEnvelope XDR.
        encoding:
          type: string
          enum: [base58, base64, hex]
          description: Optional encoding hint for `raw` (Solana base58 vs base64).
        userOperation: { $ref: '#/components/schemas/UserOperation' }
        userOp: { $ref: '#/components/schemas/UserOperation' }
        payload:
          type: object
          description: >
            Nested transaction representation. When present it is merged with
            documented top-level fields; disagreement is HTTP 400, not a
            silent nested-payload win.
        origin_url: { type: string, format: uri }
        metadata: { type: object }
        network_key: { type: string }
        usd_equivalent_cents:
          type: integer
          minimum: 0
          description: Scan-time USD-equivalent notional in cents. Caller fact only.
        balance_share_bps:
          type: integer
          minimum: 0
          maximum: 65535
          description: Scan-time notional / wallet-balance share in basis points.
webhooks:
  scan.completed:
    post:
      tags: [Webhooks]
      security: []
      summary: A scan reached a final ALLOW or BLOCK
      description: >
        Sent for wallet, contract, and transaction scans. Verify the
        signature: hex HMAC-SHA256 with your whsec_ secret over
        `${x-safeclick-timestamp}.${raw body}`, compared in constant time
        with the value after `sha256=`; reject timestamps more than 300
        seconds from your clock. These requests carry no SafeClick
        credential; authenticate them by the signature. Answer with any 2xx
        within 10 seconds. SafeClick does not follow redirects. 5xx, 429,
        response timeouts, and connection errors are retried in rounds of up
        to 3 attempts, with a growing wait between rounds, until 9 attempts
        in total; any other status fails the delivery at once. Before each
        round the hostname is resolved and every address must be public; a
        lookup that fails or takes longer than 2 seconds, or a non-public
        address, fails the delivery at once with nothing sent. A round is
        signed once, so its attempts carry the same bytes and timestamp;
        each later round is signed again with a new timestamp and the
        endpoint's current secret. The event id stays the same, so drop
        duplicate ids. Events can arrive out of order. After a secret
        rotation, a round already under way keeps the old signature, so
        accept both secrets for a few minutes. A signing secret that cannot
        be read or decrypted is never replaced by an unsigned delivery: the
        event is kept and the lookup is retried, each failed lookup using the
        same budget as delivery attempts (a new round starts only while fewer
        than 8 are used), with last_error secret_lookup_failed in the
        delivery history. A failed scan sends no webhook.
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDelivery'
      requestBody: { $ref: '#/components/requestBodies/WebhookEventBody' }
      responses:
        "2XX": { description: Received. Any 2xx status within 10 seconds. }
  decision.block:
    post:
      tags: [Webhooks]
      security: []
      summary: A scan reached a final BLOCK
      description: Sent as a separate delivery together with scan.completed when the final action is BLOCK. Same signature and retry rules as scan.completed.
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDelivery'
      requestBody: { $ref: '#/components/requestBodies/WebhookEventBody' }
      responses:
        "2XX": { description: Received. Any 2xx status within 10 seconds. }
  validation.required:
    post:
      tags: [Webhooks]
      security: []
      summary: A scan returned WARN or HOLD
      description: data.status is pending_validation. Same signature and retry rules as scan.completed.
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDelivery'
      requestBody: { $ref: '#/components/requestBodies/WebhookEventBody' }
      responses:
        "2XX": { description: Received. Any 2xx status within 10 seconds. }
  validation.final_allow:
    post:
      tags: [Webhooks]
      security: []
      summary: Validation of a WARN or HOLD finished with FINAL_ALLOW
      description: Not sent during the beta, where a WARN or HOLD is not finalized later. Same signature and retry rules as scan.completed.
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDelivery'
      requestBody: { $ref: '#/components/requestBodies/WebhookEventBody' }
      responses:
        "2XX": { description: Received. Any 2xx status within 10 seconds. }
  validation.final_block:
    post:
      tags: [Webhooks]
      security: []
      summary: Validation of a WARN or HOLD finished with FINAL_BLOCK
      description: Not sent during the beta, where a WARN or HOLD is not finalized later. Same signature and retry rules as scan.completed.
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDelivery'
      requestBody: { $ref: '#/components/requestBodies/WebhookEventBody' }
      responses:
        "2XX": { description: Received. Any 2xx status within 10 seconds. }
paths:
  /scan/wallet:
    post:
      tags: [Supporting analysis]
      summary: Evaluate a wallet address
      description: >
        Waits for SafeClick's decision and returns it in the same response,
        with HTTP 202. There is no scan to poll.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AddressScanRequest' }
      responses:
        "202":
          $ref: '#/components/responses/AddressScanDecision'
        "400":
          description: >
            error invalid_request: the body is invalid, for example a missing
            address or disagreeing chain hints. error unsupported_network: the
            network is not one SafeClick scans (for example Optimism). No quota
            is used for either.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        "401": { description: "Missing, invalid, expired, paused, or revoked API key", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "403": { description: "insufficient_scope (no scan.execute), or access_not_granted with reason absent/expired/exhausted/disabled", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "429": { description: rate_limited; honor Retry-After, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "409":
          description: >
            reason payload_mismatch means the Idempotency-Key was already used
            with a different request payload. Use a new key for a new or changed
            scan. reason in_flight means SafeClick has not settled the first
            request with this key yet; it may still be running or be held after
            a failure. Retry later with the same key and request body, and
            contact SafeClick if it persists.
          content: { application/json: { schema: { $ref: '#/components/schemas/SupportingScanConflict' } } }
        "503":
          description: >
            error pipeline_unavailable or access_grant_unavailable.
            SafeClick could not confirm the scan, so there is nothing to act on:
            do not execute. Retry with the same Idempotency-Key and request
            body once the service is available; a new key starts a second
            scan. If the scan never started, its quota hold is released and the
            retry runs it. If the scan may already have a decision whose
            response was lost, or the hold could not be released, the quota
            stays reserved until SafeClick settles it, and until then the retry
            returns 409 with reason in_flight.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
  /scan/contract:
    post:
      tags: [Supporting analysis]
      summary: Evaluate a contract address
      description: >
        Waits for SafeClick's decision and returns it in the same response,
        with HTTP 202. There is no scan to poll.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AddressScanRequest' }
      responses:
        "202":
          $ref: '#/components/responses/AddressScanDecision'
        "400":
          description: >
            error invalid_request: the body is invalid, for example a missing
            address or disagreeing chain hints. error unsupported_network: the
            network is not one SafeClick scans (for example Optimism). No quota
            is used for either.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        "401": { description: "Missing, invalid, expired, paused, or revoked API key", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "403": { description: "insufficient_scope (no scan.execute), or access_not_granted with reason absent/expired/exhausted/disabled", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "429": { description: rate_limited; honor Retry-After, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "409":
          description: >
            reason payload_mismatch means the Idempotency-Key was already used
            with a different request payload. Use a new key for a new or changed
            scan. reason in_flight means SafeClick has not settled the first
            request with this key yet; it may still be running or be held after
            a failure. Retry later with the same key and request body, and
            contact SafeClick if it persists.
          content: { application/json: { schema: { $ref: '#/components/schemas/SupportingScanConflict' } } }
        "503":
          description: >
            error pipeline_unavailable or access_grant_unavailable.
            SafeClick could not confirm the scan, so there is nothing to act on:
            do not execute. Retry with the same Idempotency-Key and request
            body once the service is available; a new key starts a second
            scan. If the scan never started, its quota hold is released and the
            retry runs it. If the scan may already have a decision whose
            response was lost, or the hold could not be released, the quota
            stays reserved until SafeClick settles it, and until then the retry
            returns 409 with reason in_flight.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
  /scan/transaction:
    post:
      tags: [Enforcement]
      summary: Evaluate a transaction before execution
      description: >
        Submit the exact transaction you intend to execute. The scan has a
        maximum of 960 seconds from acceptance; a scan without a final
        decision by then ends as failed, and after that the result does not
        change. Execute only a final ALLOW or FINAL_ALLOW, and only for the
        exact transaction submitted: a new signature, blockhash, nonce, or any
        other changed byte needs a new scan. A retry with the same
        Idempotency-Key and request body returns the original scan_id
        without another scan or charge while SafeClick holds that key (see
        Idempotency-Key). Read status and action on the reply, and use
        GET /scans/{id} for the latest state. A request without an Idempotency-Key
        always starts a new scan. Solana results currently arrive after a
        typical recent blockhash has expired, so Solana use is shadow only.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransactionScanRequest' }
      responses:
        "202":
          description: >
            Scan accepted with a scan_id, usually within a few seconds. The
            body is usually status processing; poll GET /scans/{id} until
            status is final or failed. The response may already be final or
            failed, so always read status and action rather than the HTTP code.
          content: { application/json: { schema: { $ref: '#/components/schemas/PublicScanSummary' } } }
        "400":
          description: >
            Invalid or conflicting transaction representation. error
            unsupported_network means the network is not one SafeClick scans
            (for example Optimism); no quota is used.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } }
        "409": { description: The Idempotency-Key was already used with a different request body. Use a new key for a new or changed transaction., content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "401": { description: Missing or invalid API key }
        "403": { description: "Insufficient scope, tenant mismatch, or scan access grant absent/expired/exhausted/disabled" }
        "429": { description: Request budget exceeded; honor Retry-After }
        "503":
          description: >
            SafeClick could not confirm the scan, so there is nothing to act
            on: do not execute. The scan may already be stored, queued, or
            even decided. Retry with the same Idempotency-Key and request
            body; a new key starts a second scan. A retry can return 503 again
            while the queue is unavailable.
  /access-grant:
    get:
      tags: [Team configuration]
      summary: Read the authenticated Team's scan access grant status
      description: >
        Returns the grant status for the Team bound to the presented
        credential. This route is read-only and is available to any
        authenticated Team member. `granted` reports the Team-level funding
        allowance, while `canScan` reports whether this credential has the
        `scan.execute` capability. `role` and `canFailOpenLiveDemo` are
        derived from the verified credential, never from cached browser
        storage. It does not consume a grant.
      responses:
        "200":
          description: Team access grant status
          content: { application/json: { schema: { $ref: '#/components/schemas/AccessGrantStatus' } } }
        "401": { description: Missing or invalid API key }
        "429": { description: Request budget exceeded; honor Retry-After }
  "/scans/{id}":
    get:
      tags: [Enforcement]
      summary: Read the current or final decision
      description: >
        Read a scan by its scan_id. Returns 202 while status is processing and
        200 once it is pending_validation, final, or failed. Poll every few
        seconds until status is final or failed; reads do not start new work.
        From 960 seconds after acceptance a transaction scan is never
        processing or pending_validation: a read at or after that deadline
        returns the final or failed result, and it does not change afterwards.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Public scan summary, content: { application/json: { schema: { $ref: '#/components/schemas/PublicScanSummary' } } } }
        "202": { description: Scan still processing, content: { application/json: { schema: { $ref: '#/components/schemas/PublicScanSummary' } } } }
        "404": { description: Unknown scan or a scan outside this Team }
  "/sandbox2/{id}/evidence":
    post:
      tags: [Validation]
      summary: Ingest plan-pinned external approval evidence
      description: >
        Authenticated organization-scoped Stage 2 intake. Beta routes do not
        ask the Stage 3 peer to evaluate and do not emit a final action. The
        202 body is ingestion metadata plus a non-executable HOLD annotation
        (`legacy_sequential_finalization: disabled`, `executable: false`,
        `enforcement_action: DO_NOT_BROADCAST`,
        `replacement_mpc_sp1_verified: false`).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ValidationEvidenceEnvelope' }
      responses:
        "202":
          description: Evidence accepted or idempotently replayed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EvidenceIngestionResult' }
  "/intel/domain/{domain}":
    get:
      tags: [Supporting analysis]
      summary: Read stored domain intelligence
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Domain intelligence; unknown domains return known false
          content: { application/json: { schema: { $ref: '#/components/schemas/DomainIntelligence' } } }
  "/intel/address/{address}":
    get:
      tags: [Supporting analysis]
      summary: Read stored address intelligence
      parameters:
        - { name: address, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Address intelligence; unknown addresses return known false
          content: { application/json: { schema: { $ref: '#/components/schemas/AddressIntelligence' } } }
  /policies:
    get:
      tags: [Team configuration]
      summary: List policy versions
      responses:
        "200":
          description: Team policy versions
          content:
            application/json:
              schema:
                type: object
                required: [policies]
                properties:
                  policies: { type: array, items: { $ref: '#/components/schemas/PolicyRule' } }
    post:
      tags: [Team configuration]
      summary: Create a policy version
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicyWriteRequest' }
      responses:
        "201": { description: Policy version created, content: { application/json: { schema: { $ref: '#/components/schemas/PolicyRule' } } } }
        "400": { description: Invalid policy }
  /webhooks:
    get:
      tags: [Team configuration]
      summary: List webhook endpoints
      responses:
        "200":
          description: Webhook endpoints without plaintext secrets
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
    post:
      tags: [Team configuration]
      summary: Create a webhook endpoint
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookRegistrationRequest' }
      description: >
        `url` must be a public https URL of at most 2048 characters. Leave
        `events` out, or send an empty list, to receive all events. A Team
        can have 10 active endpoints.
      responses:
        "201": { description: Endpoint created; plaintext signing secret returned once, content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } } }
        "400": { description: "invalid_request, for example a URL that is not public https or an unknown event type", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "409": { description: "error conflict: the URL is already registered, or the Team already has 10 active endpoints", content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
  "/webhooks/{id}":
    delete:
      tags: [Team configuration]
      summary: Remove a webhook endpoint
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses: { "204": { description: Endpoint removed }, "404": { description: Not found } }
  "/webhooks/{id}/rotate":
    post:
      tags: [Team configuration]
      summary: Rotate a webhook signing secret
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Secret rotated and returned once, content: { application/json: { schema: { $ref: '#/components/schemas/Webhook' } } } }
        "404": { description: Not found }
  "/webhooks/{id}/deliveries":
    get:
      tags: [Team configuration]
      summary: Read webhook delivery attempts
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Paginated delivery attempts, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookDeliveryPage' } } } }
        "404": { description: Not found }
  /webhooks/test:
    post:
      tags: [Team configuration]
      summary: Build or deliver a signed test webhook
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookTestRequest' }
      responses:
        "200": { description: Signed test event metadata, content: { application/json: { schema: { $ref: '#/components/schemas/WebhookTestResult' } } } }
  /usage:
    get:
      tags: [Team configuration]
      summary: Read metered API usage
      responses:
        "200": { description: Team usage metrics, content: { application/json: { schema: { $ref: '#/components/schemas/UsageSummary' } } } }
  /access-requests:
    post:
      tags: [Team configuration]
      summary: Request API access for the current Team
      description: >
        Portal-only workflow. Requires a signed Organization session rather
        than an API key. The request body is empty: the REST service resolves
        Team ID, Team name, and applicant email from the live session
        membership and sends one notification to the fixed SafeClick partner
        mailbox. This does not create or enable a grant.
      security:
        - SessionAuth: []
      responses:
        "202":
          description: Notification accepted by the configured Resend transport.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccessRequestResponse' }
        "401": { description: Missing or invalid Organization session, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "403": { description: A Team member session is required, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "502": { description: Notification provider refused the request, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
        "503": { description: Notification transport is not configured, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
