openapi: 3.0.3
info:
  title: PitchPoint SAMI Workflow API
  description: |
    Workflow-scoped, asynchronous, push-based public surface for starting and reconfirming a SAMI analysis, and for editing what the customer reports about a workflow, such as the outcome of its application. Every endpoint is scoped to a workflow via an opaque workflowId, which stays stable across a reconfirm.
    IDENTIFIERS. A workflow response carries three: `workflowId` (`wfpop_…`, the handle for every read and reconfirm, stable for the life of the workflow), `runId` (`wfrunpop_…`, the one execution a start or reconfirm created, and on a read the execution whose state is being reported), and `sessionId` (the session whose data the workflow was ordered against). The first two carry the same prefixes as the `data.workflowId`/`data.runId` fields on webhook events, so a value seen in either place means the same thing. Every path that takes a workflowId accepts it with or without its prefix. All three are opaque: store and echo them, never parse them.
    CORRELATION. Every start, reconfirm, and session order accepts an optional `correlationId` — your own reference for THAT run, up to 255 characters. It is stored on the run, echoed on the run's responses and webhook events, and is the key `GET /v1/workflows?correlationId=` searches by. It is yours, so it is not unique: reusing one across runs is allowed, and the search returns every workflow it matched.
    ERRORS. TWO ERROR SHAPES EXIST ON THIS SURFACE and the split is worth learning once. The messages[] envelope is the APPLICATION's, served as `application/json`, and carries a machine-readable messages[].code. The `Problem` document is the PLATFORM's, served as `application/problem+json`, and carries no code at all; it is defined in problems-v1.yaml, which is published beside this document and shared with the other PitchPoint APIs, so keep the files side by side. Which one you receive follows WHICH LAYER ANSWERED, never the status code — so a 400 and a 409 may arrive as either, while a 401, 403, 404 or 500 is always a problem document. Read the Content-Type, or read `messages` and treat its absence as a problem document.
    A problem document's `type` is the stable identifier your program matches on. The complete set this surface (and the session paths, which share it) returns:

      * `about:blank` — RFC 9457's own placeholder, meaning the status alone describes the
        problem. Carried by a 401 when no credential was presented, an authorization denial
        refused before the endpoint ran, a request body that cannot be bound to the operation's
        type, a path under /resources this API does not serve, and every 500.

      * `https://pointservices.com/problems/invalid-token` (401) — a credential was presented and
        rejected. See the Unauthorized response in problems-v1.yaml for why no further reason is
        given.

      * `https://pointservices.com/problems/not-authorized` (403) — you are authenticated and
        policy will not let you act on this resource. On an order this is most often that the
        signed-in user is not entitled to the product or model requested, which is an
        account-provisioning matter rather than anything about the request you sent. A refused
        start creates no session and no workflow.

      * `https://pointservices.com/problems/not-found` (404) — no resource with that identifier.
        This type comes only from an identifier that did not resolve; a path this API does not
        serve is the `about:blank` 404 above, so the two 404s are told apart by `type`. A resource
        that EXISTS but is not yours is `not-authorized` (403) instead, because these paths
        resolve before they authorize.

      * `https://pointservices.com/problems/invalid-request` (400) — the Content-Type header names
        a character set this service cannot use. Raised before the body is read, so it is never a
        statement about anything you put in it.

      * `https://pointservices.com/problems/concurrent-modification` (409) — another writer won an
        optimistic lock. TERMINAL for this request and carrying no Retry-After: re-read the
        resource before resubmitting rather than retrying the same bytes.

    RETRIES. Every POST on this surface but the edit, and on the session paths, accepts an optional `Idempotency-Key` header (see the IdempotencyKey parameter); the edit needs none, because repeating it only records the same outcome again. Send one and a timed-out or dropped request can be retried blind: a retry under the same key returns the first response, marked `X-Pps-Idempotent-Replay: true`, rather than doing the work twice, and a retry that arrives while the first is still running answers 409 IDEMPOTENCY_IN_PROGRESS with a `Retry-After` in seconds — retry that for a few minutes at most, then reconcile by correlationId (the parameter's description says how). Without a key nothing is de-duplicated, so a 500 on a start or an order must be resolved by reading state before it is retried — retrying blind is how one order becomes two.
    Not every rejection carries a JSON body. Some requests are answered with a bare status and nothing this document models, so write your client to tolerate an error response it cannot parse rather than assuming every failure is a problem document.
    SEND `Content-Type: application/json` ON EVERY POST, INCLUDING ONES WITH NO BODY. The reconfirm below is the trap: its body is optional, and an HTTP client told to send no body will often drop the header with it. A POST without that header is rejected, with no JSON body to explain itself.
    Every timestamp this API publishes is an RFC 3339 instant in UTC with a trailing `Z`, for example `2026-08-07T15:01:42Z`. They are declared as `type: string` with `format: date-time`. Fractional seconds are emitted only when they are not zero, so do not assume a fixed field width, and parse with a library rather than by slicing the string. The same convention governs the session paths, which share these schemas.
  version: 1.0.0
servers:
  - url: /riskinsight-services-ws/resources
    description: Everything after the host. Combine it with your PPS environment's host to form absolute URLs — for example `https://<your-pps-host>/riskinsight-services-ws/resources/v1/workflows`.
security:
  - BearerAuth: []
paths:
  /v1/workflows:
    get:
      summary: Find the workflows a correlation id was supplied to.
      description: |
        Answers with every workflow the caller may read that has at least one run carrying this correlationId — the value supplied on a start, a reconfirm, or a session order. A correlationId is the caller's own reference and is not unique, so this is a search that returns a list, never a lookup that returns one: an empty `workflows` array with a 200 is the ordinary "nothing matched" answer, and a 404 is never used for it.
        Each element is the same SamiWorkflowEnvelope the workflow's own GET returns, describing the workflow's CURRENT state — its newest run — which is not necessarily the run the correlationId was supplied to. A workflow reconfirmed since then is still listed, because the correlation id was supplied to one of its runs; read `workflow.runId` and `workflow.correlationId` to see which run is being reported.
        Newest first, and at most 100 workflows. A correlation id reused across more than 100 of YOUR workflows returns the newest 100; make ids more specific rather than paging.
        Workflows the caller is not entitled to read are silently absent — another tenant reusing the same string never produces a 403 here, only a shorter list — and they do not count against the 100: the search reads past them to find yours. That reading is bounded to the newest 1,000 matching runs across all tenants, so if a string is reused far more than that by others, older workflows of yours under it may be absent; a more specific id is the remedy.
      operationId: listWorkflows
      parameters:
        - name: correlationId
          in: query
          description: |
            The correlationId to search for. Matching is on the whole value — never a prefix or substring — and whether it is case-sensitive follows the data store's collation, so do not rely on case to tell two ids apart. Absent or blank is a 400. A value resembling SQL injection or markup is rejected, and the rejection carries no JSON body — see the top-level description.
          required: true
          style: form
          explode: true
          schema:
            maxLength: 255
            type: string
      responses:
        '200':
          description: |
            The matching workflows, newest first, possibly empty.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowListEnvelope'
        '400':
          description: |
            The correlationId query parameter is absent or blank. Carries the envelope with VALIDATION_ERROR.
            One other 400 reaches this operation and carries the PROBLEM shape instead: a Content-Type header naming a character set this service cannot use (`https://pointservices.com/problems/invalid-request`). It is refused before the request is evaluated, so it has no messages[] to branch on. There is no unbindable-body 400 here — this operation takes no request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            The path is not served here — a wrong host, or an environment where this API is not deployed. Body is a problem document carrying `about:blank`, NOT a SamiWorkflowEnvelope: the closed MessageCode vocabulary has no NOT_FOUND member, so there is no code to put in one. Never used for "no match"; that is a 200 with an empty list, and this operation resolves no identifier that could produce a `not-found`.
            NOT being entitled to a particular matching workflow is not an error on this operation at all — that workflow is simply omitted from the list.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      summary: Start a SAMI workflow asynchronously.
      operationId: startWorkflow
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartRequest'
        required: true
      responses:
        '202':
          description: |
            Accepted for processing. workflow.workflowId is stable across every future reconfirm. workflow.runId names this first run — the same value as workflowId behind a different prefix, since the workflow's first run IS the workflow. workflow.correlationId echoes the value sent, and is absent when none was.
            workflow.sessionId names the session this start created. A start behaves as if you had started a session, staged the terms into it, and ordered the product against it, except that the service mints the session and hands its identifier back. One difference from making those calls yourself: correlationId belongs to the run only, so the session a start creates does not carry your value as session.correlationId. Otherwise it is an ordinary session: read it at GET /v1/sessions/{sessionId}, stage a further snapshot into it, or order another product against it.
            A start takes terms only. To start a workflow from a MISMO 3.4 loan file you already hold, make the three calls yourself: start a session (POST /v1/sessions), stage the document into it (POST /v1/sessions/{sessionId}/mismo34), then start the workflow (POST /v1/sessions/{sessionId}/workflows).
            A retry under the same Idempotency-Key answers this same 202, byte for byte, marked X-Pps-Idempotent-Replay; no second workflow is started. The replay is the original acknowledgment — read the workflow for its current state.
          headers:
            X-Pps-Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
        '400':
          description: |
            Boundary rejection — an absent, blank, null, or malformed body; a missing or malformed product (or one carrying only one of service/model); an unresolvable service+model pair; a missing or non-array terms; a terms array containing a null or non-object entry, or an entry carrying other than exactly one key; a correlationId or referenceNumber longer than 255 characters; or an Idempotency-Key that is blank, longer than 255 characters, or already used with different parameters. These carry the envelope with VALIDATION_ERROR.
            A 400 HAS TWO BODY SHAPES AND A CLIENT MUST HANDLE BOTH. Everything above is a value this API evaluated and rejected, so it arrives in the envelope. A body that cannot be BOUND to this operation's request type at all — a JSON array or a bare string where an object is required, or malformed JSON — is refused before evaluation begins and arrives as a problem document with `about:blank` and NO messages key. So does a Content-Type naming an unusable character set (`.../problems/invalid-request`). A client that reads messages[0].code on every 400 breaks on all of them.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            The path is not served here — a wrong host, or an environment where this API is not deployed. Body is a problem document carrying `about:blank`, NOT a SamiWorkflowEnvelope — the envelope cannot carry a not-found because the closed MessageCode vocabulary has no NOT_FOUND member (see the GET /v1/workflows 404), and a start has no identifier to resolve, so it never produces the `not-found` type (see ERRORS in this document's description).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            A concurrent write to the same workflow was detected. Terminal either way: the server does not retry automatically, so the client decides whether to resubmit.
            TWO BODY SHAPES, and today only the second is emitted. The envelope carries WORKFLOW_WRITE_CONFLICT. The problem document carries `https://pointservices.com/problems/concurrent-modification`, is what a lost optimistic lock actually answers with right now, and has NO messages key — so a client branching on messages[0].code for every 409 breaks on the only one it will presently meet. It carries no Retry-After deliberately: re-read the workflow before resubmitting rather than retrying the same bytes.
            IDEMPOTENCY_IN_PROGRESS, in the envelope, is the one 409 that is NOT terminal: a request under the same Idempotency-Key is still running. It is the only 409 carrying Retry-After — wait that many seconds and retry the identical request under the same key, which then answers the first request's response, or the 400 VALIDATION_ERROR if the key was first used with a different body.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/workflows/{workflowId}:
    get:
      summary: Read one workflow's current state.
      operationId: getWorkflow
      parameters:
        - name: workflowId
          in: path
          description: |
            The opaque handle returned by a start or reconfirm, or carried as data.workflowId on a webhook event. Accepted with or without its `wfpop_` prefix. Never parsed by clients.
          required: true
          style: simple
          explode: false
          schema:
            type: string
      responses:
        '200':
          description: |
            Current workflow state, including recased terms. workflow.runId and workflow.correlationId describe the workflow's newest run — after a reconfirm, the reconfirm — which is the run whose status, timestamps and terms are reported.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Unknown workflowId. Terminal — read-after-write means a handle a 202 just issued is immediately GET-able, so this is never a race to retry. Body is a problem document carrying `https://pointservices.com/problems/not-found`, NOT a SamiWorkflowEnvelope: the closed MessageCode vocabulary has no NOT_FOUND member, so there is no code to put in one.
            Treat this status as a single outcome. It does not tell you which of several conditions produced it, and no detail in the body narrows it down. Check the handle you sent and the tenancy of the credential you sent it with.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      summary: Reconfirm a workflow under its existing handle.
      description: |
        Re-runs this workflow. The response carries the SAME workflowId, so a reconfirm never costs you the handle you are already tracking.
        THE BODY IS OPTIONAL, and which you send decides what the workflow runs against.
        Send no body at all to re-run against the data staged in the workflow's session (workflow.sessionId). Nothing is recorded — no new snapshot — so this is the call to use when you simply want the analysis performed again. It runs against the session's CURRENT snapshot: if the session has been re-staged since the original order, the reconfirm sees the re-staged data.
        Send a body carrying `terms` to re-run against changed data. The terms are recorded as a new snapshot of the workflow's session, exactly as staging them at POST /v1/sessions/{sessionId}/sami would, and the reconfirm runs against that snapshot. It becomes the session's current data: the session reads it back, and later orders against the session use it. The terms are a complete statement of the data, not a patch: anything you leave out is absent from the new snapshot, which is how a party is removed. The same goes for `referenceNumber`, which only a body carrying terms may send: it is recorded on the new snapshot, and leaving it out clears the loan's reference number.
        Either way the body may also carry `correlationId` — your own reference for THIS run. A reconfirm is a new run, so it takes its own correlationId rather than inheriting the original order's; a body carrying only `correlationId` re-runs against the session's current data exactly as no body would, and tags the new run.
        "No body" means an absent, empty, or whitespace-only request, and `{}` is treated the same way. A body that arrives and cannot be used is a rejection: a literal `null`, a JSON array, a bare string, and malformed JSON are all 400s.
        STILL SEND `Content-Type: application/json` WHEN YOU SEND NO BODY. "No body" is about the payload, never the header: a request arriving with no Content-Type is rejected, so the no-body reconfirm — the one call most likely to be built by telling an HTTP client to omit the body, header and all — is also the one most likely to fail with no JSON body to explain itself. See this document's top-level description.
      operationId: reconfirmWorkflow
      parameters:
        - name: workflowId
          in: path
          description: |
            The workflow's handle, accepted with or without its `wfpop_` prefix.
          required: true
          style: simple
          explode: false
          schema:
            type: string
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReconfirmRequest'
        required: false
      responses:
        '202':
          description: |
            Reconfirmed. Carries the SAME workflowId as before this call, and a NEW workflow.runId naming the reconfirm run. Like every acknowledgment it reports workflow.reconfirmAvailable false. workflow.correlationId echoes the value sent with this call, and is absent when none was — the original order's correlationId is never carried over.
            A retry under the same Idempotency-Key answers this same 202, byte for byte, marked X-Pps-Idempotent-Replay, naming the same runId; no second run is started. A key is scoped to the workflowId in the path exactly as sent, so retry with the identical URL. A retry WITHOUT a key starts a second run, and one carrying terms also records the session's snapshot a second time.
          headers:
            X-Pps-Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
        '400':
          description: |
            Boundary rejection — a body that arrived but cannot be used: a literal null, malformed JSON, a JSON array or bare string, a non-array terms, a terms array containing a null or non-object entry or an entry carrying other than exactly one key, a correlationId or referenceNumber longer than 255 characters, or a referenceNumber without terms (there is no new snapshot to record it on). Also a workflow whose product no longer resolves to a reconfirmable product, and an Idempotency-Key that is blank, longer than 255 characters, or already used with different parameters. Always VALIDATION_ERROR.
            NOT a rejection: an absent, empty, whitespace-only, or `{}` body. That is the documented way to re-run against the session's current data — see this operation's description. Nor is a body carrying product, a parent identifier, or any other field this operation does not define: such a field is ignored and cannot change what runs.
            A 400 HAS TWO BODY SHAPES HERE TOO — see the 400 on POST /v1/workflows. A body that cannot be bound to this operation's request type, and a Content-Type naming an unusable character set, both arrive as problem documents with no messages key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Unknown workflowId. Body is a problem document, NOT a SamiWorkflowEnvelope — see the GET's 404 above, including for why a workflow that exists but is not yours answers 403 rather than this status.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            Either RECONFIRM_NOT_AVAILABLE (workflow.reconfirmAvailable is false — the one error case that carries the FULL envelope, including workflow, unlike every other error response) or WORKFLOW_WRITE_CONFLICT (a concurrent write to the same workflow was detected).
            A THIRD 409 CARRIES THE PROBLEM SHAPE INSTEAD: `https://pointservices.com/problems/concurrent-modification`, with no messages key, is what a lost optimistic lock answers with today. Branch on the Content-Type first, then on messages[].code — never on the 409 alone.
            Or IDEMPOTENCY_IN_PROGRESS, in the envelope: a request under the same Idempotency-Key is still running. The only 409 here that is not terminal, and the only one carrying Retry-After — see the 409 on POST /v1/workflows.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/workflows/{workflowId}/edit:
    post:
      summary: Report what happened to a workflow's application, in one call.
      description: |
        A partial update: send any of the fields of the body in one call. A field left out of the body is left unchanged. A body that carries none of the fields below is a 400, since there would be nothing to edit. Today the one field is `outcome`.
        `outcome` reports what happened to the application behind this workflow, as your own system knows it: one reason code, for example `{"outcome": "APPLICATION_WITHDRAWN"}`. Report ONLY the outcome. The response is the workflow's current state, in which `workflow.outcomes` lists every outcome reported so far, the one just sent included: each with its `reason`, whether the product counts it as `serviceable`, and `reportedAt`, the moment it was recorded.
        THE PRODUCT DECIDES WHAT IS SERVICEABLE, NEVER THE CALLER. A product that takes outcome reports is configured with two lists: the outcomes that count as serviceable (the business result you pay for) and the outcomes that count as unserviceable. Only an outcome on one of the two lists is accepted; any other outcome is refused with a 400 that names the outcomes the product accepts, and nothing is recorded. Each report is judged against the product's lists AS THEY STAND WHEN THE REPORT IS MADE, and that answer is recorded with the report: a later change to the product's lists never changes an outcome already reported, and applies from the next report on. Each entry's `serviceable` in `workflow.outcomes` is the answer recorded with that report. A workflow whose product does not take outcome reports cannot take one: the call is a 400 and nothing is recorded. The outcomes are industry-agnostic application outcomes, with further groups for mortgage, tenant-screening and employment-screening outcomes (the mortgage group follows the HMDA action-taken codes of Regulation C, Appendix A); see `OutcomeReason` for the members and their meanings.
        REPORT EVERY WORKFLOW. While a product takes outcome reports, the platform waits for the first report on each of its workflows before it settles that workflow's billable status, so a workflow nobody reports on stays unsettled.
        EVERY REPORT IS KEPT, AND ORDER DOES NOT MATTER. No report replaces another: your system may send outcomes in any order, so none of them is treated as the workflow's current outcome. Every call records a new report, so repeating a call is safe: a timed-out or dropped call can simply be sent again. This operation takes no Idempotency-Key.
        REPORT WHEN THE OUTCOME CHANGES, NOT ON EVERY SYNC. Each call records a new report (and a new entry in the workflow's history), so re-sending the same outcome on every synchronization only accumulates history.
        THE WORKFLOW IS SERVICEABLE ONCE ANY REPORTED OUTCOME IS. From the first report on, `workflow.serviceable` is true when ANY entry of `workflow.outcomes` is serviceable, and false when none is; a later unserviceable report does not undo an earlier serviceable one. Before the first report, `workflow.serviceable` keeps reporting the workflow's billing entries.
        OUTCOMES DO NOT CHANGE WHAT THE WORKFLOW API ALLOWS: a reconfirm, and every other operation, is accepted or refused exactly as it would be had no outcome been reported, whatever outcomes have been reported.
        SEND THE WORKFLOW'S OWN HANDLE. `workflowId` is the value a start returned (`wfpop_…`, with or without the prefix). A run identifier (`wfrunpop_…`, `workflow.runId`) is refused with a 400: an edit is about the whole workflow, never about one run of it.
        WHO MAY CALL IT. Reporting an outcome needs one capability, granted to a credential by PitchPoint on request and separate from the ability to place or change orders, and the credential must also be allowed to read the workflow. If either is missing the call is refused with a 403 and nothing is changed.
        THE BODY IS REQUIRED, and a missing Content-Type is rejected with no JSON body to explain itself — see this document's top-level description.
      operationId: editWorkflow
      parameters:
        - name: workflowId
          in: path
          description: |
            The workflow's handle, accepted with or without its `wfpop_` prefix. A run identifier (`wfrunpop_…`) is refused with a 400.
          required: true
          style: simple
          explode: false
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkflowEditRequest'
        required: true
      responses:
        '200':
          description: |
            The workflow's current state, exactly as GET /v1/workflows/{workflowId} would report it, reflecting the edit just recorded: with an `outcome` in the body, `workflow.outcomes` includes that report, with whether the product counts it as `serviceable` and the `reportedAt` moment it was recorded, beside every earlier report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
        '400':
          description: |
            Boundary rejection, always VALIDATION_ERROR in the envelope: a body carrying no recognised field (an absent, empty or `{}` body included), an `outcome` that is not one of its current members, an `outcome` for a workflow whose product does not take outcome reports, an `outcome` the workflow's product does not accept (the message names the outcomes it accepts), or a run identifier (`wfrunpop_…`) or any other id that names a run rather than the workflow itself.
            A 400 HAS TWO BODY SHAPES HERE TOO — see the 400 on POST /v1/workflows. A body that cannot be bound to this operation's request type (malformed JSON, a literal null, an `outcome` sent as an object or an array instead of a string), and a Content-Type naming an unusable character set, both arrive as problem documents with no messages key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SamiWorkflowEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Unknown workflowId. Body is a problem document, NOT a SamiWorkflowEnvelope — see the GET's 404 above — carrying `https://pointservices.com/problems/not-found`. A workflow that exists but is not yours answers 403 rather than this status.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            `https://pointservices.com/problems/concurrent-modification`, as a problem document with no messages key: another writer changed the workflow while this request was in flight. Nothing was applied; call again.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  schemas:
    ProductSelector:
      required:
        - model
        - service
      type: object
      properties:
        service:
          type: string
          example: ADV
        model:
          type: string
          example: adv-120
      additionalProperties: false
    CorrelationId:
      maxLength: 255
      type: string
      description: |
        Your own reference for the run this request creates — an LOS transaction id, a case number, anything you will want to find the workflow by later. Stored on that run, echoed as workflow.correlationId on its responses and as data.correlationId on its webhook events, and searched by GET /v1/workflows?correlationId=. Not unique: reuse is allowed and the search returns everything it matches. Leading and trailing whitespace is trimmed; a blank value is treated as absent. Longer than 255 characters is a 400. A value resembling SQL injection or markup is rejected with no JSON body — encode such values (Base64, for instance) rather than sending them raw.
    ReferenceNumber:
      maxLength: 255
      type: string
      description: |
        Your own identifier for the loan — the loan or case number your staff will quote when they ask about it. Recorded on the snapshot these terms create, and shown as the loan's reference number in Fulfillment. Optional, and part of the snapshot exactly as terms are: leaving it out, sending null, or sending only whitespace records the snapshot without one and clears the loan's reference number. The workflow reports it as workflow.referenceNumber and beside its terms. Leading and trailing whitespace is trimmed; longer than 255 characters is a 400. A value resembling SQL injection or markup is rejected with no JSON body — encode such values (Base64, for instance) rather than sending them raw. Independent of correlationId: this API does not copy one into the other.
    StartRequest:
      required:
        - product
        - terms
      type: object
      properties:
        product:
          $ref: '#/components/schemas/ProductSelector'
        terms:
          $ref: '#/components/schemas/Terms'
        correlationId:
          $ref: '#/components/schemas/CorrelationId'
        referenceNumber:
          $ref: '#/components/schemas/ReferenceNumber'
      description: |
        product and terms are required; correlationId and referenceNumber are optional.
    ReconfirmRequest:
      type: object
      properties:
        terms:
          $ref: '#/components/schemas/Terms'
        referenceNumber:
          $ref: '#/components/schemas/ReferenceNumber'
        correlationId:
          $ref: '#/components/schemas/CorrelationId'
      description: |
        Every field is optional. Send `terms` only when the data has changed — they are recorded as a new snapshot of the workflow's session; omitting the body entirely, or sending `{}`, re-runs the workflow against the session's current data. Send `referenceNumber` with `terms` to record it on that snapshot; without `terms` it is a 400, since no snapshot is recorded. Send `correlationId` to tag the run this reconfirm creates — it is not inherited from the original order.
        A body carrying product, a parent identifier, or any other field not listed here is ignored and cannot change what runs — the URL is the only place a workflow is ever named, and the product always comes from that workflow.
    WorkflowEditRequest:
      type: object
      properties:
        outcome:
          $ref: '#/components/schemas/OutcomeReason'
      description: |
        The request body for POST /v1/workflows/{workflowId}/edit — a partial update. Every field is optional; a field left out of the body is left unchanged, and a JSON null is treated the same as leaving the field out. A body carrying none of the fields is a 400, since there is nothing to edit.
        A body carrying a key this schema does not list is not rejected for it: the key is ignored, and a body carrying nothing else is the "no field" 400 above.
    OutcomeReason:
      type: string
      description: |
        What happened to the application behind a workflow, as the customer's own system knows it:
        the one thing the customer reports about a workflow. Report ONLY the outcome; which outcomes
        a product accepts, and which of those count as serviceable, is the product's configuration,
        never the caller's. Closed and
        additive, like MessageCode: a client that meets a member it does not recognize should treat
        it as an unrecognized outcome rather than fail. Any value that is not a current member is a
        400 when sent.

        Current members, in groups. The vocabulary is industry-agnostic: report the outcome that
        fits your application, from whichever group describes it.

        Application outcomes, for an application of any kind: a loan, a rental or a job. For
        example, an offer a candidate declines is APPLICATION_APPROVED_BUT_NOT_ACCEPTED, and a
        candidate who is turned down is APPLICATION_DENIED. For a mortgage customer the first four
        are also HMDA action-taken outcomes (Regulation C, Appendix A), with the code in
        parentheses:

        * APPLICATION_APPROVED_BUT_NOT_ACCEPTED (HMDA 2): approved, but the applicant never accepted
          the approval within the specified time.
        * APPLICATION_DENIED (HMDA 3): the application was not approved.
        * APPLICATION_WITHDRAWN (HMDA 4): the applicant expressly withdrew the application before it
          was denied or the file was closed for incompleteness.
        * FILE_CLOSED_FOR_INCOMPLETENESS (HMDA 5): a written notice of incompleteness went
          unanswered within the period it specified, so the file was closed.
        * APPLICATION_CANCELLED: cancelled by you or your system without an applicant withdrawal.
        * DUPLICATE_APPLICATION: duplicates another application, which carries the outcome.
        * TEST_APPLICATION: a test or training file, not a real application.

        Mortgage outcomes. The names follow the MISMO 3.4 HMDA disposition vocabulary, with the
        HMDA action-taken code in parentheses where there is one:

        * LOAN_ORIGINATED (HMDA 1): a covered loan was originated, including one resulting from a
          request for preapproval.
        * LOAN_PURCHASED_BY_YOUR_INSTITUTION (HMDA 6): your institution purchased the loan.
        * PREAPPROVAL_REQUEST_DENIED (HMDA 7): a preapproval request was not approved.
        * PREAPPROVAL_REQUEST_APPROVED_BUT_NOT_ACCEPTED (HMDA 8): a preapproval was approved but
          never accepted within the specified time.
        * LOAN_FUNDED: the proceeds were disbursed. HMDA has no code for it: it counts an
          origination at closing, which can come before funding.
        * LOAN_RESCINDED: the borrower rescinded the loan after closing. HMDA has no code of its own
          for it and reports it as code 2.

        Tenant-screening outcomes, for a rental application (none has an HMDA code):

        * LEASE_SIGNED: the application became a signed lease; what a customer that is paid per
          lease bills on.
        * APPLICANT_MOVED_IN: the tenant took occupancy, for a customer that bills at move-in rather
          than at signing.
        * UNIT_NO_LONGER_AVAILABLE: the unit was leased to another applicant or taken off the
          market before this application concluded.
        * APPROVED_WITH_CONDITIONS: approved subject to a condition such as an extra deposit, a
          co-signer or prepaid rent; an interim decision.

        Employment-screening outcomes, for a pre-employment screening (none has an HMDA code; the
        application outcomes above also apply):

        * CANDIDATE_HIRED: the candidate was hired; what a customer that is paid per hire bills on.
        * CANDIDATE_STARTED: the new hire started work, for a customer that bills at the start date
          rather than at hire.
        * OFFER_RESCINDED: the employer withdrew a (conditional) offer, typically after the
          pre-adverse and adverse action process on the screening report.
        * POSITION_NO_LONGER_AVAILABLE: the position was filled by another candidate, or its
          requisition was closed or cancelled, before this candidate's process concluded.
      example: APPLICATION_WITHDRAWN
    WorkflowOutcome:
      required:
        - reason
        - serviceable
      type: object
      properties:
        reason:
          $ref: '#/components/schemas/OutcomeReason'
        serviceable:
          type: boolean
          description: |
            Whether the product's lists, as they stood when this outcome was reported, count the outcome as serviceable: true when the serviceable list named it, false when the unserviceable list did. Decided when the report was recorded, so a later change to the product's lists never changes it.
        reportedAt:
          type: string
          description: |
            When this outcome was recorded. It never changes.
          format: date-time
          example: '2026-08-07T15:12:30Z'
      description: |
        One outcome the customer reported about this workflow with POST /v1/workflows/{workflowId}/edit: the `reason` reported, whether the product counts it as `serviceable`, and the moment (`reportedAt`) it was reported. Each report is its own entry in `workflow.outcomes`; a later report never changes or replaces an earlier one.
        The API only RECORDS outcomes. Which outcomes are serviceable is the product's configuration as it stood when each outcome was reported; `serviceable` is how that configuration read this outcome. The workflow's own `serviceable` is true when ANY reported outcome is serviceable, so one entry's `serviceable` can be false while the workflow's is true. Outcomes do not change what the workflow API allows, and in particular never affect `reconfirmAvailable`.
    Terms:
      minItems: 1
      type: array
      description: |
        One entry per loan participant or term, each a small JSON object naming exactly one of eleven recognized SAMI term types under a single flat key — person, property, participant, loanTerms, loanToValue, transactionDetail, loanDetail, refinance, housingExpense, closing, company (e.g. {"person": {...}}). A request may repeat any branch (e.g. multiple person entries for co-borrowers).
        ON A RESPONSE, THE PARTY BRANCHES ARE SCOPED TO THE PRODUCT AND THE LOAN BRANCHES ARE NOT. person, property, participant and company come back as the ordered product targeted them, which for most products is narrower than what the session holds; loanTerms, loanToValue, transactionDetail, loanDetail, refinance, housingExpense and closing describe the loan rather than any party, so there is nothing to scope them by and each comes back whenever the loan carries it. Real estate a person owns comes back inside that person's properties when the person is among the parties returned, and as a property term of its own when the person is not. To read everything the session holds instead, use GET /v1/sessions/{sessionId}/sami, which is not product-scoped. On either path a term carrying no values is never echoed.
        THE FIELD-BY-FIELD CATALOG IS THE COMPANION DOCUMENT `sami-terms-v1.yaml`. It models every term branch and every field beneath it as JSON, in the exact lower-camelCase spelling this API uses: its `WorkflowTerms` schema is precisely this array, and its `Term` schema is the eleven-branch choice each element must be. Ask your PitchPoint contact for it — there is no $ref to it from here, so a sibling URL will not resolve.
        Repeated elements inside a term are FLAT ARRAYS. A person's residences are `"residences": [ ... ]`, with no intermediate container object, and the same holds for employers, properties, bankAccounts, licenses, identifiers, consentTokens, valuations, courtDetails and items. It is the same flattening this `terms` array itself has.
        `items` DECLARES ONE CONSTRAINT AND NO MORE. Every entry must be a JSON object, and that is declared because it is genuinely enforced. The key-count rule below is enforced too but is stated here in prose rather than declared. Nothing else is declared, because the catalog is descriptive: a constraint drawn from it would advertise validation this API does not perform, and this API deliberately promises only what it checks.
        What IS enforced here, in full: `terms` must be a non-empty array; every entry must be a JSON object, never null and never a bare scalar; every entry must carry exactly one key, never zero and never two; and a term whose single key is none of the eleven recognized names is a VALIDATION_ERROR. Each of these rejects the WHOLE request rather than dropping just that entry.
        Three integration notes are repeated HERE rather than left to the catalog alone, because they trap every first integration and a client needs them before it can send a single valid term: (1) every field ending in Amount/Percent/Ratio (noteAmount, ltvRatioPercent, ...) is a JSON string, not a number — send "360000", never 360000; (2) every date-like field (dob, saleDate, noteDate, ...) is a string in mm/dd/yyyy form, NOT ISO-8601, and nothing checks the format on submission, so a malformed date is accepted here and fails later; (3) roughly 15 "enum-looking" fields (participantType, propertyType, loanPurpose, and similar) accept free text rather than a closed set, and most have a matching *OtherDescription sibling for values outside the documented list. The catalog carries that list for each such field, so ask for it rather than guessing at a value.
        Every key, at every depth, has its first character lower-cased, with a fixed 6-entry acronym exception list (ssn, dob, yob, ein, ltvRatioPercent, cltvRatioPercent).
      items:
        type: object
    MessageCode:
      type: string
      description: |
        Closed, additive vocabulary. Clients branch on this value, never on messages[].text or on HTTP status alone. Not modeled as an enum, deliberately: a client meeting a code it does not recognize should treat it as non-retryable rather than fail to parse the response.
        Current members: ACCEPTED, SUCCESS, FAILED, VALIDATION_ERROR, FORBIDDEN, IDEMPOTENCY_IN_PROGRESS, RECONFIRM_NOT_AVAILABLE, WORKFLOW_WRITE_CONFLICT, SESSION_STARTED, SESSION_UPDATED, SESSION_EMPTY.
        FORBIDDEN is part of the shared vocabulary but is not emitted by any path, and cannot be: no 403 on this surface carries this envelope at all, so there is no messages array for this code to appear in. Every 403 carries a problem document instead — a body, but not this one. Listed because the vocabulary is one closed, permanent set.
        There is deliberately no member for the 401, the 404 or the 500 either, for the same reason and permanently. Those statuses are platform behavior rather than an outcome this API reports, so a client reads the status line and the problem document's `type`, and finds no messages[].code for them anywhere.
        SUCCESS and FAILED are the workflow's own outcome, and they appear on the workflow-state GET rather than on any acknowledgment. They are emitted only once the workflow reaches a TERMINAL status: complete produces SUCCESS, failed produces FAILED. A `processing` workflow carries neither. Nor does a `cancelled` one — terminal, but mapped to no code at all; see SamiWorkflowStatus, which also records that no path emits `cancelled` in this release.
        Both project the messages recorded against the order about its outcome, one envelope entry each; a note that the order was only waiting is not an outcome, so complete leaves it out. They are not constants: the `text` is whatever description was recorded, human copy that is never machine-parsed, and a terminal workflow with nothing recorded carries an EMPTY ARRAY rather than a bare SUCCESS.
        SO BRANCH ON workflow.status, NEVER ON THE PRESENCE OR ABSENCE OF THESE CODES. The empty case is what makes that rule load-bearing rather than stylistic: "no FAILED" is not success, and a client waiting for SUCCESS to appear can wait forever on a workflow that completed without recording anything. status is the outcome; messages are the explanation of it, when there is one to give.
        IDEMPOTENCY_IN_PROGRESS answers a 409 on any POST that carried an Idempotency-Key (every POST but the edit takes one) while an earlier request under that same key is still running. It is the one retryable 409: it comes with a Retry-After in seconds, and the identical retry under the same key answers the first request's response once that request finishes (or the 400 VALIDATION_ERROR if the key was first used with a different body). Retry it for a few minutes at most — see the IdempotencyKey parameter for what a 409 that persists means and what to do instead.
        SESSION_STARTED, SESSION_UPDATED and SESSION_EMPTY are emitted only by the session paths, documented in sessions-v1.yaml. They are listed here because this is one vocabulary for the whole surface: a client branching on messages[].code learns a single set of values for every endpoint it calls, rather than one set per document.
    Message:
      required:
        - code
        - text
        - timestamp
      type: object
      properties:
        code:
          $ref: '#/components/schemas/MessageCode'
        text:
          type: string
          description: Human copy. Never machine-parsed.
        timestamp:
          type: string
          description: |
            When this message was produced. RFC 3339 UTC with a trailing Z — see the timestamp convention in this document's description for the one format every timestamp on this surface uses, fractional seconds included.
          format: date-time
          example: '2026-08-07T15:01:42Z'
    SamiWorkflowStatus:
      type: string
      description: |
        processing covers every state before the workflow's results are ready. complete means they are ready to fetch: it is reported at the same moment a workflow.updated delivery reports data.status ready. Like failed, complete is the latest outcome rather than a final one: a reconfirm returns the workflow to processing under a new runId, and PitchPoint occasionally reopens a run for more work, which reads processing until it is ready again. cancelled is reserved for a future cancellation source — no code path emits it yet in this release. Current members: processing, complete, failed, cancelled.
    SamiWorkflow:
      required:
        - runId
        - status
        - workflowId
      type: object
      properties:
        workflowId:
          type: string
          description: |
            Opaque handle assigned at start, `wfpop_`-prefixed. Never re-parsed; stable across every reconfirm. The same value webhook events carry as data.workflowId, and the value to pass back to GET and reconfirm (which also accept it without the prefix).
          example: wfpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91
        runId:
          type: string
          description: |
            The one execution this block describes, `wfrunpop_`-prefixed. On a start acknowledgment it names the first run, which IS the workflow — the same value as workflowId behind a different prefix. On a reconfirm acknowledgment it names the new run. On a read it names the workflow's newest run, whose status, timestamps, terms and correlationId are what the block reports. The same value webhook events carry as data.runId, so a client that accumulates events per run can match them to this response. Descriptive only: no path takes a runId, and the workflow is always addressed by workflowId.
          example: wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92
        correlationId:
          type: string
          description: |
            The correlationId supplied when the run named by runId was created — a start, a reconfirm, or a session order. Absent when none was supplied to THAT run: a reconfirm's run does not inherit the original order's value. Belongs to the run, not the workflow, exactly as data.correlationId does on webhook events.
        sessionId:
          type: string
          description: |
            The session this workflow was ordered against — the same handle the session paths use, documented in sessions-v1.yaml. Opaque, like every identifier this API publishes.
            Published so a client holding only a workflowId can find its way back to the session: read it at GET /v1/sessions/{sessionId} (its staged data at GET /v1/sessions/{sessionId}/sami), stage a further snapshot, or order another product against the same data.
            Every workflow has one, however it was started. A start at POST /v1/workflows writes its inline terms into a new session and returns that session's identifier here, exactly as an order at POST /v1/sessions/{sessionId}/workflows returns the session it was placed against.
        product:
          $ref: '#/components/schemas/ProductSelector'
        productName:
          type: string
          example: adv-120
        productType:
          type: string
          example: ADV
        referenceNumber:
          type: string
          description: |
            The reference number recorded on the snapshot of the loan that the run named by runId was ordered against: the same value the envelope prints beside terms. Recorded by whichever call created that snapshot: a stage (POST /v1/sessions/{sessionId}/sami), a start, or a reconfirm carrying terms. A later stage never changes what an existing run reports. Ordering never copies your correlationId into it. Absent when that snapshot carries none, which includes every run ordered against a snapshot recorded before reference numbers were kept per snapshot. Like the envelope's own referenceNumber it is present only where terms are: if a response carries no terms, it carries no referenceNumber. So it is absent from all three 202 acknowledgments; read the workflow for it.
        status:
          $ref: '#/components/schemas/SamiWorkflowStatus'
        serviceable:
          type: boolean
          description: |
            Whether the workflow is serviceable (billable). Once the customer has reported an outcome, this is true when ANY reported outcome is serviceable (see `outcomes`), each judged against the product's lists of serviceable and unserviceable outcomes as they stood when it was reported, and false when none is; later changes to the product's lists do not apply, and the order in which outcomes were reported does not matter. Before the first report the workflow's billing entries are netted, charges against reversals, and this is true only while a charge remains — so it is false on a workflow that has not been charged yet and false again once its charges are fully reversed. Absent from both 202 acknowledgments, which are issued before the workflow has finished being recorded.
        outcomes:
          type: array
          description: |
            Every outcome the customer has reported about this workflow with POST /v1/workflows/{workflowId}/edit, one entry per report, newest first by when each was recorded. The order is for display only: outcomes can be reported in any order, so no entry is the workflow's current outcome, and `serviceable` above is true when ANY entry is serviceable. Absent until the customer has reported an outcome, and from both 202 acknowledgments, which are issued before the workflow has finished being recorded. Reported for the original workflow and for every reconfirm of it.
          items:
            $ref: '#/components/schemas/WorkflowOutcome'
        reconfirmAvailable:
          type: boolean
          description: |
            The gate a client checks before POSTing a reconfirm to POST /v1/workflows/{workflowId}. Compare against true: absent means not available.
            False does not necessarily mean the ordered product can never be reconfirmed. A reconfirm POST names no target, so it runs the one default follow-up for the product: where there is exactly one, that is what runs, and where the product offers several there is no default and this operation will not choose on your behalf. Read the flag as "a reconfirm is available here, with no further input from you" rather than as a statement about the product's capabilities.
        ordered:
          type: string
          description: |
            When the workflow was ordered. Present on the workflow-state GET, once the workflow has finished being recorded. ABSENT from both 202 acknowledgment envelopes (start and reconfirm) — those set no timestamp at all; the only time in that response is the ACCEPTED message's own messages[].timestamp. A client that needs the start instant reads that, or issues the follow-up GET /v1/workflows/{workflowId}.
          format: date-time
          example: '2026-08-07T15:01:42Z'
        fulfilled:
          type: string
          description: |
            When the workflow's results were fulfilled. Absent until that stage is reached, on the same terms as ordered above.
          format: date-time
          example: '2026-08-07T15:04:18Z'
        completed:
          type: string
          description: |
            When PitchPoint recorded the workflow as completed on its side, on the same terms as ordered above. It does not follow status: a complete workflow often has none yet, and fulfilled is when its results became available. So its presence is not a substitute for reading status.
          format: date-time
          example: '2026-08-07T15:09:55Z'
      description: |
        Deliberately absent, permanently: transactionId and any top-level ref — no internal record identifier is ever published beyond the three opaque handles below, and no URL is published: the workflow's address is /v1/workflows/{workflowId}. correlationId is the exception that proves the rule: it is YOUR value echoed back, never an identifier of ours. Every optional field below is OMITTED (not null) when the underlying stage never happened or the value never resolved.
        Only workflowId, runId and status are guaranteed on EVERY workflow block. `ordered` is deliberately NOT required: the 202 acknowledgment envelopes set no timestamps at all (see the `ordered` field below), so requiring it would make this schema reject a response the surface really emits.
    SamiWorkflowEnvelope:
      required:
        - messages
      type: object
      properties:
        workflow:
          $ref: '#/components/schemas/SamiWorkflow'
        terms:
          $ref: '#/components/schemas/Terms'
        referenceNumber:
          type: string
          description: |
            The reference number recorded on the snapshot these terms came from, the same value as workflow.referenceNumber. Present only where terms are, and omitted when that snapshot carries none.
        messages:
          type: array
          description: Never null — an empty array when there is nothing to say.
          items:
            $ref: '#/components/schemas/Message'
        compliancePosture:
          type: object
          additionalProperties: true
          description: Not yet implemented. Reserved for a future release.
      description: |
        The response shape for every 200/202, and for the 400 and 409 error classes. workflow is omitted only when a start was rejected before any workflow existed (a 400). terms, and referenceNumber beside them, are present only on the workflow-state GET, never on a start/reconfirm acknowledgment. compliancePosture is not yet implemented — omitted, never an invented empty object, until a future release adds it.
        It is NOT universal, and a client must not assume it. Among the error classes:

          * every 401, 403, 404 and 500 carries a problem document (see Problem in problems-v1.yaml), never this type;
          * a 400 carries EITHER — this envelope for a value the service evaluated and rejected,
            a problem document for a body it could not bind at all or a Content-Type it could not
            use;
          * a 409 likewise carries either — this envelope for a named conflict, a problem document
            for a lost optimistic lock.

        THE RULE THAT FITS ALL OF IT: this envelope is the APPLICATION's error shape, served as `application/json`; the problem document is the PLATFORM's, served as `application/problem+json`. Which one arrives follows which layer answered, not the status code.
        So a JSON parser is safe on every non-2xx body on this surface — what a client must not do is assume WHICH JSON. Read the Content-Type, or read `messages` and treat its absence as a problem document.
        Not every rejection carries either shape; some have no JSON body at all. See this document's top-level description.
    SamiWorkflowListEnvelope:
      required:
        - messages
        - workflows
      type: object
      properties:
        workflows:
          type: array
          description: |
            Never null — an empty array when no readable workflow carries the correlation id.
          items:
            $ref: '#/components/schemas/SamiWorkflowEnvelope'
        messages:
          type: array
          description: Never null — an empty array when there is nothing to say.
          items:
            $ref: '#/components/schemas/Message'
      description: |
        The 200 body of GET /v1/workflows?correlationId=. A list of the same SamiWorkflowEnvelope the single-workflow GET returns, newest first, capped at 100, and EMPTY — never absent, never a 404 — when nothing matched. Every other status on that operation follows the same rules as SamiWorkflowEnvelope: 400 carries a SamiWorkflowEnvelope with messages[].code, and 401/403/404/500 carry what that schema's description says they carry.
    Problem:
      required:
        - status
        - title
        - type
      type: object
      properties:
        type:
          type: string
          description: |
            An absolute URI identifying the problem type. THIS is the stable value your program matches on. `about:blank` is RFC 9457's own placeholder and means the status code alone describes the problem.
          format: uri
          example: https://pointservices.com/problems/invalid-token
        title:
          type: string
          description: |
            A short summary of the problem type, written for a person. Stable for a given `type`, but written for a reader rather than for matching.
          example: The access token was not accepted
        status:
          type: integer
          description: |
            The HTTP status code, repeated in the body so a logged problem document stands on its own.
          format: int32
          example: 401
        detail:
          type: string
          description: |
            An explanation of THIS occurrence, written for a person to read and naming the offending field where one is known. Intended for your logs rather than for display to an end user, and NOT a stable value your program can match on. Match on `type`, never on this string.
          example: The access token was rejected.
        instance:
          type: string
          description: |
            A URI identifying THIS occurrence, written as `urn:pps:request:<id>`. The id inside it is the request correlation id, the same value returned in the `x-pps-request-id` and `X-PPS-CorrelationID` response headers. Quote it when you raise a support ticket; it is what lets PPS find this exact request.
          format: uri
          example: urn:pps:request:4b8c2d90-7f31-4a6e-95b2-0c3e8a7d1f45
      description: |
        THE PLATFORM'S ERROR BODY, as RFC 9457 Problem Details, served as `application/problem+json`.
        `type` is the stable identifier your program matches on. Branch on it rather than on any text in this body. It is always an absolute URI. `about:blank` is RFC 9457's own placeholder, meaning the status alone describes the problem; every other value is a `https://pointservices.com/problems/...` URI or an IANA-registered problem type. The complete set a given API can return is listed in that API's own document, because the APIs do not all return the same ones.
        A type URI identifies a problem; it is not a document you fetch. Compare it as a string.
        The per property examples below are drawn from ONE occurrence, the 401 `invalid-token` case, so that they read together as a single coherent document.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Optional. Your own key for THIS request, which makes retrying it safe: repeat the identical request under the same key and you receive the first response again, marked with `X-Pps-Idempotent-Replay: true`, instead of the work being done twice. Omit it and the request behaves exactly as it would without this feature.
        Mint a new, unique value (a V4 UUID is ideal) for every distinct request, and reuse it only to retry that same request. Never put sensitive data — a borrower name, an email address, a loan number — in a key: unlike the response, keys are not encrypted. Keys are matched ignoring case and surrounding whitespace, so `abc`, `ABC`, and ` abc ` are treated as the same key; do not rely on case to tell two keys apart (never an issue for a V4 UUID). A key belongs to the USER that sent it, to the API it was sent to (method and route), and to the resource in the path: the same value sent by two users, to two APIs, or to two different workflows or sessions, constitutes independent requests, and no user is ever answered with another user's response.
        A retry must repeat the request's PARAMETERS: the body is compared with the one the key was first used with (field order and whitespace do not matter), and a key reused with different parameters is a 400 VALIDATION_ERROR — never a replay of the first request's response. While the first request is still running, a different body may instead get the 409 IDEMPOTENCY_IN_PROGRESS below; that same body, retried after the Retry-After, gets the 400.
        A stored response is kept for 30 days by default (your account may be configured differently). After that the key no longer de-duplicates, and a retry is treated as a new request — for an order, a second real order.
        What is stored is the first response to be COMPLETED under the key, with two rules. A 4xx is never stored, because a rejected request did nothing — fix it and retry under the same key. A 5xx on a start, a reconfirm, or a session order IS stored and replays as that same 5xx for the life of the key, because whether the work happened is unknown; read state first (`GET /v1/workflows?correlationId=` finds a start or an order by your own reference), then retry under a NEW key if it did not. A 5xx on a session start or a stage is not stored: retry under the same key, which returns the original response if the failed attempt's work was kept and runs again if it was not.
        A 409 IDEMPOTENCY_IN_PROGRESS means wait the Retry-After and retry the same request under the same key. If it persists for more than a few minutes, stop: the first request may have completed without its response being recorded. Read state as above, then continue under a NEW key only if the work did not happen.
        A blank key, or one longer than 255 characters, is a 400 VALIDATION_ERROR — never truncated, since a truncated key could silently match another request. A 400 is never stored, so the corrected request may reuse the key. The value is sent in a header, so it is inspected at the edge like any other: a value resembling SQL injection or markup is rejected with no JSON body.
      required: false
      style: simple
      explode: false
      schema:
        maxLength: 255
        minLength: 1
        type: string
  headers:
    IdempotentReplay:
      description: |
        Present, with the value `true`, only on a response replayed under an Idempotency-Key; absent on every response produced live. The body of a replay is byte-for-byte the body first returned, which is why this is a header and not a field in it.
      style: simple
      explode: false
      schema:
        type: string
        enum:
          - 'true'
    RetryAfter:
      description: |
        How long to wait before retrying, as a whole number of SECONDS — never the HTTP-date form, so parse it as an integer.
      style: simple
      explode: false
      schema:
        minimum: 1
        type: integer
  securitySchemes:
    BearerAuth:
      type: http
      description: 'A bearer token in the standard `Authorization` header — `Authorization: Bearer <token>`.'
      scheme: bearer
      bearerFormat: JWT
  responses:
    Unauthorized:
      description: |
        The request carried no valid authentication. Authentication is enforced BEFORE the request body is read, so an unauthenticated request never reveals whether its body would have been accepted.
        Two cases produce this status and the problem `type` tells them apart. `about:blank` means no credential was presented. `https://pointservices.com/problems/invalid-token` means one was presented and rejected — and it deliberately does NOT say why, because the platform cannot distinguish an expired token from a revoked one from a badly signed one from a malformed one. Treat every `invalid-token` the same way: obtain a fresh token rather than inspecting the one you sent.
        WIRE YOUR TOKEN REFRESH TO THIS STATUS. An expiring token moves a client from 200 to 401, never to 403. A 403 means the opposite thing — your identity was established and the answer is still no — so refreshing a token will never resolve one.
      headers:
        WWW-Authenticate:
          description: |
            The RFC 6750 section 3 challenge. `Bearer realm="Helix"` when no credential was presented, and that same value with `, error="invalid_token"` appended when one was presented and rejected — which is the example shown here.
          style: simple
          explode: false
          schema:
            type: string
          example: Bearer realm="Helix", error="invalid_token"
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/invalid-token
            title: The access token was not accepted
            status: 401
            detail: The access token was rejected.
            instance: urn:pps:request:4b8c2d90-7f31-4a6e-95b2-0c3e8a7d1f45
    Forbidden:
      description: |
        You are authenticated and the answer is still no. This is an AUTHORIZATION decision, never a statement about your credential: a token that is expired, revoked or malformed is a 401, and no amount of refreshing turns this status into a 200.
        Two cases produce it and the problem `type` tells them apart. `about:blank` is a denial answered before the endpoint's own code ran. `https://pointservices.com/problems/not-authorized` is a policy decision made inside it, about the specific resource you named.
        A REFUSED REQUEST LEAVES NOTHING BEHIND. A request refused with this status is all or nothing, so a write made earlier in the same call does not survive. Nothing needs cleaning up on your side.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/not-authorized
            title: Not authorized
            status: 403
            detail: The authenticated identity is not permitted to perform this action.
            instance: urn:pps:request:2f7e4c83-b019-4d56-8a3f-6e1d0b9c5a27
    InternalServerError:
      description: |
        An unhandled failure inside the service. The body is a problem document carrying `about:blank` — untyped on purpose, because a stable type here would invite clients to branch on a condition that has no client-side remedy. Quote the `instance` when you report one; that value is what lets PPS find this exact request.
        NOT a statement that the request had no effect. A failure that happens after a write has already been saved leaves that write in place, so a 500 means "outcome unknown", not "nothing happened". RE-READ rather than assume a no-op: a GET is safe to repeat as-is, but a write that 500s should be resolved by reading state before it is retried.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Internal Server Error
            status: 500
            detail: The request could not be completed. Quote the instance identifier when reporting this problem.
            instance: urn:pps:request:6c1b9f07-4e52-4a83-b5d1-2f7a8e0c94d3
