openapi: 3.0.3
info:
  title: PitchPoint Session API
  description: |
    Build your data up before you order against it. A session is a durable workspace that holds one set of data and the successive snapshots you take of it: it outlives the request that started it, it is never discarded on your behalf, and it has no expiry. It is not a login session, and holding a session identifier is not by itself permission to use it — every call below is authorized independently.
    The flow is three calls. Start a session to obtain a stable identifier before you have a product decision to make; stage your data, re-staging as often as you need to correct or complete it; then order a product against it. Ordering answers with a workflow handle, and from that point the workflow paths in workflows-v1.yaml own the request: this API reports nothing about order progress.
    Companion document. Everything shared with the workflow paths — the SAMI terms model, the closed messages[].code vocabulary, and SamiWorkflowEnvelope — is defined in workflows-v1.yaml and referenced from here rather than restated, so the two documents cannot disagree. The Problem error shape and the 401/403/500 responses, which are platform behavior identical on every PitchPoint API, are defined in problems-v1.yaml the same way. The error split and the complete set of problem types these paths return are described in workflows-v1.yaml's own description; this surface shares them. Keep the files side by side; the refs below are relative.
    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. Terms carry borrower and property free text, so a value that merely looks SQL-ish can be rejected this way — which makes the stage call, the one that carries that text, the likeliest to meet it. Encode such values (Base64, for instance) rather than sending them raw.
    SEND `Content-Type: application/json` ON EVERY POST, INCLUDING THE BODYLESS START. Starting a session takes no request body, so a client built to send nothing at all will often drop the header with it. A POST without that header is rejected, with no JSON body to explain itself.
  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/sessions`.
security:
  - BearerAuth: []
paths:
  /v1/sessions:
    post:
      summary: Start a session and return its identifier.
      description: |
        Starts an empty session and answers with its identifier. A sessionId is only ever issued by the service — here, or as workflow.sessionId when a start at POST /v1/workflows creates one — and is never derived, guessed, or constructed by a client.
        The request body is optional and carries one thing: your own `correlationId` for the session — an LOS loan or case identifier, say — echoed back as session.correlationId on every session response. Nothing else about a session is configurable when it starts: it begins empty, and everything it will ever hold comes from what you stage into it afterwards. A session's correlationId is NOT inherited by the workflows ordered against it; each order carries its own.
        Each call starts a new session, so a client that retries a timed-out start WITHOUT an Idempotency-Key will hold two. Send one and the retry answers the first call's 201 and sessionId instead. Sessions are cheap and an unused one is inert, but a client that starts far more than it stages is accumulating identifiers it will never use again.
      operationId: startSession
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionStartRequest'
        required: false
      responses:
        '201':
          description: |
            Started. The new session's handle is in the body at session.sessionId; its address is GET /v1/sessions/{sessionId}. session.dataStaged is always false here — a session holds no data until the first stage.
            A retry under the same Idempotency-Key answers this same 201, byte for byte, marked X-Pps-Idempotent-Replay — the same sessionId, and no second session. Because it is the original response, its dataStaged is still false even if you have staged since; read the session for its current state.
          headers:
            X-Pps-Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '400':
          description: |
            A body that arrived but cannot be used. An absent, empty, whitespace-only, or `{}` body is NOT a rejection — those all start a session normally.
            A 400 HAS TWO BODY SHAPES HERE, and on this operation the PROBLEM shape is the common one. A correlationId longer than 255 characters, and an Idempotency-Key that is blank, longer than 255 characters, or already used with different parameters, are values this API evaluated and rejected, so they carry the envelope with VALIDATION_ERROR. Everything else listed below is refused before evaluation begins and carries a problem document with `about:blank` and NO messages key at all:

              * a literal `null` body, or malformed JSON — detail `Malformed JSON`;
              * a JSON array or a bare string where an object is required — the detail names the
                request type that could not be bound;
              * a Content-Type naming a character set this service cannot use — type
                `https://pointservices.com/problems/invalid-request`.

            A client that reads messages[0].code on every 400 therefore breaks on the MAJORITY of this operation's rejections, not a rare one. Branch on the Content-Type first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            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. Nothing about the request reached this service. Body is a problem document carrying `about:blank`, NOT a SessionEnvelope and NOT `https://pointservices.com/problems/not-found`: that type means an identifier did not resolve, and a start supplies no identifier to resolve.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            IDEMPOTENCY_IN_PROGRESS — a start under the same Idempotency-Key is still running. Wait the Retry-After, in seconds, and retry the identical request under the same key. Only a request that sent a key can meet this.
            Like every error on this surface it may instead arrive as a problem document — a lost optimistic lock answers `https://pointservices.com/problems/concurrent-modification` with no messages key and no Retry-After — so branch on the Content-Type first.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/sessions/{sessionId}:
    get:
      summary: Read one session's current state.
      description: |
        Reports what the session holds. It changes nothing: reading a session never stages data, never starts a workflow, and never affects a workflow already running.
        Poll this only to answer "is data staged yet". It says nothing about the progress of any order — order state lives behind the workflow handle the order call returns.
      operationId: getSession
      parameters:
        - name: sessionId
          in: path
          description: The identifier returned when the session was started. Opaque to clients.
          required: true
          style: simple
          explode: false
          schema:
            type: string
      responses:
        '200':
          description: Current session state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            No session exists with this identifier. Body is a problem document carrying `https://pointservices.com/problems/not-found`, NOT a SessionEnvelope — the closed MessageCode vocabulary has no NOT_FOUND member.
            A 404 whose `type` is `about:blank` means something different: the path itself is not served here, so the request never reached this API. Read `type` to tell them apart.
            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 identifier you sent and the tenancy of the credential you sent it with.
            An unknown sessionId is final, never a timing artifact: a session exists before its identifier is ever returned to you, so retrying will not make it appear.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/sessions/{sessionId}/sami:
    get:
      summary: Read the session's staged data as SAMI terms.
      description: |
        Returns the data this session currently holds, so you can read it back without ordering anything against it.
        It changes nothing. Reading never stages, never orders, and never affects a workflow already running. Terms are the CURRENT snapshot only — earlier snapshots are kept as history and are never served here.
        EVERY TERM YOU STAGED COMES BACK. The party terms — person, property, participant and company, with employers, residences, bank accounts and the real estate they own (properties) nested inside the person they belong to — and the terms describing the loan itself: loanTerms, loanToValue, transactionDetail, loanDetail, refinance, housingExpense and closing. The snapshot's referenceNumber comes back too, beside terms, when it carries one. A response is therefore a complete request body for the POST on this path: read it, change what you need, send the whole thing back, and what you record is the data you had plus your edit rather than a thinner set. That holds for a loan with one subject property, which is what a loan is expected to have; one carrying more than one top-level property (for example after a product added some) behaves unreliably when sent back (sami-terms-v1.yaml, Property).
        SOME OF IT DIFFERS FROM WHAT YOU SENT, always because that is how staging records it. A term carrying no values at all is not echoed — only terms with something in them are published. housingExpense comes back one term per expense however you grouped them on the way in. Amounts and percentages come back in the form they were recorded, usually to two decimal places ("425000" as "425000.00"), and every *Percent field is ROUNDED to two, so a noteRatePercent of "6.375" comes back as "6.38" (convention (3) in sami-terms-v1.yaml). A vocabulary value comes back the way convention (4) says it was recorded: a recognized value in its documented spelling, and an unrecognized one either as Other with your value in the *OtherDescription sibling or, for the fields convention (4) lists as dropping it, not at all. Every property carries the propertyPurpose it was recorded with, so a top-level property you staged reads back as SubjectProperty whatever you sent (sami-terms-v1.yaml, Property).
        Each party term carries its own correlationId, inside the term object — yours when you supplied one, otherwise one generated when that party was first recorded. Match an echoed term on it rather than by comparing contents. It identifies that party and nothing else: it is not the session's correlationId, and it is not what GET /v1/workflows?correlationId= searches.
      operationId: readSessionSami
      parameters:
        - name: sessionId
          in: path
          description: The identifier returned when the session was started. Opaque to clients.
          required: true
          style: simple
          explode: false
          schema:
            type: string
      responses:
        '200':
          description: |
            The data the session holds. A session that has staged nothing answers `{}` — terms is omitted rather than sent as an empty array, because the POST on this path rejects an empty array.
            `{}` is not by itself proof nothing is staged: staged data every one of whose terms came back empty answers `{}` too. GET /v1/sessions/{sessionId} reports session.dataStaged, which is the answer to "is anything staged".
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionSamiResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            Either this surface is not enabled for this environment, or no session exists with this identifier — deliberately indistinguishable, exactly as on the session read. It never means "nothing staged yet": that is a 200 whose body is `{}`.
            A SESSION THAT EXISTS BUT IS NOT YOURS IS A 403, NOT THIS STATUS, for the same reason as the session read: the session is resolved before it is authorized.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      summary: Stage the session's data from SAMI terms.
      description: |
        Records a snapshot of the session's data from the submitted terms — the same data you would get by starting a workflow with a body of terms, without ordering anything. Staging is not the only writer: a reconfirm that carries terms (POST /v1/workflows/{workflowId}) records a snapshot of the workflow's session the same way, and orders against it.
        Call this as many times as you need. A session holds successive snapshots of ONE set of data — one loan, not a collection of separate loans: re-stage to add information you did not have the first time, or to correct what you sent. The most recent snapshot is the session's data, and it is what an order runs against. Earlier snapshots are kept as history and are never served in place of the current one.
        EACH SNAPSHOT IS COMPLETE, NOT A PATCH. Send the data as it now stands, in full. Anything you omit is simply absent from the new snapshot rather than inherited from the previous one — which is exactly how you remove a party: leave them out of the next submission. A client that sends only the fields it wants to change will get a snapshot containing only those fields.
        Nothing is kept from a rejected request. The body is validated and the terms are read before the session is touched, so a 400 leaves the session exactly as it was — the previous snapshot remains current, and no partial one is recorded.
        A retried stage WITHOUT an Idempotency-Key records the snapshot a second time. Send one and the retry answers the first stage's response instead of recording another.
      operationId: stageSessionSami
      parameters:
        - name: sessionId
          in: path
          description: The identifier returned when the session was started.
          required: true
          style: simple
          explode: false
          schema:
            type: string
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionSamiRequest'
        required: true
      responses:
        '200':
          description: |
            Snapshot recorded, and it is now the current one. session.dataStaged is true from here on, and the session can be ordered against.
            A retry under the same Idempotency-Key answers this same 200, byte for byte, marked X-Pps-Idempotent-Replay, and records nothing.
          headers:
            X-Pps-Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '400':
          description: |
            Boundary rejection — an absent, blank, null, or malformed body; a body that is not a JSON object; a missing or non-array terms; a terms array that is empty or contains a null or non-object entry, or an entry carrying other than exactly one key; a 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 — see startSession's 400. A body that cannot be bound to this operation's request type at all, and a Content-Type naming an unusable character set, are refused before evaluation and carry a problem document with no messages key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            No such session. See getSession's 404, including for why a session that EXISTS but is not yours answers 403 here rather than this status, and for the `about:blank` 404 that means the path itself is not served. Body is a problem document.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            Another change to this session was in flight and this one lost the race. Final for that attempt either way: the server does not retry it, so the client decides whether to resubmit. Staging two snapshots of one session at the same time is the way to provoke this; stage them one after another instead.
            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`, has NO messages key, and is what a lost optimistic lock actually answers with — re-read the session before resubmitting rather than retrying the same bytes; it carries no Retry-After deliberately.
            IDEMPOTENCY_IN_PROGRESS, in the envelope, is different: a stage under the same Idempotency-Key is still running. It is the only 409 here that is not final and the only one carrying Retry-After — wait that many seconds and retry under the same key.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/sessions/{sessionId}/mismo34:
    post:
      summary: Stage the session's loan from a MISMO 3.4 document.
      description: |
        Records a snapshot of the loan from a MISMO 3.4 document. It is the same kind of snapshot the SAMI stage on /v1/sessions/{sessionId}/sami records, but read from a loan file you already have instead of from terms you assemble. Then start a workflow against it with POST /v1/sessions/{sessionId}/workflows.
        SEND THE DOCUMENT ITSELF AS THE BODY. It is raw XML, not wrapped in JSON and not Base64-encoded, sent with `Content-Type: application/xml; charset=utf-8` (`text/xml` is accepted too). The body must be UTF-8. The root is a MISMO 3.4 MESSAGE, or a bare DEAL, in the MISMO namespace. The version is checked TEXTUALLY: the document must contain MISMOReferenceModelIdentifier="3.4…" (for example "3.4.032420160128"), written with no spaces around the equals sign. Include it even though the MISMO schema makes it optional: this operation reads it to recognise a MISMO 3.4 document, and refuses a document it cannot recognise as one with "Request body is not a MISMO 3.4 document". The document must declare exactly one LOAN whose LoanRoleType is SubjectLoan, and that is the loan staged. Other loans in the document, such as a related HELOC, are read only as context for it and are never staged in its place.
        Every rule of the SAMI stage applies. A session holds successive snapshots of ONE loan, and the most recent one is what a workflow run uses. Each snapshot is a complete restatement, not a patch. You can mix the two formats: a MISMO stage after a SAMI stage records a new snapshot of the same loan. GET /v1/sessions/{sessionId}/sami reads back whichever was staged last, as terms.
        The loan's correlation id and reference number come from the subject loan's LOAN_IDENTIFIER entries whose LoanIdentifierType is LenderLoan and LenderCase respectively. When only one of the two is present, it is used for both. They are taken when the session's loan is first created, and a later stage does not restate them.
        Nothing is kept from a rejected request. The document is checked and read before the session is touched, so a 400 for the document leaves the session exactly as it was. This also means an unreadable document is a 400 even when the session does not exist or is not yours.
        A retried stage WITHOUT an Idempotency-Key records the snapshot a second time. Send one and the retry answers the first stage's response instead. The key's fingerprint is the document's exact bytes, ignoring only surrounding whitespace. A retry must therefore resend the same bytes; the same key with a document that differs in any way is refused.
      operationId: stageSessionMismo34
      parameters:
        - name: sessionId
          in: path
          description: The identifier returned when the session was started.
          required: true
          style: simple
          explode: false
          schema:
            type: string
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        description: A MISMO 3.4 MESSAGE (or DEAL) document, UTF-8, at most 5 MiB.
        content:
          application/xml:
            schema:
              type: string
          text/xml:
            schema:
              type: string
        required: true
      responses:
        '200':
          description: |
            Snapshot recorded, and it is now the current one. The envelope is the same one the SAMI stage answers with. A retry under the same Idempotency-Key answers this same 200, byte for byte, marked X-Pps-Idempotent-Replay, and records nothing.
          headers:
            X-Pps-Idempotent-Replay:
              $ref: '#/components/headers/IdempotentReplay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '400':
          description: |
            The document was refused for one of these reasons: a blank body; a charset other than UTF-8, or bytes that are not valid UTF-8; a body that is not well-formed XML, or that carries a DOCTYPE (never accepted); a document that fails the textual 3.4 version check described above, or whose root is not MESSAGE or DEAL in the MISMO namespace; no LOAN with LoanRoleType SubjectLoan, or more than one; or a subject loan that names neither a borrower nor the subject property. These are problem documents, and the detail names the reason.
            An Idempotency-Key that is blank, longer than 255 characters, or already used with a different document carries the envelope with VALIDATION_ERROR instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: |
            No such session. This works as for the SAMI stage, including why a session that exists but is not yours answers 403. A document that cannot be read answers 400 first (see above).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            Another change to this session was in flight, or a stage under the same Idempotency-Key is still running. Both work exactly as for the SAMI stage on /v1/sessions/{sessionId}/sami, including which of the two carries Retry-After.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '413':
          description: |
            The body is larger than 5 MiB, counted after any Content-Encoding is undone. It is refused while the body is still arriving, so it answers before any other check on the request (Content-Type, the session lookup, or whether the session is yours). A request with no valid credential gets the 401 first.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '415':
          description: |
            The Content-Type is not application/xml or text/xml. A JSON body belongs on /v1/sessions/{sessionId}/sami.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/sessions/{sessionId}/workflows:
    post:
      summary: Order a product against the data staged in this session.
      description: |
        Starts a workflow against the session's current snapshot. The body carries a product and nothing else: the data is already staged, so no terms are sent and NO SNAPSHOT IS RECORDED — ordering reads the data, it never changes it. This is the whole reason the session API exists.
        Order after you have finished staging. The workflow runs against whatever snapshot is current at the moment you order; a later stage does not reach a workflow already started, and does not retroactively alter one. A later reconfirm of that workflow does see it: every run executes against the session's latest snapshot. A reconfirm carrying terms first records them as the new snapshot, then runs against that.
        Answers with the same SamiWorkflowEnvelope that starting a workflow at POST /v1/workflows returns, and the workflowId in it is what you use to read and reconfirm through the workflow paths. Starting a workflow at /v1/workflows with terms inline is not a session-less alternative to this operation: that start creates a session, stages the terms into it, and orders against it, returning the new session's identifier as workflow.sessionId. Every workflow is ordered against a session — you either hold it before ordering or receive it afterwards — and there is one way to follow a workflow afterwards.
        Ordering twice against one session is legitimate and gives you two independent workflows. Both run against the data the session holds, and neither is a reconfirm of the other: each has its own workflowId, each is read and reconfirmed separately, and each is billed as its own order. That is also what a blind retry of a timed-out order does — unless it carries the same Idempotency-Key, in which case it answers the first order's 202 instead.
      operationId: startSessionWorkflow
      parameters:
        - name: sessionId
          in: path
          description: The identifier returned when the session was started.
          required: true
          style: simple
          explode: false
          schema:
            type: string
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionWorkflowRequest'
        required: true
      responses:
        '202':
          description: |
            Accepted for processing. workflow.workflowId is stable across every future reconfirm and is the handle for every later read.
            A retry under the same Idempotency-Key answers this same 202, byte for byte, marked X-Pps-Idempotent-Replay — the same workflowId, and no second order.
          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 correlationId longer than 255 characters; or an Idempotency-Key that is blank, longer than 255 characters, or already used with different parameters. Always carries VALIDATION_ERROR.
            The unresolvable service+model pair is the one rejection here that is not refused before the order begins to be recorded: it is only discovered once recording is under way. No workflow handle is issued, so there is nothing to read or reconfirm, and the session's data is left exactly as it was — but the pair will not become resolvable on its own, so resubmitting it unchanged returns the same 400. Correct the product rather than retry.
            A 400 HAS TWO BODY SHAPES — see startSession's 400. A body that cannot be bound to this operation's request type at all, and a Content-Type naming an unusable character set, are refused before evaluation and carry a problem document 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: |
            No such session. See getSession's 404, including for why a session that EXISTS but is not yours answers 403 here rather than this status, and for the `about:blank` 404 that means the path itself is not served. Body is a problem document.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '409':
          description: |
            SESSION_EMPTY — nothing has been staged into this session, so there is no data to order against. Stage terms first, then retry; this is the one 409 on this API a client can resolve on its own. It is deliberately not VALIDATION_ERROR: the body was fine, the session was empty, and the corrective action is different.
            WORKFLOW_WRITE_CONFLICT is also possible here, meaning the same thing it does on the stage call.
            AND A THIRD SHAPE: a lost optimistic lock answers with a problem document carrying `https://pointservices.com/problems/concurrent-modification` and no messages key at all. So branch on the Content-Type FIRST and only then on messages[].code — never on the 409 alone, and never on messages[0].code without checking it is there.
            Or IDEMPOTENCY_IN_PROGRESS: an order under the same Idempotency-Key is still running. The only 409 here carrying Retry-After — wait that many seconds and retry under the same key, which then answers the first order'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'
components:
  schemas:
    SessionSamiRequest:
      required:
        - terms
      type: object
      properties:
        terms:
          $ref: '#/components/schemas/Terms'
        referenceNumber:
          maxLength: 255
          type: string
          description: |
            Your own identifier for this loan — the loan or case number your staff will quote when they ask about it. Recorded on the snapshot this stage writes, and shown as the loan's reference number in Fulfillment.
            Optional, and part of the snapshot exactly as terms are: each stage replaces the loan, reference number included. Leaving it out, sending null, or sending only whitespace records a snapshot without one and clears the loan's reference number. GET /v1/sessions/{sessionId}/sami returns it beside terms, so re-posting a read-back body unchanged keeps it. A workflow's read reports the value of the snapshot it was ordered against, as workflow.referenceNumber and beside its terms, so a later stage never changes what an earlier workflow reports. 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.
            It is independent of correlationId: this API does not copy one into the other.
      description: |
        The body of a stage. Carries terms and, optionally, the loan's referenceNumber. product belongs on the order call, not here: a stage body carrying one records the terms and drops the product.
    SessionSamiResponse:
      type: object
      properties:
        terms:
          $ref: '#/components/schemas/Terms'
        referenceNumber:
          type: string
          description: |
            The reference number recorded on the session's current snapshot, the one these terms describe. Omitted when that snapshot carries none.
      additionalProperties: false
      description: |
        The data a session currently holds. Structurally identical to SessionSamiRequest, so a response can be posted back to the same path unchanged — see the read operation for what that does and does not carry.
        Unlike the request, terms is not required: a session with nothing to serve answers `{}`. An empty terms array is never sent, because the request side rejects one. referenceNumber is returned beside terms when the session's current snapshot carries one, so posting this body back unchanged keeps it.
    SessionStartRequest:
      type: object
      properties:
        correlationId:
          $ref: '#/components/schemas/CorrelationId'
      description: |
        The optional body of a start. Its one field is optional too, so `{}` and no body at all mean the same thing.
    SessionWorkflowRequest:
      required:
        - product
      type: object
      properties:
        correlationId:
          $ref: '#/components/schemas/CorrelationId'
        product:
          $ref: '#/components/schemas/ProductSelector'
      description: |
        The body of an order. Carries product, and optionally a correlationId for the run this order creates. There is deliberately no terms field and no way to name a snapshot: the data comes from the session, at whatever snapshot is current when the order is placed.
    Session:
      required:
        - dataStaged
        - sessionId
      type: object
      properties:
        sessionId:
          type: string
          description: |
            The session's identifier, and the handle for every call on this API. Opaque: it is issued when the session is started, and a client should store and echo it rather than parse it, derive anything from it, or construct one. Nothing about its format or its relationship to any other identifier is contractual, and both are free to change.
            Holding a sessionId is not permission to use it. Every call on this API authorizes its caller independently, so a session identifier that reaches the wrong party grants nothing. Do not treat the value as a secret, and do not treat possession of one as proof of anything.
        dataStaged:
          type: boolean
          description: |
            Whether this session holds data, and therefore whether an order against it will be accepted. False on a session that has just been started; true from the first successful stage onward. It never returns to false — re-staging replaces the current snapshot, and nothing on this API empties a session that has held data.
        correlationId:
          type: string
          description: |
            The session's correlation reference. Your value when the start supplied one; otherwise a value this service generated for the session, so the field is always present. It describes the session only — the workflows ordered against it each carry their own correlationId, and this one is never searched by GET /v1/workflows?correlationId=.
    SessionEnvelope:
      required:
        - messages
      type: object
      properties:
        session:
          $ref: '#/components/schemas/Session'
        messages:
          type: array
          description: |
            Always present, including as an empty array. A plain read carries no message; a start carries SESSION_STARTED, a stage SESSION_UPDATED, and a rejection VALIDATION_ERROR or WORKFLOW_WRITE_CONFLICT. Branch on code, never on text.
          items:
            $ref: '#/components/schemas/Message'
      description: |
        The response shape for the successful JSON bodies this API produces, and for part of its 400 and 409. 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. On startSession the problem shape is the COMMON one;
          * a 409 likewise carries either — this envelope for SESSION_EMPTY, 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 — 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.
        The order call is the remaining exception, and deliberately so: it answers with SamiWorkflowEnvelope, not this type, because what it returns is a workflow rather than a session. A client parses one shape for start, read, and stage, and the workflow shape for order.
    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.
    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 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'
    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.
    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
    ProductSelector:
      required:
        - model
        - service
      type: object
      properties:
        service:
          type: string
          example: ADV
        model:
          type: string
          example: adv-120
      additionalProperties: false
    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 billable on balance. Its 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.
        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.
  securitySchemes:
    BearerAuth:
      type: http
      description: 'A bearer token in the standard `Authorization` header — `Authorization: Bearer <token>`.'
      scheme: bearer
      bearerFormat: JWT
  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
  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
