openapi: 3.0.3
info:
  title: PPS Webhooks API
  description: |-
    Manage your PPS webhooks: register and configure delivery endpoints, set their signing secrets, enable and disable them, and inspect or replay individual deliveries.

    ## Authenticating your requests

    Every request must be authenticated with a bearer token in the standard `Authorization` header, written as `Authorization: Bearer <token>`. That is the mechanism this API supports; send your credential this way and no other. Authentication is checked before the request body is read, so an unauthenticated request never reveals whether its body would have been accepted.

    ## Your signing secret

    You choose your own signing secret; PPS never generates one. No response from any call in this API ever carries a secret. It travels in one direction only, from you to us. If you lose your copy, set a new one by editing the endpoint with a `signingSecret`.

    Because you choose it, its strength is your responsibility. Generate it from a cryptographically secure random source, never a password, a passphrase, or a value you use anywhere else. We check that a secret is well FORMED (the `whsec_` prefix, then 24 to 64 bytes of base64 encoded key material) but we cannot check that it is unguessable, and a weak secret lets anyone forge deliveries your server will accept as genuine.

    ## Verifying a delivery

    PPS implements the Standard Webhooks signature scheme (https://www.standardwebhooks.com). Most languages have a ready made library for it; the details below are what those libraries implement, so you can verify by hand if you prefer.

    Every delivery carries three headers: `webhook-id` (the event id, also the idempotency key), `webhook-timestamp` (unix seconds), and `webhook-signature`. A fourth, `webhook-key-id`, is CONDITIONAL. It accompanies deliveries carrying a `v1a` entry, and is described below.

    `webhook-signature` is a LIST of signatures separated by spaces, each written as a version tag, a comma, then a base64 value, for example `v1,<signature> v1a,<signature>`. **Accept the delivery if ANY entry verifies**, and ignore entries whose version tag you do not recognize. That lets us add schemes without breaking your integration.

    Both schemes sign the identical string, formed by joining three values with full stops: `{webhook-id}.{webhook-timestamp}.{raw request body}`. Use the body exactly as received, before you parse it or serialize it again. A JSON body that has been parsed and rebuilt will not verify.

    `v1` is HMAC-SHA256, keyed by your signing secret with its `whsec_` prefix removed and the remainder base64 decoded. Compare in constant time.

    `v1a` is Ed25519, verified against our published public key rather than a shared secret. You receive it if your endpoint's `signatureMode` is `ASYMMETRIC`, and in that mode you receive `v1a` and nothing else.

    Plan for one consequence of that exclusivity. An `ASYMMETRIC` endpoint holds no shared secret, so if we cannot produce the `v1a` signature the delivery is not sent at all. It is retried on the ordinary schedule. We never downgrade it to an unsigned request, and never to one signed with a secret you have given up.

    ## Our public keys

    This section applies to `v1a`, the asymmetric scheme described above.

    A delivery carrying a `v1a` entry also carries `webhook-key-id`, naming WHICH of our published keys produced that signature. Use it to select that key directly instead of trying every key you hold.

    Our signing keys are published as a JWKS. Reach it through OIDC discovery: fetch `https://identity.pointservices.com/.well-known/openid-configuration` and read `jwks_uri` from it. NEVER hardcode a JWKS path. The path discovery returns can change, so a hardcoded path will break when it does. From that key set select the Ed25519 keys: `kty` of `OKP` and `crv` of `Ed25519`.

    The published key set and the signer derive both the `kid` and the signature from the same key material, so the value in `webhook-key-id` and the `kid` in the published key set agree BY CONSTRUCTION. There is no coordination protocol between the two systems that could drift, fall behind, or be forgotten during a rotation.

    Even so, treat the header as an OPAQUE LOOKUP HINT. It proves nothing: only the SIGNATURE authenticates a delivery. Keep trying each key you hold as your fallback, because the header may be absent entirely. Symmetric deliveries do not carry it.

    If the `kid` is one you do not recognize, refetch the JWKS ONCE, and limit that rate so that a stream of unknown kids cannot turn into a stream of requests to us. If it is still unknown after that refetch, REJECT the delivery rather than guessing. Our retry will redeliver it, signed fresh.

    Do not assume the two attempts of one `webhook-id` carry the same `webhook-key-id`. Every attempt is signed at SEND time, so a key rotation landing between retries means the same event arrives under different kids. That is expected. Verify against the signature, not the header.

    ## Handling deliveries

    Delivery happens at least once, so the same event can arrive more than once and denotes the same event every time. Deduplicate on the event id, which reaches you in two places carrying the same value: the `webhook-id` header and the `eventId` field of the delivery body. It is stable across every attempt of one business event, including across a replay, so if you deduplicate on it you will correctly recognise a redelivery as the same event rather than as a second one.

    Reject a delivery whose `webhook-timestamp` is far from your current time. Five minutes is the common tolerance, and is what most libraries default to. The specification requires a tolerance but does not fix the number.

    ## Errors

    Every error response in this API is an RFC 9457 problem document, served as `application/problem+json` with the `Problem` shape. That shape, and the 401 and 403 responses, are defined in problems-v1.yaml, which is published beside this document and shared with the other PitchPoint APIs; keep the files side by side, since the refs to it are relative. Branch on the problem `type`, which is an absolute URI, never on the text of `title` or `detail`. The complete set of types this API emits is:

    * `https://pointservices.com/problems/invalid-request` (400), the
      Content-Type header names a character set this service cannot use. A
      body that is malformed or carries a value outside this API's vocabulary
      is the `about:blank` 400 below, with `detail` naming the field.

    * `https://iana.org/assignments/http-problem-types#digest-mismatched-values`
      (400), the body did not match the digest sent with it. This one is
      IANA's registered type rather than ours, because the condition is a
      registered HTTP problem rather than anything specific to PPS.

    * `https://pointservices.com/problems/invalid-token` (401), a credential
      was presented and rejected.

    * `about:blank` (401), no credential was presented. It is also the type
      carried by a 400 for a malformed or out-of-vocabulary request, a 403
      refused before the endpoint ran, and any status with no dedicated type
      of its own, including every 5xx.

    * `https://pointservices.com/problems/not-authorized` (403), you are
      authenticated but may not act on this endpoint. Authorization is checked
      per endpoint: a caller that does not own the endpoint is refused outright
      rather than served a filtered result.

    * `https://pointservices.com/problems/not-found` (404), the resource is
      missing, out of your tenancy, or one you may not act on.

    * `https://pointservices.com/problems/conflict` (409), the resource's
      current state refuses the request.

    * `https://pointservices.com/problems/concurrent-modification` (409),
      another write landed first, so nothing was applied. Re-read the
      endpoint and resubmit if your change still applies.

    * `https://pointservices.com/problems/too-many-requests` (429), the
      endpoint's delivery rate budget for the current window is spent.


    ## Conventions

    Every timestamp in this API, sent or received, is an RFC 3339 instant in UTC with a trailing `Z`, for example `2026-08-28T14:26:03Z`. 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.

    Unknown JSON keys in a request body are ignored rather than rejected, so a client you write against a later version of this API can safely call an earlier deployment.
  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/webhooks/endpoints`.
security:
  - BearerAuth: []
tags:
  - name: endpoints
    description: Webhook endpoint registration and configuration.
  - name: deliveries
    description: Delivery inspection and replay.
  - name: events
    description: Event simulation.
paths:
  /v1/webhooks/endpoints:
    get:
      tags:
        - endpoints
      summary: List your webhook endpoints, paginated by cursor.
      description: |-
        Returns a page of the webhook endpoints your organization owns.

        ORDER AND DIRECTION. Endpoints are returned ascending by endpoint `id`, and paging walks forward toward newer endpoints. The `marker` cursor is EXCLUSIVE: the endpoint it names is never sent again on the next page.

        Pagination is by opaque cursor. Pass the `nextMarker` from one response as the `marker` of the next. `nextMarker` is present whenever more endpoints may exist to see. Each page is filtered down to the endpoints you may see, and the server bounds how much work it will do for one request, so a page can come back short, or even completely empty, and still carry `nextMarker`. Stop paging ONLY when `nextMarker` is absent, never on a short or empty page alone.

        The listing is filtered by your access: it carries the endpoints you are permitted to see and nothing else, so a page shorter than you expected reflects what you may act on rather than what exists. It can also mean the server paused its scan partway through this request, so follow the cursor rather than concluding the listing ended.

        Entries take the endpoint shape that carries no secret.
      operationId: listWebhookEndpoints
      parameters:
        - $ref: '#/components/parameters/EndpointListMarker'
        - $ref: '#/components/parameters/EndpointListLimit'
      responses:
        '200':
          description: A page of endpoints.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointListResponse'
              example:
                limit: 100
                count: 1
                results:
                  - id: 4711
                    displayName: Production order hook
                    url: https://hooks.example.com/pps/orders
                    enabled: true
                    circuitState: CLOSED
                    receiving: true
                    hasNoContactsRegistered: false
                    acceptedEventTypes:
                      - serviceName: ADV
                        modelName: ADV-120
                        eventTypes:
                          - workflow.updated
                          - workflow.failed
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - endpoints
      summary: Register a new webhook endpoint.
      description: |-
        Creates an endpoint in an org unit you hold registration rights for. `organizationalUnitGuid` is REQUIRED and names that org unit: your own, or any org unit beneath yours in your organization's hierarchy. There is no default, so a body omitting it is rejected with a 400. An org unit outside your hierarchy, or one you hold no registration rights for, is rejected with a 403 rather than falling back to your own.

        Creating an endpoint in a CHILD org unit does not route the parent's events to it: subscription grants name one destination org unit exactly and do not inherit down the tree. Read `organizationalUnitGuid` and `receiving` back off the response before assuming anything will arrive.

        Supply `signingSecret` when `signatureMode` is `SYMMETRIC`, including when you omit `signatureMode` entirely, since `SYMMETRIC` is the default. An `ASYMMETRIC` endpoint holds no shared secret, so supplying one is rejected rather than ignored. The response never echoes it back.

        PPS validates the URL against its list of approved domains and against SSRF rules before the endpoint is created; a URL whose host is not approved is rejected outright rather than accepted in a pending state.

        A newly registered endpoint is ENABLED. It will not receive anything until it also has a live subscription grant and declares at least one accepted event type. Check `receiving` on the response rather than `enabled`.

        Registering without `acceptedEventTypes` is allowed — you may declare consent later by editing the endpoint — but such an endpoint receives nothing in the meantime, so the response carries a `warnings` entry saying so. Read that entry as a prompt to check your request body: unknown keys are ignored rather than rejected, so a MISSPELLED `acceptedEventTypes` is dropped silently and registers the same receive-nothing endpoint that omitting it deliberately would. Compare the `acceptedEventTypes` echoed back in the response against what you sent to tell the two apart.
      operationId: registerWebhookEndpoint
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointWriteRequest'
            example:
              url: https://hooks.example.com/pps/orders
              organizationalUnitGuid: OU41502
              displayName: Production order hook
              acceptedEventTypes:
                - serviceName: ADV
                  modelName: ADV-120
                  eventTypes:
                    - workflow.updated
                    - workflow.failed
              contacts:
                - integrations@example.com
        required: true
      responses:
        '200':
          description: The created endpoint, with no secret in it. Note that a successful register returns 200, not 201.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointResponse'
              example:
                id: 4711
                organizationalUnitGuid: OU41502
                displayName: Production order hook
                url: https://hooks.example.com/pps/orders
                enabled: true
                circuitState: CLOSED
                receiving: true
                hasNoContactsRegistered: false
                acceptedEventTypes:
                  - serviceName: ADV
                    modelName: ADV-120
                    eventTypes:
                      - workflow.updated
                      - workflow.failed
                signatureMode: SYMMETRIC
                warnings: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/ConcurrentModification'
  /v1/webhooks/endpoints/{id}:
    get:
      tags:
        - endpoints
      summary: Get one webhook endpoint.
      description: Returns the endpoint in the shape that carries no secret.
      operationId: getWebhookEndpoint
      responses:
        '200':
          description: The endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointResponse'
              example:
                id: 4711
                displayName: Production order hook
                url: https://hooks.example.com/pps/orders
                enabled: true
                circuitState: CLOSED
                receiving: true
                hasNoContactsRegistered: false
                acceptedEventTypes:
                  - serviceName: ADV
                    modelName: ADV-120
                    eventTypes:
                      - workflow.updated
                      - workflow.failed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - endpoints
      summary: Edit a webhook endpoint.
      description: |-
        Updates the endpoint. Every property of the request body is optional; an omitted property is left unchanged.

        Those are merge semantics, and they are why this operation is a POST. RFC 9110 section 9.3.4 defines PUT as REPLACEMENT, so a PUT that left the properties you omitted untouched would be promising one thing and doing another. PATCH says exactly what this operation does, but PATCH support is unreliable in older HTTP client SDKs and we would rather be callable than idiomatic. POST is the verb that makes no semantic promise to break, and it is already the verb of every other operation that changes an endpoint.

        An edit is validated as strictly as a create: any URL change runs the same SSRF validation again, and a change of host must resolve to an active domain PPS has approved or the entire edit is rejected, with no part of it applied. There is no state in which an edit waits to be authorized again.

        ## Moving an endpoint to another org unit

        Send `organizationalUnitGuid` with a different value. The destination is authorized in its own right, not merely the endpoint you already own: you may move an endpoint only into your own org unit or one beneath it in your hierarchy. A destination outside it, or one you hold no rights over, is rejected with a 403, and the endpoint is left exactly where it was.

        THIS CHANGES WHAT THE ENDPOINT RECEIVES, and it is the one consequence this API cannot warn you about at the time. Subscription grants name one destination org unit exactly and are matched to an endpoint by exact equality, with no walk up or down the hierarchy. So an endpoint receiving today under a grant naming its current org unit will receive NOTHING once it sits in an org unit no grant names. The move succeeds with a 200, the endpoint still reports itself enabled, and deliveries simply stop arriving. Confirm a grant names the destination before you move, then read `receiving` and `notReceivingReason` off the response.

        Deliveries already queued for the endpoint are not rewritten by a move. Omitting the property, or sending the value the endpoint already has, leaves it where it is.

        The response carries no secret, because editing an endpoint never discloses its signing secret. This operation can SET one, but no response in this API ever returns one.

        ## Changing the signing secret

        Send `signingSecret`. The new value takes effect IMMEDIATELY, including for deliveries already queued and awaiting an attempt: those are signed with the new secret, not the one in force when they were created. There is no overlap window in which both the old and the new secret verify. Install the new value in your consumer FIRST, then call this. In that order there is no interval during which you cannot verify what we send.

        Choose the value from a cryptographically secure random source. We check that it is well FORMED, namely the `whsec_` prefix followed by 24 to 64 bytes of key material in base64, but we cannot check that it is unguessable. Treat it as a credential: do not send it over any channel but this API, and do not paste it into a support ticket.

        A bare `signingSecret` is valid only on a `SYMMETRIC` endpoint, because an `ASYMMETRIC` one holds no shared secret; sending one to an `ASYMMETRIC` endpoint is rejected. To MOVE an endpoint between the two modes, send `signatureMode` and `signingSecret` together in this one request. Switching to `SYMMETRIC` requires the secret, switching to `ASYMMETRIC` forbids it and clears the stored value, and doing it in a single request is what keeps the endpoint from being momentarily symmetric but unsignable.

        ## Switching the endpoint off

        Send `enabled`. Disabling stops all deliveries, and deliveries queued at the moment of disabling are PARKED, not cancelled: enabling the endpoint again and then issuing a bulk replay resurrects them, while disabling alone does not discard them. Recovery is bounded by the delivery retention window, so parked deliveries are eventually deleted like any other finished delivery.

        Note that `enabled` is only one of the three conditions behind the derived `receiving` property: an enabled endpoint whose circuit is open, or which has no live subscription grant, still receives nothing.
      operationId: editWebhookEndpoint
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointWriteRequest'
            example:
              displayName: Production order hook (EU)
              acceptedEventTypes:
                - serviceName: ADV
                  modelName: ADV-120
                  eventTypes:
                    - workflow.updated
                    - workflow.failed
                    - workflow.pending
                    - finding.reviewed
                    - finding.result_updated
                    - finding.note_updated
                    - finding.attachment_updated
                    - finding.attachment_removed
        required: true
      responses:
        '200':
          description: The updated endpoint, with no secret in it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointResponse'
              example:
                id: 4711
                displayName: Production order hook (EU)
                url: https://hooks.example.com/pps/orders
                enabled: true
                circuitState: CLOSED
                receiving: true
                hasNoContactsRegistered: false
                acceptedEventTypes:
                  - serviceName: ADV
                    modelName: ADV-120
                    eventTypes:
                      - workflow.updated
                      - workflow.failed
                      - workflow.pending
                      - finding.reviewed
                      - finding.result_updated
                      - finding.note_updated
                      - finding.attachment_updated
                      - finding.attachment_removed
                warnings: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ConcurrentModification'
    parameters:
      - $ref: '#/components/parameters/EndpointId'
  /v1/webhooks/endpoints/{id}/test:
    post:
      tags:
        - endpoints
      summary: Send a synthetic test delivery to an endpoint and report what happened.
      description: |-
        Sends a synthetic delivery to the endpoint and tells you what came back. It is SYNCHRONOUS and makes ONE ATTEMPT ONLY: the call blocks for the round trip and there is no retry. Set your client's timeout accordingly. The call can block for as long as your own server takes to answer, up to about 45 seconds (a connect timeout of 15 seconds plus a read timeout of 30 seconds) before PPS gives up and reports the failure in the body.

        It is the REAL delivery path, not a simulation of it. It uses the same signing, the same headers, the same SSRF validation, the same validation of the destination against approved domains, and the same HTTP client that production deliveries use. A passing test therefore exercises the code that real deliveries run.

        YOU CHOOSE A SELECTOR, made up of `serviceName`, `modelName` and `eventType`, and PPS BUILDS the envelope. You can never supply payload bytes. This is a security boundary: our signatures verify against our published public key everywhere, so accepting bytes from a caller would make this API a signing oracle, letting any authenticated user mint PPS envelopes that carry a valid signature and replay them at any third party who trusts us.

        The synthetic payload is recognisable THREE ways, so you can keep test traffic out of your production handling. `data.test` is `true` and is never present on a production envelope; `workflowId` and `runId` carry their normal prefixes but are random and resolve to nothing on any PPS API; and the request carries a `webhook-test: true` header, which lets you reject test traffic on the header alone, without parsing the body at all.

        BOTH HALVES OF CONSENT MUST ALREADY BE IN PLACE, and a missing half is a 400. The first half is yours: the `eventType` must be one this endpoint has DECLARED, for this same product. The second half is ours: a live subscription grant must route that product and event type to your organization. Both are checked AFTER authorization, so a caller who may not see this endpoint cannot use either error to learn what it accepts or what it is entitled to, and both answer with the same status so the error does not reveal which half is missing.

        Subscription grants are written by PPS, never by you, so there is no request you can make that creates one. If a test is refused for a missing grant, contact your PPS integration contact to have it created, then test again.

        This refusal is deliberate. A test send exists to answer one question, "will real events reach me?", and with no grant in place the answer is no. Returning a successful report in that situation would tell you the integration is wired up when nothing would actually be delivered, so the call is refused instead.

        A DISABLED endpoint is still testable, and surfaces as a `warning` rather than a refusal: testing before you enable is the point of a pre-flight check.

        An open circuit does not block a test either. A SUCCESSFUL test CLOSES the circuit and doubles as the recovery probe, while a FAILED test never counts against it. A broken endpoint cannot lock you out of testing, and testing cannot trip the breaker.

        A 200 means the test RAN, not that it succeeded. Your endpoint's failure is DATA IN THE BODY, carried by `success`, `statusCode` and `failureReason`, and never an HTTP error from us: a 502 from your server is a correct outcome of a working test. Read `success`, not the status line.

        Every attempt is signed fresh at send time. The rate is limited per endpoint against the SAME budget as real deliveries, so a test can never starve production traffic; a spent budget is a 429. A 503 means WE could not sign, which is a PPS fault and never a statement about your endpoint.
      operationId: testWebhookEndpoint
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookTestRequest'
            example:
              serviceName: ADV
              modelName: ADV-120
              eventType: workflow.updated
        required: true
      responses:
        '200':
          description: The test ran. Read `success` to learn whether your endpoint accepted it; a failure on your side is reported here, not as an HTTP error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookTestResponse'
              example:
                eventId: msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451
                topic: workflow.updated
                url: https://hooks.example.com/pps/orders
                sent: true
                success: true
                statusCode: 200
                responseBody: '{"received":true}'
                durationMillis: 187
                payload:
                  type: workflow.updated
                  eventId: msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451
                  timestamp: '2026-08-28T14:26:03Z'
                  data:
                    test: true
                    workflowId: wfpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91
                    runId: wfrunpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91
                    serviceName: ADV
                    modelName: ADV-120
                    status: ready
                warnings: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    parameters:
      - $ref: '#/components/parameters/EndpointId'
  /v1/webhooks/events/simulate:
    post:
      tags:
        - events
      summary: Fire a synthetic event through the real fan out and report the deliveries it created.
      description: |-
        Fires one synthetic event of the type you name and reports every delivery it produced.

        THIS IS THE REAL FAN OUT, NOT A DRY RUN, AND IT HAS PRODUCTION EFFECTS. The event goes through the same entitlement resolution, the same delivery creation, and the same delivery schedule a real event goes through. Read the four consequences below before you call it.

        * It requires a LIVE GRANT. An event nobody is entitled to receive fans
          out to nothing, so a simulation into an org unit holding no grant
          reports zero deliveries rather than an error.

        * It fans out to EVERY ENTITLED ENDPOINT, not to one you choose. There
          is no endpoint id in the request. If three endpoints in your
          organization accept this product and topic, all three receive it. To
          exercise a single endpoint, use the test operation instead, which is
          addressed to one endpoint and sends nothing to any other.

        * It writes REAL DELIVERY ROWS. They appear in the delivery listing,
          count toward retention, and are replayable exactly like any other
          delivery. They are marked `simulated: true` so you can tell them
          apart, and that is the only thing that distinguishes them.

        * **A FAILING RECEIVER WILL OPEN THE CIRCUIT EXACTLY AS A REAL EVENT
          WOULD.** Simulated deliveries retry on the ordinary schedule and their
          failures count against the breaker. Simulating repeatedly against an
          endpoint that is down will trip it, and the endpoint then stops
          receiving real traffic until the circuit closes. The test operation is
          the one that cannot do this.


        Use it to exercise your fan out configuration end to end: whether the grant, the declared types, and the endpoint states line up so that an event of this shape actually reaches the endpoints you expect. The response's `deliveries` array is that answer, endpoint by endpoint.

        **It answers the RECEIVER's question, and only that one:** would events routed TO you actually arrive at your own endpoints? A subscription grant is directed, naming an org unit whose activity produces events and a separate one whose endpoints receive them, and this operation always puts you on the receiving end of that arrow. The event it fires is attributed to the org unit that granted you delivery, never to you.

        **There is deliberately no producer-side mode**, no field to request one, and nothing to configure. Simulating as a producer would mean firing an event that fans out to ANOTHER organization's endpoints, sending them real signed deliveries whose failures count against their circuit breakers. Rather than gate that behind a flag, this operation cannot express it at all.

        **How delivery is confined to your own endpoints.** Two separate limits apply, and both are enforced server side from your authenticated org unit, never from anything you send:

        1. The org units the event may be attributed to are read out of grant rows that name YOUR org unit as the destination, so you can fire only as producers who have already granted you delivery.

        2. Every simulated event carries an explicit destination restriction set to your own org unit. Fan out applies it to the matched grants before it resolves a single endpoint, so a grant routing this same product and topic from that producer to a DIFFERENT vendor is discarded and no delivery is written against their endpoint.

        The second limit is what makes the first one safe: an event names its producer, and a producer's grants routinely fan out to several vendors, so restricting who you may fire AS would not by itself restrict who receives it. If several org units route this same product and topic to you, each is fired as its own event with its own `eventId`, and `deliveries` reports every resulting delivery together.
      operationId: simulateWebhookEvent
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookSimulateRequest'
            example:
              eventType: workflow.updated
              serviceName: ADV
              modelName: ADV-120
        required: true
      responses:
        '200':
          description: The event was fanned out. `deliveries` names every delivery created, one per entitled endpoint, and is empty when nothing was entitled to receive it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookSimulateResponse'
              example:
                eventId: msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384
                simulated: true
                deliveries:
                  - deliveryId: 80531
                    endpointId: 4711
                    subscriptionId: 620
                    eventId: msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384
                    sourceOuGuid: OU10432
                  - deliveryId: 80532
                    endpointId: 4712
                    subscriptionId: 620
                    eventId: msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384
                    sourceOuGuid: OU10432
                warnings: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/webhooks/deliveries/{endpointId}/query:
    post:
      tags:
        - deliveries
      summary: List an endpoint's deliveries, paginated by cursor.
      description: |-
        Returns a page of delivery SUMMARIES for one endpoint, optionally filtered by status and by a creation time range.

        The filter and the pagination cursor travel in the REQUEST BODY rather than in the query string, so this listing is a POST and creates nothing. The body is OPTIONAL: send an empty object `{}`, or no body at all, to request the first page with no filter.

        ORDER AND DIRECTION. Deliveries are returned OLDEST FIRST, ascending by delivery `id`, and paging walks forward toward newer deliveries. The `marker` cursor is EXCLUSIVE: the delivery it names is never sent again on the next page.

        Pagination is by opaque cursor. Pass the `nextMarker` from one response as the `marker` of the next. `nextMarker` is present only when the page was full. A page that is not full is the last one, and carries no cursor. Stop paging there.

        `since` and `until` bound the window by creation time. `marker` is SUBORDINATE to that window rather than an alternative to it: when you send both, the two are combined with AND, so paging walks forward through the window and stops at its end rather than continuing past it.

        This shape never carries a delivery's `payload` or `lastResponseBody`. Both can be large. Fetch a single delivery to see either.
      operationId: listWebhookDeliveries
      requestBody:
        description: Optional. An absent body is equivalent to `{}` and returns an unfiltered first page.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookDeliveriesQueryRequest'
            example:
              status: FAILED
              limit: 50
        required: false
      responses:
        '200':
          description: A page of delivery summaries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryListResponse'
              example:
                limit: 50
                status: FAILED
                count: 2
                nextMarker: '80514'
                results:
                  - id: 80512
                    eventId: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
                    topic: workflow.updated
                    url: https://hooks.example.com/pps/orders
                    status: FAILED
                    simulated: false
                    lastResponseStatus: 500
                    lastFailureReason: HTTP 500 from destination
                  - id: 80514
                    eventId: msg_2d7a4c19-8e05-3f62-b431-7c9a0e5d8f26
                    topic: workflow.failed
                    url: https://hooks.example.com/pps/orders
                    status: FAILED
                    simulated: false
                    lastResponseStatus: null
                    lastFailureReason: Connection timed out
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    parameters:
      - $ref: '#/components/parameters/DeliveryEndpointId'
  /v1/webhooks/deliveries/{endpointId}/{deliveryId}:
    get:
      tags:
        - deliveries
      summary: Get one delivery in full.
      description: |-
        Returns the delivery including its signed `payload` and any captured `lastResponseBody`. This is the ONE place either is exposed. Use it to see exactly what PPS sent and what your server returned.

        A delivery is addressed through its owning endpoint rather than by delivery id alone. The endpoint in the path is what the request is authorized against, and a delivery id that does not belong to that endpoint is a 404.
      operationId: getWebhookDelivery
      responses:
        '200':
          description: The full delivery detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryDetailResponse'
              example:
                id: 80512
                eventId: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
                topic: workflow.updated
                url: https://hooks.example.com/pps/orders
                status: FAILED
                simulated: false
                nextAttemptDateTime: '2026-08-28T15:04:11Z'
                lastResponseStatus: 500
                lastFailureReason: HTTP 500 from destination
                lastResponseBody: '{"error":"internal"}'
                payload:
                  type: workflow.updated
                  eventId: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
                  timestamp: '2026-08-28T14:26:03Z'
                  data:
                    workflowId: wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92
                    runId: wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92
                    correlationId: 9c2e14a7-55d0-4b8e-a731-6f0d2b93e185
                    serviceName: ADV
                    modelName: ADV-120
                    status: ready
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    parameters:
      - $ref: '#/components/parameters/DeliveryEndpointId'
      - $ref: '#/components/parameters/DeliveryId'
  /v1/webhooks/deliveries/{endpointId}/{deliveryId}/replay:
    post:
      tags:
        - deliveries
      summary: Replay one delivery.
      description: |-
        **A 200 FROM THIS OPERATION DOES NOT MEAN THE REPLAY HAPPENED, AND THE RESPONSE BODY CANNOT BE USED TO CONFIRM THAT IT DID.** Read this before you write your error handling.

        Two conditions cause this operation to do NOTHING and still return 200 with the delivery unchanged:

        * The delivery's status is `PENDING` or `RETRYING`. Those rows still
          have a live task driving them, so a replay is refused.

        * The endpoint's circuit is currently OPEN.

        Neither is reported. There is no 409, no error body, and no field in the response that differs between a replay that was queued and one that was refused. Because the body also reports the delivery's state as it stood BEFORE the replay (below), it looks identical in both cases. An integrator writing `if (409) back off, else assume queued` will silently swallow every refused replay.

        THE ONLY WAY TO TELL is to fetch the delivery again afterwards with `GET /v1/webhooks/deliveries/{endpointId}/{deliveryId}` and compare: a replay that was actually queued leaves the delivery `PENDING` with a `nextAttemptDateTime` set. Check the circuit with `circuitState` on the endpoint, and the delivery's status before replaying, if you want to predict the refusal rather than detect it after the fact.

        Signs the delivery again and queues it for immediate attempt. This request has no body.

        The delivery keeps its original `eventId`, so if you deduplicate on that value you will recognise the replay as the SAME business event rather than as a new one. A replay is a redelivery, not a new event.

        A delivery is replayable only while PPS still holds its record. Records are removed once they fall outside the delivery retention window, which is 90 days, so a delivery older than that cannot be replayed.
      operationId: replayWebhookDelivery
      responses:
        '200':
          description: |-
            **THIS BODY REPORTS THE DELIVERY'S STATE AS IT WAS BEFORE THE REPLAY, not after it.** `status`, `nextAttemptDateTime` and the other properties describing an attempt in this response hold the values as they stood before the replay, so a delivery that is now `PENDING` is still reported here as `FAILED`. Do not drive your state machine from this body. To observe the state after the replay, fetch the delivery with `GET /v1/webhooks/deliveries/{endpointId}/{deliveryId}`.

            This status is ALSO returned when the replay was refused and nothing was queued. See the operation description. A 200 means the request was accepted, not that a delivery was queued again.

            The delivery, in the full detail shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryDetailResponse'
              example:
                id: 80512
                eventId: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
                topic: workflow.updated
                url: https://hooks.example.com/pps/orders
                status: FAILED
                simulated: false
                lastResponseStatus: 500
                lastFailureReason: HTTP 500 from destination
                lastResponseBody: '{"error":"internal"}'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    parameters:
      - $ref: '#/components/parameters/DeliveryEndpointId'
      - $ref: '#/components/parameters/DeliveryId'
  /v1/webhooks/deliveries/{endpointId}/replay:
    post:
      tags:
        - deliveries
      summary: Replay an endpoint's stalled deliveries in bulk.
      description: |-
        Selects deliveries over a DATE RANGE, narrowed by an optional `status`, and queues them. The call is ASYNCHRONOUS: it returns 200 with a task handle as soon as the task is accepted, without waiting for the deliveries to be queued. Poll `GET /v1/webhooks/deliveries/{endpointId}/replay/{taskId}` with the returned `taskId` for progress.

        THE WINDOW IS THE BOUND. `since` is REQUIRED and `until` is optional, so a replay always states the span it covers rather than inheriting an implicit one. There is no per request ceiling on how many deliveries one replay may queue: narrow the window if you want to queue fewer. The window is over each delivery's CREATION time, which is when the event occurred, not when it was last attempted.

        SELECTION. The `status` filter decides what a replay covers, and the default is deliberately narrow. Omitting `status` selects only the two STALLED states, `FAILED` plus `PARKED`, which is recovery from a failure on the PPS side. That default is what recovers a disabled endpoint: enable the endpoint again, then run a bulk replay with no `status`, and the deliveries parked while it was off are queued along with any that exhausted their retries.

        If what you need is to re-send events YOUR service lost, the default is the wrong selection. Name a `status` explicitly, as described next.

        Naming a `status` narrows the selection to that one status. FOUR statuses can be replayed: `FAILED`, `PARKED`, `DELIVERED` and `CANCELLED`.

        **`DELIVERED` is the one to name when your own consumer lost the events.** A delivery PPS made successfully is `DELIVERED` however your service handled it afterwards, so if your consumer acknowledged deliveries with a 2xx during an outage and then dropped them, those deliveries are `DELIVERED` and the default selection does not reach them. Replay them with `status` set to `DELIVERED` and the window your consumer was broken in. This re-sends events your service has already seen, so your handler must tolerate duplicates, which it should in any case because delivery happens at least once.

        `CANCELLED` covers deliveries stopped before they were sent.

        Naming `PENDING` or `RETRYING` is REJECTED with a 400, because those rows still have a live delivery task driving them and replaying one would race it. The refusal names the status you sent, so the mistake is visible rather than arriving later as a replay that silently did nothing.

        A bulk replay can flood an endpoint that has only just recovered. Three bounds apply, and you must handle all three:

        * It is always scoped to ONE endpoint. There is no bulk replay that
          spans more than one endpoint.

        * Replayed deliveries are spread TWO SECONDS APART rather than queued
          all at once. A replay of 100 deliveries therefore drains over roughly
          three and a half minutes. Size your receive window accordingly, and do
          not read a slow drain as a failed replay.

        * CONCURRENT BULK REPLAYS ARE BOUNDED, NOT PREVENTED. Nothing serializes
          two bulk replays issued against the same endpoint at the same time, so
          a delivery selected by both windows can be reset twice and enqueued
          twice. Your consumer already has to tolerate that, since the `eventId`
          is unchanged and delivery happens at least once. If a replay seems
          slow, poll the task rather than issuing a second call. The spread
          above is the expected behaviour, and a second call doubles the
          traffic.

        * A replay into an endpoint whose circuit is currently OPEN is REFUSED
          outright with a 409, never silently accepted. A task that completes
          with `replayed: 0` means "nothing matched the filter", never "the
          endpoint refused".


        RETENTION BOUNDS WHAT A WINDOW CAN REACH. Delivery records are removed once they fall outside the delivery retention window of 90 days, so a `since` older than that selects only what still exists. The task's own status is kept for 30 days; poll within that or the task id reads as a 404.
      operationId: bulkReplayWebhookDeliveries
      requestBody:
        description: Optional as a body, but `since` is required within it. An absent body is equivalent to `{}`, which is rejected with a 400 naming `since`.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookBulkReplayRequest'
            example:
              since: '2026-08-01T00:00:00Z'
              until: '2026-08-28T00:00:00Z'
              status: FAILED
        required: false
      responses:
        '200':
          description: The replay task was accepted. The deliveries are queued after this response returns, so poll the task for progress rather than reading this body as a result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookBulkReplayTaskResponse'
              example:
                taskId: wrt_60184
                endpointId: 4711
                status: PENDING
                since: '2026-08-01T00:00:00Z'
                until: '2026-08-28T00:00:00Z'
                replayed: 0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    parameters:
      - $ref: '#/components/parameters/DeliveryEndpointId'
  /v1/webhooks/deliveries/{endpointId}/replay/{taskId}:
    get:
      tags:
        - deliveries
      summary: Report the progress of an asynchronous bulk replay.
      description: |-
        Reports the state of one bulk replay task: whether it is still running, how many deliveries it has queued so far, and the window it resolved from `since` and `until`. Poll it with the `taskId` the bulk replay returned.

        The task is scoped to the endpoint in the path, so a task id belonging to a different endpoint is a 404 on the same terms as any other lookup that reaches across endpoints.

        Task status is retained for 30 days. After that the task is removed and this operation returns a 404, so record the outcome you care about rather than relying on the task as a durable record.
      operationId: getWebhookBulkReplayTask
      responses:
        '200':
          description: The task's current state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookBulkReplayTaskResponse'
              example:
                taskId: wrt_60184
                endpointId: 4711
                status: RUNNING
                since: '2026-08-01T00:00:00Z'
                until: '2026-08-28T00:00:00Z'
                replayed: 1420
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    parameters:
      - $ref: '#/components/parameters/DeliveryEndpointId'
      - $ref: '#/components/parameters/ReplayTaskId'
components:
  schemas:
    WebhookTopic:
      type: string
      description: |-
        A published webhook topic. The complete vocabulary is eight topics in two families: the workflow topics `workflow.pending`, `workflow.updated`, and `workflow.failed`, and the finding topics `finding.result_updated`, `finding.reviewed`, `finding.note_updated`, `finding.attachment_updated`, and `finding.attachment_removed`. An endpoint may accept any subset of it, and any other value is rejected with a 400.

        `workflow.pending` fires when a run enters processing, either for the first time or on resuming after a failure. `workflow.updated` fires whenever new data becomes available for the workflow: when a run becomes ready, and also whenever a finding on one of its runs changes. `workflow.failed` fires when a run fails.

        `workflow.updated` is the signal to act on. There is no completion event, by design: once a run is ready its results are available to fetch, and nothing further happens that an integration needs to know about. Do not wait for one.

        None of the three workflow topics implies the others follow. A run can be updated without ever having announced pending, and a failed run can later announce pending and then updated, so none of them is terminal.

        The finding topics say which finding changed and how, and each carries `data.findingId`. Each fires only when something actually changed; a save that leaves the value as it was publishes nothing.

        * `finding.result_updated` - a finding's result was overridden. The
          finding's effective outcome is `Finding.resultOrOverriddenResult`;
          `result` keeps the check's own, unoverridden outcome. The change
          also appears in the finding's history
          (`GET /v1/findings/workflows/{workflowId}/findings/{findingId}/history`)
          as an entry whose `field` is `resultOrOverriddenResult`, which is
          where the value it replaced can be seen.

        * `finding.reviewed` - a finding was CLEARED, either by a reviewer
          (including through `POST .../findings/{findingId}` with
          `reviewed: true`) or automatically. Only a clear publishes it. A
          reset, which returns a cleared finding to unreviewed, does NOT
          publish `finding.reviewed`, or any other finding topic; it arrives
          as a `workflow.updated` alone, which carries no `data.findingId`,
          so refetch the workflow's findings to see which one changed.

        * `finding.note_updated` - the review note was set, replaced, or
          cleared.

        * `finding.attachment_updated` - an attachment was added to the
          finding, or an existing attachment's content or details changed
          (for example, it was renamed or its file replaced). Saving an
          attachment without changing it publishes nothing, and neither
          does a change to an attachment that was already removed. Carries
          `data.attachmentId`, naming the attachment that was added or
          changed.

        * `finding.attachment_removed` - an attachment was removed from the
          finding. Removing an attachment that is already removed publishes
          nothing. Carries `data.attachmentId`, naming the attachment that
          was removed; its content can no longer be fetched.


        EVERY FINDING EVENT IS ACCOMPANIED BY ITS OWN `workflow.updated`, a separate event with its own `eventId`, for the same `workflowId` and `runId`. They are not combined: a change touching several findings, or several things on one finding, publishes one `workflow.updated` per finding event, possibly alongside the one a run becoming ready publishes, and a reset publishes its own. An integration that accepts only `workflow.updated` therefore still hears that a finding changed, just not which one.

        Consent is per topic. Accepting `workflow.updated` does not opt you into any finding topic, so an endpoint receives no finding events until it declares them; events from another organization additionally need a subscription grant naming that same topic.

        Matching is exact and sensitive to case: `Workflow.Updated` is not `workflow.updated`.
      example: workflow.updated
    WebhookSignatureMode:
      type: string
      description: |-
        Which signature scheme your deliveries carry. An endpoint uses exactly one, never both. Defaults to `SYMMETRIC`, which is what every endpoint gets if you never set this.

        `SYMMETRIC` gives a `v1` HMAC signature computed with your signing secret. It requires `signingSecret` to be set. An endpoint you register without mentioning signing at all is a symmetric one, and must carry a secret from the moment it exists.

        `ASYMMETRIC` gives a `v1a` Ed25519 signature only, verified against our published public key. You hold no shared secret, so there is nothing to store or to keep in step with us, and we can rotate our key without you changing anything. Each delivery names the key that signed it in its `webhook-key-id` header, so you can SEE a rotation per delivery rather than inferring it from a signature that stopped verifying. Any tooling of yours still checking `v1` stops verifying, so make the switch deliberately.

        Because the two are exclusive, an `ASYMMETRIC` endpoint has no secret to fall back on. If we cannot produce its `v1a` signature the delivery is not sent and is retried on the ordinary schedule. We never send it unsigned, and never downgrade it to `v1`.

        Moving from `SYMMETRIC` to `ASYMMETRIC` clears your stored secret; there is nothing left for us to hold. Moving back requires supplying `signatureMode` and `signingSecret` together in one edit.
      example: SYMMETRIC
    WebhookProductEventTypes:
      type: object
      properties:
        serviceName:
          type: string
          description: The product's service name, exactly as it appears in the order API path. An unrecognized value is rejected with a 400.
          example: ADV
        modelName:
          type: string
          description: The product's model name, exactly as it appears in the order API path. Stored as given and matched exactly at delivery time, so a value that does not match a real model simply never receives anything, so check what you send.
          example: ADV-120
        eventTypes:
          type: array
          description: |-
            The topics you want for this product. Repeats are collapsed. An empty array is permitted and means this entry accepts nothing, which is equivalent to omitting the entry.

            Accepting `workflow.updated` without `workflow.failed` returns a warning (not an error): you would hear when new data arrived but never when a run failed.
          items:
            $ref: '#/components/schemas/WebhookTopic'
      description: |-
        The topics accepted for ONE product. Consent is scoped to a product: this entry says nothing about any other product, and a product you never list sends you nothing.

        `serviceName` and `modelName` together identify the product, and they are the same pair that appears in the order API's path. Whatever you call to place an order is what you name here.
    WebhookNotReceivingReason:
      type: string
      description: |-
        Why an endpoint is not receiving. One of:

        * `NOT_DECLARED` - the endpoint has declared no accepted event types at
          all, so no event can ever match it. Yours to fix, by adding the
          products and topics you want to `acceptedEventTypes`. Reported ahead
          of `NO_GRANT` because a grant cannot deliver to an endpoint that
          accepts nothing.

        * `NO_GRANT` - the endpoint declared selectors, but no live subscription
          grant covers any of them. Only PPS can write a grant, so this one is
          not yours to fix: contact your PPS integration contact. It tells you
          about your OWN entitlement and deliberately nothing else - not which
          organizations grant to you, not how many do, not when they did.

        * `DISABLED` - fully provisioned, but the endpoint is switched off,
          either by you or by the platform after repeated failures. Re-enable it.

        * `CIRCUIT_OPEN` - fully provisioned and enabled, but the delivery
          circuit breaker opened after repeated delivery failures. Nothing to
          edit: it closes on its own once your endpoint responds successfully.


        Treat an unrecognized value as "not receiving, cause unknown" rather than as an error, so a reason added later does not break your client.
      example: NO_GRANT
    WebhookCircuitState:
      type: string
      description: |-
        Whether PPS is currently delivering to this endpoint. There are exactly two states, `CLOSED` and `OPEN`. There is no third. An earlier revision of this specification published a third value, `HALF_OPEN`; no release ever emitted it and it is withdrawn. Recovery is a hold-back window plus a probe delivery, not a distinct state a client can observe.

        `CLOSED` is the healthy state: deliveries are attempted normally. A freshly registered endpoint reports `CLOSED`.

        `OPEN` means PPS has stopped attempting deliveries to this endpoint because it failed repeatedly. Deliveries are HELD rather than dropped. Nothing is lost while the circuit is open, and they are attempted again once it closes.

        **What opens it.** Any of three things: a run of consecutive failed ATTEMPTS with no success between them, the endpoint answered `410 Gone`, or a single delivery exhausted its full retry schedule. A failure is a transport error or any response outside the 2xx range, including a 4xx. The unit is the attempt, not the delivery: every request we actually send counts, so one delivery retried to exhaustion can open the circuit on its own. The run is not bounded by a time window and may span minutes or days, which is what lets a low-volume endpoint be detected as broken at all.

        A success RESETS the run to zero. Only failures newer than your endpoint's most recent successful attempt are counted, so an endpoint that fails intermittently but succeeds in between does not trip.

        **What closes it.** A single successful delivery. While the circuit is open PPS periodically admits ONE delivery as a probe. If your endpoint accepts it, the circuit closes immediately and the held deliveries resume. If it fails, the circuit stays open and a later probe is admitted. Only one probe is in flight at a time, so a broken endpoint receives an occasional single request rather than a retry storm.

        **What you can do.** Fix the endpoint and wait for the next probe, or send a test delivery, which is not subject to the circuit and closes it on success. A bulk replay is refused with a 409 while the circuit is open, so close it first.

        There is no third state. The probe is admitted without changing the reported state, so an endpoint under trial still reports `OPEN` until the probe succeeds.
      example: CLOSED
    WebhookDeliveryStatus:
      type: string
      description: |-
        A delivery's lifecycle state. One of `PENDING`, `DELIVERED`, `RETRYING`, `FAILED`, `PARKED`, or `CANCELLED`.

        `PENDING` is queued and not yet attempted; `RETRYING` has failed at least once and has a further attempt scheduled; `DELIVERED` succeeded; `FAILED` exhausted its attempts. `PARKED` is the state deliveries are moved to when their endpoint is DISABLED or its authorized domain loses that authorization. Parked deliveries are not discarded and can be resurrected by enabling the endpoint again and issuing a bulk replay, which selects `PARKED` by default. `CANCELLED` means the authorizing grant was revoked, so we will not send it again on our own.

        Recovery of a parked delivery is bounded by the delivery retention window: parked deliveries are eventually deleted like any other finished delivery, so enable the endpoint again and replay within it.

        Matching is exact and sensitive to case when used as a filter.
      example: DELIVERED
    WebhookEndpointWriteRequest:
      type: object
      properties:
        url:
          type: string
          description: The destination deliveries are POSTed to. Required on register, optional on edit. Its host must resolve to a domain PPS has approved.
          example: https://hooks.example.com/pps/orders
        displayName:
          type: string
          description: Your own label for the endpoint. Leading and trailing whitespace is trimmed, and the trimmed value must be 255 characters or fewer; a longer value is rejected with a 400.
          example: Production order hook
        acceptedEventTypes:
          type: array
          description: |-
            What this endpoint accepts, declared PER PRODUCT: one entry per product, each naming the topics you want for that product.

            Consent is scoped to a product, so accepting `workflow.updated` for one product does not accept it for any other. A product you do not list sends you nothing.

            Entitlement is decided on the WORKFLOW, not on the individual run: grants are keyed on the root order's product and ordering org unit. So a grant covering the workflow you ordered also covers every run inside it, including a reconfirm placed later by another org unit — which is announced to the subscribers of the ORIGINATING organization. A grant naming only a sub-product of a workflow matches nothing and delivers nothing.

            Every topic must be a published one and every `serviceName` must name a real service; an unrecognized value in either is rejected with a 400 that names what was wrong. `modelName` is stored as given and matched exactly at delivery time, so a mistyped model receives nothing rather than being rejected. Sending `[]` means "accept nothing", which is a valid configuration — on a register, one that comes back with a `warnings` entry pointing out that the endpoint will receive nothing.
          items:
            $ref: '#/components/schemas/WebhookProductEventTypes'
        contacts:
          type: array
          description: |-
            Notification contact email addresses. Each value is trimmed and NORMALIZED TO LOWERCASE before storage, so a contact read back may differ in case from the one you sent. A value that is blank or has no `@` with text on both sides is rejected with a 400.

            Zero contacts is permitted but means the notification sent when an endpoint is disabled automatically will reach nobody. See `hasNoContactsRegistered` on the response.
          items:
            type: string
            format: email
            example: integrations@example.com
        enabled:
          type: boolean
          description: 'Whether the endpoint is switched on. Omitting this leaves the current state unchanged; it does not default to either value. A newly registered endpoint is enabled regardless of this property. This is the kill switch a customer uses to stop deliveries during an incident, so the server accepts only the JSON literals true and false: a number (including 0 or 1) or a quoted string is rejected with a 400 naming the field, never silently converted to a boolean.'
        organizationalUnitGuid:
          type: string
          description: |-
            The org unit the endpoint is created in, which is the org unit its deliveries are routed to.

            ON REGISTER this is REQUIRED. A register body that omits it, or that carries it empty, is rejected with a 400; there is no default and no fallback to your own org unit. Like `url`, the requirement belongs to the register OPERATION rather than to this shape, which is why it is not listed under `required` here: this one schema describes the edit body too, where the property is optional.

            Naming your own org unit is the ordinary case. You may also name any org unit BENEATH yours in your organization's hierarchy, which is how you create an endpoint for a particular branch or department. An org unit that is neither your own nor beneath it is rejected with a 403. Ancestors are not reachable either: an org unit above yours is a wider scope than the one you hold, so naming one is rejected the same way.

            REQUIRED RATHER THAN DEFAULTED, deliberately. Unknown keys on this API are ignored rather than rejected, so had this property been optional a MISSPELLING of it would have been silently dropped and indistinguishable from leaving it out: you would get a 200, an endpoint in your own org unit rather than the one you meant, and no delivery ever arriving from the org unit you thought you had chosen. Requiring the property turns that typo into a 400 on the call that made it.

            READ THIS BEFORE NAMING A CHILD ORG UNIT. Subscription grants do NOT inherit down the hierarchy. A grant names exactly one destination org unit and is matched to an endpoint by EXACT EQUALITY against the endpoint's org unit, with no walk up the tree.

            This matters for events another org unit produces. An endpoint you create in a department receives that department's OWN events automatically, with no grant at all, as soon as it declares the topic. What it does NOT receive is events produced elsewhere: a grant held by the parent company root does not reach it, and only a grant naming THAT DEPARTMENT as its destination will.

            So `receiving: true` on the response tells you the endpoint is wired up for its own org unit's traffic. It does not tell you that any other org unit routes to you. If you are waiting for a counterparty's events specifically and none arrive, the missing piece is a grant naming your exact org unit as its destination, not a change to the endpoint.

            ON EDIT this property is optional and sending a DIFFERENT value MOVES the endpoint to that org unit. Omit it to leave the endpoint where it is; sending back the value it already has is accepted and changes nothing, so a `getWebhookEndpoint` response can be posted straight back as an edit body. The destination is checked in its own right: moving into an org unit outside your hierarchy, or one you hold no rights over, is refused.

            CHECK YOUR GRANTS BEFORE YOU MOVE AN ENDPOINT. Because grants are matched to the endpoint's org unit by exact equality, moving an endpoint CHANGES WHICH GRANTS CAN REACH IT. An endpoint that is receiving today under a grant naming its current org unit stops receiving the moment it lands in an org unit no grant names. The move returns 200, the endpoint still lists as enabled, and deliveries simply stop. Confirm a grant exists naming the destination before moving, and read `receiving` off the response afterwards.

            The org unit an endpoint ended up in is echoed back on every response that carries an endpoint, so you can always confirm it rather than infer it.
          example: OU41502
        signatureMode:
          $ref: '#/components/schemas/WebhookSignatureMode'
        signingSecret:
          type: string
          description: |-
            The secret to sign this endpoint's deliveries with: the `whsec_` prefix followed by 24 to 64 bytes of key material in base64.

            GENERATE YOUR OWN, from a cryptographically secure random number generator, and generate a distinct one per endpoint. Never send a value copied from documentation, an example or another integrator: the strength of every signature on this endpoint rests entirely on the material you choose here, and PPS checks only the shape of what you send, never how it was produced. The example below is a placeholder and is deliberately rejected if you send it.

            ON REGISTER this is required when `signatureMode` is `SYMMETRIC` (including when you omit the mode, since `SYMMETRIC` is the default), and must be absent when it is `ASYMMETRIC`.

            ON EDIT this is how the secret changes; it is the only operation that sets one. Sent alone, it rotates the secret of an endpoint that is already `SYMMETRIC`, and takes effect immediately for deliveries already queued.

            Sent alone to an `ASYMMETRIC` endpoint it is rejected with a 400, because such an endpoint holds no shared secret and storing one would leave a live credential nothing can reach. To move an endpoint between the modes, send `signatureMode` and `signingSecret` together: switching to `SYMMETRIC` requires the secret, switching to `ASYMMETRIC` forbids it and clears the stored value. One request makes the transition atomic, with no interval in which the endpoint is `SYMMETRIC` but unsignable.
          example: whsec_REPLACE_WITH_YOUR_OWN_RANDOM_KEY_MATERIAL
      description: |-
        The body of a register or an edit. The two operations share one shape, and the difference between them is which properties are meaningful when omitted, not which properties exist.

        On REGISTER, `url` and `organizationalUnitGuid` are required. On EDIT, every property is optional and an omitted one leaves the stored value unchanged. Since one schema describes both bodies, neither is marked required here; the requirement depends on the operation and a register without either is rejected with a 400.

        `organizationalUnitGuid` is required on register and optional on edit, where a different value MOVES the endpoint to that org unit and an omitted one leaves it where it is. A move is authorized against the DESTINATION as well as the endpoint, and it changes which subscription grants can reach the endpoint: see the property's own description.

        For the two array properties, an OMITTED key and an EXPLICITLY EMPTY array are different instructions: omitting `acceptedEventTypes` leaves the declared set alone, while sending `[]` replaces it with nothing.

        Unknown keys are ignored rather than rejected.
    WebhookDeliveriesQueryRequest:
      type: object
      properties:
        marker:
          type: string
          description: |-
            The pagination cursor: pass back the `nextMarker` from a previous response. It is EXCLUSIVE, so the delivery it names is not sent again, and paging proceeds toward NEWER deliveries.

            SUBORDINATE TO THE RANGE. When you send `since` or `until` as well, the cursor and the range are combined with AND rather than one overriding the other: paging walks forward inside the window and stops at its end. A cursor naming a delivery outside the window simply yields nothing.

            A string on the wire. It must parse as a positive integer; a value that will not is rejected with a 400. Treat it as opaque and pass it back unmodified.
          example: '80514'
        limit:
          type: integer
          description: |-
            The requested page size. Valid range is 1 to 100; the default is 100 when omitted.

            A VALUE OUTSIDE THAT RANGE IS NEITHER AN ERROR NOR CLAMPED TO THE NEAREST BOUND. It is replaced outright by the default, and the default is 100, the same number as the maximum. So `limit: 0` and `limit: 5000` BOTH yield 100. A request for zero rows returns the LARGEST page, not the smallest and not a 400. If you compute this value, check its range yourself before sending it; the API will not tell you that it ignored what you asked for beyond echoing the value actually applied in the response's own `limit` property.
          format: int32
          example: 50
        status:
          description: Restricts the result to one delivery status. Omit to return every status.
          allOf:
            - $ref: '#/components/schemas/WebhookDeliveryStatus'
        since:
          type: string
          description: |-
            The start of the window, as an RFC 3339 date-time. INCLUSIVE: a delivery created at exactly this instant is returned. Omit it, or send an explicit `null`, to start from the oldest delivery that still exists.

            The window is over each delivery's CREATION time, which is when the event occurred, not when it was last attempted.
          format: date-time
          nullable: true
          example: '2026-08-01T00:00:00Z'
        until:
          type: string
          description: 'The end of the window, as an RFC 3339 date-time. EXCLUSIVE: a delivery created at exactly this instant is NOT returned, so consecutive windows can share a boundary without returning anything twice. Omit it, or send an explicit `null`, to run to now.'
          format: date-time
          nullable: true
          example: '2026-08-28T00:00:00Z'
      description: |-
        The body of a delivery listing: the filter that chooses which deliveries to SHOW, and the cursor that pages through them.

        Every property is optional; send `{}` for an unfiltered first page. Unknown keys are ignored rather than rejected.
    WebhookTestRequest:
      required:
        - eventType
        - modelName
        - serviceName
      type: object
      properties:
        serviceName:
          type: string
          description: The product's service name, matching the order API path and the `acceptedEventTypes` entry you registered.
          example: ADV
        modelName:
          type: string
          description: The product's model name, paired with `serviceName` to identify the product exactly as `acceptedEventTypes` does.
          example: ADV-120
        eventType:
          type: string
          description: Which topic to synthesize. It must be one this endpoint has DECLARED for that product; any other value is rejected with a 400, checked after authorization.
          example: workflow.updated
      description: |-
        The body of a test delivery. All three properties are required.

        This is a SELECTOR, not a payload: the three values together name WHICH declared event shape PPS should synthesize, and PPS builds the envelope from them. There is no way to supply payload bytes. Accepting them would let any authenticated caller mint PPS envelopes that carry a valid signature and replay them elsewhere, since our signatures verify against our published public key. See the operation description.
    WebhookSimulateRequest:
      required:
        - eventType
        - modelName
        - serviceName
      type: object
      properties:
        eventType:
          type: string
          description: |-
            The topic to fire, from the published vocabulary. A value outside it is rejected with a 400.

            For a workflow topic it also decides the envelope's `data.status`, which always agrees with the topic you ask for: `workflow.pending` publishes `status: pending`, `workflow.updated` publishes `status: ready`, and `workflow.failed` publishes `status: failed`. That is the pairing a real event carries when a run changes status. A real `workflow.updated` that accompanies a finding event carries the run's current status instead, which need not be `ready`.

            A finding topic describes no run status, so its simulated envelope OMITS `data.status` rather than inventing one. It carries a `data.findingId` instead, random like the synthetic `workflowId` and `runId`, so it resolves to nothing. The two attachment topics, `finding.attachment_updated` and `finding.attachment_removed`, also carry a random `data.attachmentId` in its `jattmt_`-prefixed form, which likewise resolves to nothing. Simulating a finding topic fires that one event only, not the `workflow.updated` a real finding change is accompanied by.
          example: workflow.updated
        serviceName:
          type: string
          description: The product's service name, exactly as it appears in the order API path. Together with `modelName` it decides which endpoints are entitled to the event.
          example: ADV
        modelName:
          type: string
          description: The product's model name, exactly as it appears in the order API path. Matched exactly, so a value that names no real model reaches no endpoint and the simulation reports zero deliveries.
          example: ADV-120
        correlationId:
          type: string
          description: |-
            OPTIONAL. The correlation id to stamp on the simulated envelope — supply the value you would send when starting a real workflow, so the envelope your endpoint receives exercises your own correlation handling exactly as a production one would.

            A value you supply is delivered UNCHANGED — it is yours, so it is never trimmed into a different value, reformatted, or prefixed. (Surrounding whitespace only is treated as omission, since it names nothing.)

            OMIT IT AND ONE IS MINTED FOR YOU: a random UUID, present in the delivered envelope's `data.correlationId` and in the delivery rows this call returns, so you can still tie the two together. The key is never absent from a simulated envelope.
          example: 9c2e14a7-55d0-4b8e-a731-6f0d2b93e185
      description: |-
        The body of an event simulation. Three properties are required; `correlationId` is optional.

        The three required values name the event to fire. They do NOT name an endpoint, and they do not name an organization either. The simulation delivers to YOUR OWN endpoints, for every org unit that has granted you delivery of an event of this shape: your authenticated org unit is both the destination matched when choosing which producers to fire as, and the destination restriction carried on the event itself, which fan out applies before resolving any endpoint. See the operation description.
    WebhookSimulateResponse:
      required:
        - deliveries
        - eventId
        - simulated
        - warnings
      type: object
      properties:
        eventId:
          type: string
          description: |-
            The simulated event's id, formatted as `msg_` followed by a UUID. It is the value that reaches a receiver in the `webhook-id` header and in the delivery body.

            One event is fired per org unit routing this product and topic to you, each with its own id, and this field carries the FIRST of them. When exactly one org unit routes to you, which is the common case, every delivery listed below carries this id. Otherwise read each delivery's own `eventId` rather than assuming this one covers them all.
          example: msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384
        simulated:
          type: boolean
          description: Always `true` on this response, and the same value that appears on every delivery this call created. It is stated here so a stored or logged report says what it was without reference to the operation that produced it.
          example: true
        deliveries:
          type: array
          description: |-
            One entry per delivery the fan out created, one per entitled endpoint. Always emitted, including as `[]` when nothing was entitled, so you can loop over it unconditionally.

            These are real deliveries. Each appears in that endpoint's delivery listing, retries on the ordinary schedule, and can be fetched and replayed like any other delivery.
          items:
            $ref: '#/components/schemas/WebhookSimulatedDelivery'
        warnings:
          type: array
          description: Advisories about why the fan out reached fewer endpoints than you expected, for example that no live grant covers this product or that no endpoint has declared this topic. They are English sentences for you or your operators to read, not codes your program can match on. Always emitted, as `[]` when there is nothing to say.
          items:
            type: string
      description: |-
        The report from one simulated event: the event that was fired and every delivery the fan out created for it.

        An empty `deliveries` array is a real answer rather than a failure. It means nothing was entitled to receive an event of this shape, and `warnings` says which condition was missing.
    WebhookSimulatedDelivery:
      required:
        - deliveryId
        - endpointId
      type: object
      properties:
        deliveryId:
          type: integer
          description: The created delivery's id. Fetch it with `GET /v1/webhooks/deliveries/{endpointId}/{deliveryId}` to see the envelope that was built and, once attempted, what your server returned.
          format: int64
          example: 80531
        endpointId:
          type: integer
          description: The endpoint this delivery is addressed to. It is also the `endpointId` you pass when fetching the delivery.
          format: int64
          example: 4711
        subscriptionId:
          type: integer
          description: 'The subscription grant that routed this event to the endpoint. Absent when the delivery was created on a path that resolved no single grant: most commonly because the endpoint sits in the org unit that produced the event, which needs no grant to receive it.'
          format: int64
          example: 620
        eventId:
          type: string
          description: |-
            The event this delivery belongs to, the same value the receiver sees in its `webhook-id` header.

            One event is fired per producing org unit, so when more than one routes to you these entries carry DIFFERENT ids and this field is what tells them apart. When a single org unit routes to you, every entry repeats the one id at the top of the response.
          example: msg_1d4b7e02-5a93-4c68-b7f1-0e2a9c6d5384
        sourceOuGuid:
          type: string
          description: |-
            The org unit whose activity the simulated event was attributed to, that is the producing side of the grant that entitled this delivery.

            It is never your own org unit: it is the one that granted you delivery, and it is the field that answers which of your counterparties this delivery would have come from.
          example: OU10432
      description: 'One delivery created by a simulated event, identified by the handles you need to follow it: the delivery itself, the endpoint that will receive it, the grant that routed the event there (when one did), and the event it belongs to.'
    WebhookEndpointResponse:
      required:
        - acceptedEventTypes
      type: object
      properties:
        id:
          type: integer
          description: The endpoint's id. It is the handle every other call in this API that names an endpoint takes.
          format: int64
          example: 4711
        organizationalUnitGuid:
          type: string
          description: |-
            The org unit this endpoint belongs to, which is the org unit its deliveries are routed to. Present on every operation that returns an endpoint: the value behind it is never absent for an endpoint that exists.

            This is the applied value, read back from the stored endpoint rather than echoed from your request, so it is what the platform will actually route on. Compare it against what you sent when you want to confirm an endpoint landed where you intended.

            Grants do not inherit down the hierarchy, and they govern only events produced ELSEWHERE. An endpoint here always receives the events its own org unit produces, with no grant, once it declares the topic. For another org unit's events a subscription grant must name THIS org unit exactly as its destination; a grant held by an org unit above it does not reach it.
          example: OU41502
        displayName:
          type: string
          description: Your own label. Absent when none was ever set.
          example: Production order hook
        url:
          type: string
          description: The destination deliveries are POSTed to.
          example: https://hooks.example.com/pps/orders
        enabled:
          type: boolean
          description: Whether the endpoint is switched on, either by you or by the platform disabling it automatically. This is only ONE of the three conditions behind `receiving`.
        circuitState:
          description: The delivery circuit breaker's state. An endpoint the breaker has never stamped reports `CLOSED` rather than an absent or unknown value, so a freshly registered endpoint always reports a real state.
          allOf:
            - $ref: '#/components/schemas/WebhookCircuitState'
        receiving:
          type: boolean
          description: |-
            The single derived answer to "will an event fired right now reach me?".

            It is true when all three of these hold FOR THIS ENDPOINT, and false the moment any one of them does not:

            * the endpoint is enabled;
            * its circuit is CLOSED;
            * a live subscription grant matches this endpoint's own declared
              (`serviceName`, `modelName`, `eventType`) triple.


            The third condition is per endpoint and per product, not merely per organization. An endpoint that has declared nothing, or that has declared only products no live grant covers, reports `false` even when your organization holds grants for other products. Surface THIS rather than `enabled`, which is true for plenty of endpoints that are receiving nothing.

            Because it ANDs those conditions together, a `false` here does not say WHICH one failed, and they have different owners. Read `notReceivingReason`, present whenever this is false, to learn whether the next action is yours or ours.
        notReceivingReason:
          description: |-
            WHY `receiving` is false, as a value your program can branch on.

            PRESENT ONLY WHEN `receiving` IS FALSE. When the endpoint is receiving, this key is ABSENT rather than null or a `NONE` sentinel, so test for the key's presence rather than comparing against a magic value.

            `receiving` ANDs several independent conditions into one bit, which cannot tell you WHOSE problem a stalled integration is: whether PPS has not provisioned you, whether you disabled the endpoint, or whether your own circuit breaker tripped. Those have three different owners and three different next actions. This field names which one applies.

            EXACTLY ONE cause is reported even when several hold at once, and it is the one CLOSEST TO THE ROOT of the dependency chain rather than the one easiest to fix. Consent (`NOT_DECLARED`, then `NO_GRANT`) outranks your endpoint's own state (`DISABLED`, then `CIRCUIT_OPEN`), because re-enabling an endpoint that holds no grant still receives nothing: fixing a cause above the root changes no outcome. Work the reported cause, then read the field again.
          allOf:
            - $ref: '#/components/schemas/WebhookNotReceivingReason'
        hasNoContactsRegistered:
          type: boolean
          description: True when zero notification contacts are registered. That is a permitted configuration, but it means that disabling the endpoint automatically will notify nobody, and you would not learn that deliveries had stopped.
        signatureMode:
          $ref: '#/components/schemas/WebhookSignatureMode'
        acceptedEventTypes:
          type: array
          description: |-
            What this endpoint has declared it accepts, grouped BY PRODUCT, with one entry per product, each listing that product's topics. ALWAYS present, including as `[]`, because "accepts nothing" is a real state, so iterating this never needs a null check.

            A product absent from this list sends you nothing, however many grants exist for it.
          items:
            $ref: '#/components/schemas/WebhookProductEventTypes'
        warnings:
          type: array
          description: |-
            Advisories about this endpoint's configuration that block nothing. They are English sentences for you or your operators to read, not codes your program can match on; do not match on their text.

            PRESENCE OF THIS KEY IS ITSELF INFORMATION. The write paths (register and edit) always emit it, as `[]` when there is nothing to say, while the read paths (get, listing) never emit it at all. So `warnings: []` means "your configuration produced no advisories" and an absent key means "this response did not come from a write path". Those are different statements and you may rely on the difference.

            Three advisories exist today: one when the endpoint accepts a success event type without its failure sibling, one when the endpoint has no notification contacts registered, and one on REGISTER only when the endpoint declares no accepted event types at all and can therefore never receive anything.

            That last one is register only by design. On an edit, an omitted `acceptedEventTypes` means "leave the declared set alone" rather than "accept nothing", so an edit cannot tell from your request whether the stored set is empty and does not guess.
          items:
            type: string
      description: |-
        One webhook endpoint, in the shape that CARRIES NO SECRET. This is the body of EVERY endpoint operation in this API: register, GET, listing entries, and edit (which is also how you set a secret or enable and disable the endpoint).

        There is NO `signingSecret` property on this schema, and no other response schema in this document has one either. A signing secret travels from you to us and never back, so no response type here is capable of carrying one.
    WebhookEndpointListResponse:
      required:
        - count
        - limit
        - results
      type: object
      properties:
        limit:
          type: integer
          description: The page size actually applied, after a request that was absent or outside the valid range was replaced with the default of 100. Always echoed; compare it against what you sent.
          format: int32
          example: 100
        marker:
          type: string
          description: 'The cursor you supplied, echoed back verbatim: literally the string you sent, not a parse of it rendered afresh. Absent when none was supplied.'
          example: '4700'
        count:
          type: integer
          description: How many endpoints this page carries. Always present, including as 0.
          format: int32
          example: 1
        nextMarker:
          type: string
          description: |-
            An opaque resume position, present whenever more endpoints may exist to see. Pass it as your next request's `marker`.

            On a full page it is the last endpoint in `results`. After a bounded ACL scan it is instead the position the scan reached, which need not appear in `results` at all and may not correspond to any endpoint you can see. Treat it as opaque in both cases and pass it back unmodified.
          example: '4711'
        results:
          type: array
          description: The endpoints, always the shape that CARRIES NO SECRET. Always emitted, including as `[]` on an empty page, so you can loop over it unconditionally.
          items:
            $ref: '#/components/schemas/WebhookEndpointResponse'
      description: |-
        The endpoint listing envelope.

        Entries are ordered ascending by endpoint `id`, and the listing carries only the endpoints you are permitted to see.

        WHICH KEYS THIS TYPE EMITS IS CONDITIONAL, and absent and empty are different to a consumer:

        * `limit` and `count` are always present, `count` including at 0.

        * `marker` is echoed ONLY when you actually supplied it, so you can
          confirm the cursor you asked for was applied rather than silently
          ignored. An unsupplied value is an ABSENT key, never a null one.


        * `nextMarker` is present whenever more endpoints may exist to see.
          Each page is filtered down to the endpoints you may see, and the
          server bounds the work it does for one request, so this page can be
          short, or even empty, and still carry `nextMarker`. Its absence,
          not a short or empty `results`, is what means the listing ended.


        * `results` is always present, including as `[]` on an empty page.
    WebhookDeliveryResponse:
      required:
        - lastFailureReason
        - lastResponseStatus
        - simulated
      type: object
      properties:
        id:
          type: integer
          description: The delivery's id. This is also the value the listing advertises as `nextMarker`.
          format: int64
          example: 80512
        eventId:
          type: string
          description: |-
            The stable id of the underlying business event, shared by every delivery of it, including replays. Formatted as `msg_` followed by a UUID.

            Delivery happens at least once, so the same event genuinely can arrive more than once. Deduplicate on this value.
          example: msg_9f1c2e4a-6b73-3d81-a0c5-4e2f7b9d1350
        topic:
          description: The published topic this delivery carries.
          allOf:
            - $ref: '#/components/schemas/WebhookTopic'
        url:
          type: string
          description: |-
            The URL this delivery was actually sent to, snapshotted at the time the delivery was created.

            This is the snapshot, not the endpoint's current URL. Editing the URL between retries does not redirect deliveries already under way, and this value reports where the attempt actually went.
          example: https://hooks.example.com/pps/orders
        status:
          description: The delivery's lifecycle state.
          allOf:
            - $ref: '#/components/schemas/WebhookDeliveryStatus'
        simulated:
          type: boolean
          description: |-
            Whether this delivery came from the event simulation operation rather than from a business event. Always emitted.

            A simulated delivery is real in every other respect: it is signed the same way, retried on the same schedule, counted against the circuit breaker, and replayable. This flag is the only thing that distinguishes it, so use it if you want to keep simulated traffic out of a report or a reconciliation.
          example: false
          default: false
        nextAttemptDateTime:
          type: string
          description: When the next retry is due, as an RFC 3339 UTC instant with a trailing `Z`. ABSENT (not null) when no retry is scheduled. It is an absolute instant rather than a time local to the server, since your own scheduler may act on it.
          format: date-time
          nullable: true
          example: '2026-08-28T15:04:11Z'
        deliveredDateTime:
          type: string
          description: When the delivery succeeded, as an RFC 3339 UTC instant with a trailing `Z`. ABSENT (not null) while the delivery is undelivered.
          format: date-time
          nullable: true
          example: '2026-08-28T14:26:03Z'
        lastResponseStatus:
          type: integer
          description: The HTTP status your server returned on the last attempt. Emitted as an explicit `null`, not omitted, before any attempt has been made, and also when the attempt failed without ever receiving an HTTP response (a connection timeout, for instance).
          format: int32
          nullable: true
          example: 500
        lastFailureReason:
          type: string
          description: Why the last attempt failed. Emitted as an explicit `null`, not omitted, on a delivery that has not failed. Text written for a person to read while diagnosing, not a stable code your program can match on.
          nullable: true
          example: HTTP 500 from destination
      description: |-
        One delivery attempt in SUMMARY form. It is the shape every entry of the delivery listing takes.

        It carries no `payload` and no `lastResponseBody`. Both can be large. Fetch a single delivery for either.

        NOTE ON NULLS. Several properties below are emitted with an explicit `null` rather than being left out when they have no value. This is not uniform across the schema, and each property states which it does. The difference between an explicit null and an absent key is part of the contract, so do not treat the two as interchangeable.
    WebhookDeliveryDetailResponse:
      description: |-
        One delivery in FULL: everything the summary carries, plus the two large fields exposed here and nowhere else. This is the shape the GET for one delivery returns, and the response to a single replay.

        These two properties live only on this shape. A listing never carries them.
      allOf:
        - $ref: '#/components/schemas/WebhookDeliveryResponse'
        - required:
            - lastResponseBody
          type: object
          properties:
            payload:
              type: object
              description: |-
                The signed envelope actually sent to your server, embedded as a JSON object rather than as a string of JSON, so you do not have to parse it a second time. ABSENT, never null, when this delivery has no retained payload.

                The envelope carries `type` (the topic), `eventId`, `timestamp` (an RFC 3339 UTC instant), and a `data` object.

                `data` identifies the event with TWO ids that are always present, `workflowId` and `runId`; a finding event adds `findingId`, and an attachment event adds `attachmentId` as well. Each id carries a stable prefix so you can tell them apart in your own logs.

                `workflowId` is the workflow — the unit of work you ordered, and the id to key your integration on. It is prefixed `wfpop_`, e.g. `wfpop_8f14e45f-ceea-467a-9c1a-2b6d3e7f4a91`.

                Pass it unchanged, exactly as received, as the `workflowId` of `GET /v1/findings/workflows/{workflowId}` and the routes beneath it.

                `runId` is the individual execution that changed: usually the workflow itself, in which case the two ids name the same package, and a distinct value when a child run such as a reconfirm moved. It is prefixed `wfrunpop_`, e.g. `wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92`. `runId` is always present, and today it is descriptive only — no endpoint takes one.

                On a `finding.*` event, and on the `workflow.updated` that accompanies it, `runId` is the run the finding BELONGS TO. That can be an earlier run of the workflow than its latest one.

                `findingId` is carried on `finding.*` events and is ABSENT on every `workflow.*` event. It names the finding that changed, in the same `fndg_`-prefixed form the findings API publishes, e.g. `fndg_3a9d6c1e-47b2-4f08-9e5d-2c7b1f0a8e64`. Pass it unchanged, prefix included, as the `findingId` of `GET /v1/findings/workflows/{workflowId}/findings/{findingId}` and the routes beneath it.

                `attachmentId` is carried ONLY on `finding.attachment_updated` and `finding.attachment_removed`, and is ABSENT on every other event — including the other finding topics and the `workflow.updated` that accompanies an attachment event. It names the attachment that was added, changed or removed, in the same `jattmt_`-prefixed form the findings attachments API publishes as `attachmentId`, e.g. `jattmt_5d7e2b10-9c4f-4a83-b6e1-0f2a8c3d7e59`. Pass it unchanged, prefix included, as the `attachmentId` of the attachment routes beneath `/v1/findings/workflows/{workflowId}/findings/{findingId}`. After `finding.attachment_removed` the attachment is gone, so those routes answer not-found for it.

                Every one of these prefixes is part of the published contract and will not change, so you can rely on their shape when matching or storing these values.

                `correlationId` belongs to the RUN: it is the value YOU supplied when starting that run, so it is your own handle on one execution and it goes with `runId`, not with `workflowId`. It is the only value on the envelope that came from you, and it is present only when you supplied one.

                The envelope deliberately carries NO loan-file context — no loan reference, no borrower details. It tells you which workflow changed and how; the loan association is yours already, through the `correlationId` you supplied and through `workflowId`, and anything further is a read against the order API.

                `data` also holds `serviceName` and `modelName` (the product, matching the order API path) and `status` (the run's status in lowercase, e.g. `ready`). On a `finding.*` event, and on the `workflow.updated` that accompanies it, `status` is whatever status the run holds at that moment, for example `pending`, `ready` or `complete`; it does not describe the finding.

                Repeats are normal and are not duplicates: `workflow.updated` may fire several times for one `workflowId`, and more than once for a single `runId`, each time genuinely new data becomes available, including once for every `finding.*` event. Each is a distinct event with its own `webhook-id`, so a conforming consumer — one deduplicating on `webhook-id` as the integration guide describes — processes each of them.

                Fields are added to `data` over time and absent values are OMITTED rather than sent as null, so parse it leniently: ignore keys you do not recognize and treat any of them as optional. A delivery replayed from before a field existed will not carry it, because a replay sends the stored bytes again verbatim.
              example:
                type: finding.reviewed
                eventId: msg_6e2b9f47-1c83-4a5d-b0e6-8d4f2a7c9153
                timestamp: '2026-08-28T16:02:47Z'
                data:
                  workflowId: wfpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92
                  runId: wfrunpop_7b3f1c9e-2a84-4d16-9f05-c3e8a1b47d92
                  findingId: fndg_3a9d6c1e-47b2-4f08-9e5d-2c7b1f0a8e64
                  correlationId: 9c2e14a7-55d0-4b8e-a731-6f0d2b93e185
                  serviceName: ADV
                  modelName: ADV-120
                  status: complete
            lastResponseBody:
              type: string
              description: |-
                The response body your server returned on the last attempt. Emitted as an explicit `null`, not omitted, for a delivery not yet attempted or whose server returned no body.

                Captured from your own server, so treat it as untrusted text: it may contain arbitrary content and may be truncated.
              nullable: true
    WebhookDeliveryListResponse:
      required:
        - count
        - limit
        - results
      type: object
      properties:
        limit:
          type: integer
          description: The page size actually applied, after a request that was absent or outside the valid range was replaced with the default of 100. Always echoed; compare it against what you sent.
          format: int32
          example: 50
        marker:
          type: string
          description: 'The cursor you supplied, echoed back verbatim: literally the string you sent, not a parse of it rendered afresh. Absent when none was supplied.'
          example: '80320'
        status:
          description: The status filter you supplied, echoed back. Absent when none was supplied.
          allOf:
            - $ref: '#/components/schemas/WebhookDeliveryStatus'
        count:
          type: integer
          description: How many deliveries this page carries. Always present, including as 0.
          format: int32
          example: 2
        nextMarker:
          type: string
          description: |-
            The cursor for the next page, present ONLY when this page was full. Pass it as your next request's `marker`.

            It is the id of the LAST DELIVERY ACTUALLY PRESENT in `results`. Treat it as opaque and pass it back unmodified.
          example: '80514'
        results:
          type: array
          description: The page of delivery SUMMARIES, never the detail shape, so a listing never carries a payload. Always emitted, including as `[]`.
          items:
            $ref: '#/components/schemas/WebhookDeliveryResponse'
      description: |-
        The delivery listing envelope.

        Entries are ordered OLDEST FIRST, ascending by delivery `id`.

        WHICH KEYS THIS TYPE EMITS IS CONDITIONAL, and absent and empty are different to a consumer:

        * `limit` and `count` are always present, `count` including at 0.
        * `marker` and `status` are echoed ONLY when you actually supplied them,
          so you can confirm the cursor and filter you asked for were applied
          rather than silently ignored. An unsupplied value is an ABSENT key,
          never a null one.

        * `nextMarker` is present only when this page was FULL. A page that is
          not full is the last one.

        * `results` is always present, including as `[]` on an empty page, which
          matches the endpoint listing so you can loop unconditionally over
          either.
    WebhookBulkReplayRequest:
      required:
        - since
      type: object
      properties:
        since:
          type: string
          description: |-
            REQUIRED. The start of the replay window, as an RFC 3339 date-time. INCLUSIVE: a delivery created at exactly this instant is replayed.

            Only an absolute instant is accepted. A relative window such as "the last 30 days" is NOT accepted. Because you compute the instant, the same request replays the same set of deliveries however long after the incident you send it.

            A `since` older than the delivery retention window reaches only the rows that still exist. See the operation description.
          format: date-time
          example: '2026-08-01T00:00:00Z'
        until:
          type: string
          description: |-
            Optional. The end of the replay window, as an RFC 3339 date-time. EXCLUSIVE: a delivery created at exactly this instant is NOT replayed, so consecutive windows can share a boundary without replaying anything twice.

            Omit it, or send an explicit `null`, to replay everything from `since` up to now.
          format: date-time
          nullable: true
          example: '2026-08-28T00:00:00Z'
        status:
          description: |-
            Restricts the replay to one delivery status. Omit to select the two stalled states, `FAILED` plus `PARKED`.

            Name `DELIVERED` to re-send events your own service lost. See the operation description.
          allOf:
            - $ref: '#/components/schemas/WebhookDeliveryStatus'
      description: |-
        The request body of a bulk replay.

        THE WINDOW IS THE BOUND ON HOW MUCH ONE REPLAY QUEUES. `since` is required and `until` is optional, and there is no count ceiling on top of them: narrow the window if you want to queue fewer deliveries. The optional `status` narrows the selection further.

        The window is over each delivery's CREATION time, which is when the event occurred, not when it was last attempted. A delivery created inside the window is selected however many times it has since been retried.
    WebhookBulkReplayTaskResponse:
      required:
        - endpointId
        - replayed
        - since
        - status
        - taskId
      type: object
      properties:
        taskId:
          type: string
          description: The task's handle, formatted as `wrt_` followed by an identifier. Pass it to the status operation. Treat it as opaque.
          example: wrt_60184
        endpointId:
          type: integer
          description: The endpoint whose deliveries this task is replaying.
          format: int64
          example: 4711
        status:
          type: string
          description: The task's state, one of `PENDING`, `RUNNING`, `COMPLETED` or `FAILED`. `PENDING` is before it starts, `RUNNING` while it is queueing deliveries, `COMPLETED` when it has finished the whole window, and `FAILED` when it stopped without covering it. Poll until it is no longer `PENDING` or `RUNNING`.
          example: RUNNING
        since:
          type: string
          description: The resolved start of the window, echoed back so you can confirm what was actually replayed. Inclusive.
          format: date-time
          example: '2026-08-01T00:00:00Z'
        until:
          type: string
          description: The resolved end of the window, echoed back. Exclusive. Null when the request named none, meaning the window ran to the moment the task started.
          format: date-time
          nullable: true
          example: '2026-08-28T00:00:00Z'
        replayed:
          type: integer
          description: |-
            How many deliveries the task has queued so far. It grows while the task is `RUNNING` and is final once the task is `COMPLETED`.

            A zero on a `COMPLETED` task means nothing matched the filter. A refusal because the circuit is open is a 409 on the replay call itself, never a task reporting a count of zero.
          format: int32
          example: 1420
        stoppedReason:
          type: string
          description: |-
            Why the task stopped before covering the whole window. Present only on a `FAILED` task, and absent on every other state.

            Text written for a person to read while diagnosing, not a stable code your program can match on. Branch on `status`, never on this string.
          example: The endpoint's circuit opened while the replay was running.
      description: |-
        The handle a bulk replay returns, and the body its companion status operation reports.

        A bulk replay is ASYNCHRONOUS: the call returns 200 with this document as soon as the task is accepted, without waiting for the deliveries to be queued. Poll `GET /v1/webhooks/deliveries/{endpointId}/replay/{taskId}` for progress.
    WebhookTestResponse:
      required:
        - eventId
        - payload
        - sent
        - statusCode
        - success
        - topic
        - url
        - warnings
      type: object
      properties:
        eventId:
          type: string
          description: |-
            The synthetic event's id, formatted as `msg_` followed by a version-4 UUID.

            STRUCTURALLY DISJOINT from production event ids, which are UUIDs derived by name from the business event. A test id can therefore never collide with a real one, and because a test makes one attempt only and is never replayed, there is nothing to deduplicate on it. Do not feed it to whatever tracks your real `webhook-id` values.
          example: msg_5c7e1a02-9d64-4f13-b8a7-6e0d3f2c8451
        topic:
          description: The topic that was synthesized, echoing your `eventType`.
          allOf:
            - $ref: '#/components/schemas/WebhookTopic'
        url:
          type: string
          description: The URL the test was actually sent to, snapshotted at send time, so a report read later still says where the attempt really went.
          example: https://hooks.example.com/pps/orders
        sent:
          type: boolean
          description: |-
            Whether an HTTP request actually LEFT US. False when the attempt was stopped before the wire, as happens when SSRF validation or the check against approved domains refuses the URL, in which case `statusCode` is null and `failureReason` says why.

            Distinct from `success` on purpose: `sent: true, success: false` means your server answered and rejected it, while `sent: false` means it was never asked.
          example: true
        success:
          type: boolean
          description: Whether a 2xx came back. THIS is the outcome of the test, not the HTTP status of the call you just made, which is 200 whenever the test ran at all.
          example: true
        statusCode:
          type: integer
          description: The HTTP status your endpoint returned. Emitted as an explicit `null`, not omitted, when no HTTP response ever arrived, which covers both a request stopped before the wire and one that failed in transport (a connection or socket timeout).
          format: int32
          nullable: true
          example: 200
        failureReason:
          type: string
          description: |-
            A short reason token for a failed test, drawn from the same vocabulary the delivery listings use, for example `HTTP_404`, `TRANSPORT_SocketTimeoutException`, or `SSRF_VALIDATION_FAILED`. Absent when the test succeeded.

            As with every reason string this API emits, it is NOT a stable vocabulary your program can match on: it is meant for your logs and your operators. Branch on `success` and `statusCode`, never on this text.
          example: HTTP_404
        failureDetail:
          type: string
          description: A longer explanation of the failure, written for a person, where one adds anything beyond `failureReason`. Absent when the test succeeded.
          example: The destination returned 404 Not Found.
        responseBody:
          type: string
          description: |-
            Your endpoint's response body, truncated to the same bound the delivery capture applies. Absent when no response arrived or the response carried no body.

            Captured from your own server, so treat it as untrusted text: it may contain arbitrary content and may be truncated.
          example: '{"received":true}'
        durationMillis:
          type: integer
          description: Elapsed real time of the HTTP attempt in milliseconds. Measures the outbound call alone, not the time PPS spent building and signing the envelope. Absent when nothing was sent.
          format: int64
          example: 187
        payload:
          type: object
          description: |-
            The exact JSON body we sent, embedded as a JSON object rather than as a string of JSON. Always emitted, including when the request never left us, because you asked for a specific envelope and this is the one that was built.

            This is the exact body, byte for byte, that the signature was computed over, so it is what you check your verification handling against. Note that the signed string joins `{webhook-id}.{webhook-timestamp}.{raw request body}`, and serializing this object again may not reproduce the original bytes. Verify against a delivery your server actually received, and use this to confirm the SHAPE.

            The envelope matches a production one except for its test markers: `data.test` is `true`, which never appears on a production envelope, and `workflowId` and `runId` are random and resolve to nothing.
        warnings:
          type: array
          description: |-
            Advisories that block nothing, about conditions that did NOT stop the test: the endpoint being disabled, or its circuit having been open when the test ran.

            A missing subscription grant was once a third advisory here. It is now a 400 refusal, so a successful report never carries it and a client must not wait for it to detect an ungranted endpoint.

            They are English sentences for you or your operators, not codes your program can match on; do not match on their text. Always emitted, as `[]` when there is nothing to say, unlike the endpoint schema's `warnings`, where absence is itself meaningful.

            Each one describes something that would have stopped a REAL delivery. A test that succeeds while `warnings` is not empty is telling you the endpoint works but is not yet receiving.
          items:
            type: string
      description: |-
        The report from one test delivery: what was sent, whether it left us, and what came back.

        A 200 carrying this body means the TEST RAN. Your endpoint's failure is described in these properties, never by the HTTP status of the test call itself, so read `success` rather than the status line.

        NOTE ON NULLS. `statusCode` is emitted as an explicit `null` rather than being left out when no HTTP response arrived. The remaining optional properties are ABSENT when they have nothing to report. The difference between an explicit null and an absent key is part of the contract here, as it is on the delivery schemas.
    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.
  responses:
    BadRequest:
      description: |-
        The request body was malformed, carried a value of the wrong type, or carried a value outside this API's vocabulary (an unknown topic, an unrecognized delivery status, an implausible contact address, a marker that is not a delivery id). Unknown KEYS are not an error. They are dropped, so that your client keeps working against a later version.

        The TEST operation adds two conditions that are about the endpoint rather than the syntax of your request: the selector naming a product and event type this endpoint has not declared, and no live subscription grant routing that product and event type to your organization. They share this status, and this wording, so that the response does not reveal which of the two halves of consent is the missing one. Read `detail` to tell them apart; only the first is one you can fix yourself.

        This response carries `about:blank`: the status alone describes the problem, and `detail` names the field at fault. Two exceptions carry a dedicated type: a Content-Type naming a character set this service cannot use carries `https://pointservices.com/problems/invalid-request`, and a digest mismatch on a signed request carries IANA's registered type `https://iana.org/assignments/http-problem-types#digest-mismatched-values`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Bad Request
            status: 400
            detail: '`acceptedEventTypes[0].eventTypes[1]` carries `workflow.done`, which is not a published topic.'
            instance: urn:pps:request:1e5f9a24-3b70-4c82-a6d1-9f4b7e0c3852
    NotFound:
      description: |-
        The resource you named is not available to this request. 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 id you sent and the tenancy of the credential you sent it with.

        This response carries the problem type `https://pointservices.com/problems/not-found`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/not-found
            title: No such resource
            status: 404
            detail: No endpoint 4711 is available to this request.
            instance: urn:pps:request:8d3a1f56-6c94-4e20-b7f8-0a5e9c2d4b73
    Conflict:
      description: |-
        The request is formed correctly, but the resource's CURRENT STATE refuses it. Two conditions produce this status, each on one operation:

        * TEST. The endpoint's destination domain is no longer authorized, so
          nothing can be sent to it. Authorize the domain again, or edit the
          endpoint to a URL on an authorized one.

        * BULK REPLAY. The endpoint's circuit is currently OPEN. The replay is
          refused outright rather than accepted. Retry once the circuit closes.


        Note that the replay of a SINGLE delivery never returns this status. It returns 200 and silently does nothing instead. See that operation's description.

        Supplying a `signingSecret` alone to an `ASYMMETRIC` endpoint was once a third condition here, on a set-secret operation that has since been deleted. Editing the endpoint answers that case with a 400 now, not a 409, so a client branching on 409 for a failed secret rotation will never reach it. See `editWebhookEndpoint`.

        This response carries the problem type `https://pointservices.com/problems/conflict`, which distinguishes it from the 409 for concurrent modification (the shared `ConcurrentModification` response in problems-v1.yaml). The remedies differ: a concurrent modification means another write landed first, so re-read the endpoint and resubmit if your change still applies, while both conditions above need the endpoint's state to change first. Branch on `type`, never on the text of `detail`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/conflict
            title: The resource's current state refuses this request
            status: 409
            detail: Endpoint 4711 has an OPEN circuit, so a bulk replay is refused until it closes.
            instance: urn:pps:request:5c2b9e47-0a38-4f15-9d6e-2b7c4a1f8035
    TooManyRequests:
      description: |-
        The endpoint's delivery rate budget for the current window is spent. Try again in about a second.

        The budget is SHARED with that endpoint's real deliveries rather than being a separate allowance for testing, so a test can never starve the production traffic for the endpoint it is meant to be validating.

        This response carries the problem type `https://pointservices.com/problems/too-many-requests`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/too-many-requests
            title: The delivery rate budget is spent
            status: 429
            detail: This endpoint's per second delivery budget for the current window is spent. The budget is shared with its real deliveries. Try again in about a second.
            instance: urn:pps:request:7e19c3b8-4d06-4a2f-b95c-1c8e6f30d472
    ServiceUnavailable:
      description: |-
        A fault on the PPS side, never a statement about your endpoint. Most concretely, our signer could not produce a signature.

        Your endpoint was NOT contacted, so nothing was delivered in part and the request is safe to retry as it stands.

        This status has no dedicated problem type, so it carries `about:blank`, which is RFC 9457's way of saying the status code alone describes the problem.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Service Unavailable
            status: 503
            detail: PPS could not produce a signature for this delivery, so nothing was sent to your endpoint. Retry the request.
            instance: urn:pps:request:0b4d7f31-9a52-4c68-8e10-3f7b2d5c9a84
    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
    ConcurrentModification:
      description: |
        Another writer changed the resource while your write was in flight, so NOTHING was applied. The problem type is `https://pointservices.com/problems/concurrent-modification`.
        It is not retried for you. Read the resource again, decide whether your change still applies to what you now see, and resubmit if it does.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://pointservices.com/problems/concurrent-modification
            title: Concurrent modification
            status: 409
            detail: The resource was modified by another request while this one was being processed. This request was not applied; re-read the resource before resubmitting.
            instance: urn:pps:request:3a6f80d2-1e74-4b93-a5c0-8d2b7e4f16a9
  parameters:
    EndpointId:
      name: id
      in: path
      description: The endpoint's id.
      required: true
      style: simple
      explode: false
      schema:
        type: integer
        format: int64
      example: 4711
    EndpointListMarker:
      name: marker
      in: query
      description: |-
        The pagination cursor: pass back the `nextMarker` from a previous response. It is EXCLUSIVE, so the endpoint it names is not sent again, and paging proceeds toward newer endpoints.

        A string on the wire. It must parse as a positive integer; a value that will not is rejected with a 400. Treat it as opaque and pass it back unmodified.
      required: false
      style: form
      explode: true
      schema:
        type: string
      example: '4711'
    EndpointListLimit:
      name: limit
      in: query
      description: |-
        The requested page size. Valid range is 1 to 100; the default is 100 when omitted.

        A value outside that range is neither an error nor clamped to the nearest bound. It is replaced outright by the default of 100, which is also the maximum, so `limit=0` and `limit=5000` both yield 100. Check the range yourself before sending a computed value, and compare the response's own `limit` against what you asked for.
      required: false
      style: form
      explode: true
      schema:
        type: integer
        format: int32
      example: 50
    DeliveryEndpointId:
      name: endpointId
      in: path
      description: The owning endpoint's id. Deliveries are always addressed through their endpoint, because that is where ownership and authorization live.
      required: true
      style: simple
      explode: false
      schema:
        type: integer
        format: int64
      example: 4711
    DeliveryId:
      name: deliveryId
      in: path
      description: The delivery's id.
      required: true
      style: simple
      explode: false
      schema:
        type: integer
        format: int64
      example: 80512
    ReplayTaskId:
      name: taskId
      in: path
      description: The handle a bulk replay returns, identifying the asynchronous replay task. Treat it as opaque and pass it back unmodified.
      required: true
      style: simple
      explode: false
      schema:
        type: string
      example: wrt_60184
  securitySchemes:
    BearerAuth:
      type: http
      description: |-
        A bearer token in the standard `Authorization` header, written as `Authorization: Bearer <token>`.

        This is the mechanism this API supports, and the only one you should send. The scheme name is matched without regard to case, but the header must carry the scheme and the token separated by a single space; a bare token with no `Bearer ` prefix is rejected.

        Contact PPS to have a token issued for your integration; there is no endpoint that mints one for you on demand. Send it over HTTPS only, and never place it in a URL or query string.
      scheme: bearer
      bearerFormat: JWT
