openapi: 3.1.0
info:
  title: Somo Content Service API
  version: 1.0.0
  summary: Submit articles for editorial review and publication on Somo properties.
  description: |
    The Somo Content Service (SCS) API is how Somo media partners submit articles. Every article is reviewed by a
    human editor before anything is published.

    **Contract rules**
    - This is `v1`. Fields are only ever added, never changed or removed. Ignore fields you do not recognize.
    - Requests and responses are JSON (`application/json`, UTF-8), except `POST /oauth/token`, which takes
      `application/x-www-form-urlencoded` (RFC 6749).
    - Unknown request fields are refused (`validation-failed`), so a misspelled field is an error.
    - Times are RFC 3339 in UTC. Ids are UUIDs. String formats (`uri`, `uuid`, `date`, `date-time`) are validated,
      not just documented.
    - Text fields are plain text: `<` and `>` are refused. Before validation, CRLF and CR become LF and every text
      field is NFC-normalized. Control and format characters (Unicode `Cc`/`Cf`) are refused, with these
      exceptions: tab and newline in `body`; U+200C, U+200D and U+FE0F (emoji sequences and joiners) in `body` and
      in plain-text fields. Bidi controls (U+202A–U+202E, U+2066–U+2069) are always refused. Only `body` carries
      markup.
    - A path id that is not a UUID answers `404 not-found`, exactly like an unknown id or another client's id.
    - **Tolerate unknown values.** Responses and webhooks may gain new enum values (statuses, event types, reasons,
      states) and new fields. Treat an unknown value as "something else", never as an error.
    - Every response carries `X-Request-Id`; quote it when you contact us.

    **Onboarding.** There is no registration or sign-up endpoint. Credentials are issued by Somo after
    onboarding through the [Somo Media Partner program](https://somo.financial/media-partners): you send us your
    public key, we register it and give you a `client_id`.

    **Idempotency.** Successes and schema-level validation answers are stored per `Idempotency-Key` and replayed.
    Everything else (including validation that depends on a property's configuration) is worked out again. After
    fixing a refused request, send it with a **new** key: the old key with a changed body is
    `409 idempotency-key-reused`.

    **Authentication.** OAuth 2.0 client credentials with `private_key_jwt` client authentication (RFC 7523).
    Exchange a signed assertion at `POST /oauth/token` for a 15-minute bearer token. An API key is available only
    by exception and is sent as the bearer token directly.

    **Scopes.** `submissions:write`, `submissions:read:own`, `assets:write`, `webhooks:manage`, plus one
    `submit:property:<property id>` per property you may submit to (for example `submit:property:example-property`). Property
    scopes are not listed in the security requirements below because their names depend on the property.

    **Errors** are RFC 9457 problem details (`application/problem+json`) with a stable `type` URI. The `type` is
    what to branch on; `title` and `detail` are for people and may change. Known types (all under
    `https://api.content.somo.financial/problems/`, the same in every environment):

    | type | status | meaning |
    |---|---|---|
    | `invalid-request` | 400 | malformed JSON, bad header or query parameter |
    | `idempotency-key-required` | 400 | `Idempotency-Key` missing or malformed |
    | `invalid-token` | 401 | missing, expired or revoked token |
    | `insufficient-scope` | 403 | the token lacks a scope this call needs |
    | `property-not-allowed` | 403 | the property is unknown, or you may not submit to it (one answer for both) |
    | `not-found` | 404 | no such resource, or it is not yours (one answer for both, on reads and writes) |
    | `invalid-state` | 409 | the submission's status does not allow this action |
    | `publishing-in-progress` | 409 | `POST /v1/submissions/{id}/withdraw` only: a publication of this submission is under way or waiting on an editor; retry after `Retry-After` |
    | `idempotency-key-reused` | 409 | same key, different request |
    | `secret-rotation-in-progress` | 409 | a webhook secret rotation is still in its 24-hour overlap |
    | `duplicate-external-ref` | 409 | `externalRef` already used on an open submission |
    | `payload-too-large` | 413 | request body over 256 KB |
    | `unsupported-media-type` | 415 | wrong `Content-Type` |
    | `validation-failed` | 422 | the body breaks the schema or a content rule (for example a non-https link, or HTML with CDATA, a declaration, a processing instruction or a comment containing markup); see `errors` |
    | `asset-not-usable` | 422 | a referenced asset is missing or not yours (`reason: not_found`, one answer for both), or yours but `rejected` / `expired` |
    | `ymyl-not-allowed` | 422 | your agreement does not cover YMYL topics |
    | `property-paused` | 422 | the property is not accepting submissions |
    | `rate-limited` | 429 | too many requests; see `Retry-After` |
    | `quota-exceeded` | 429 | your submission quota for this property is used up; see `Retry-After` |
    | `internal-error` | 500 | our fault; safe to retry with the same `Idempotency-Key` |
    | `service-unavailable` | 503 | the request couldn't be completed right now (for example, an earlier request with the same `Idempotency-Key` is still running); retry after `Retry-After` |

    **Webhooks** are listed under `webhooks`. Each delivery is signed:
    `SCS-Signature: t=<unix seconds>,v1=<lowercase hex HMAC-SHA256 of "<t>.<raw body>">`. The HMAC key is the UTF-8
    bytes of your whole `signingSecret` string, prefix included. Reject a `t` more than 5 minutes from your clock.
  contact:
    name: Somo Media Partner program
    url: https://somo.financial/media-partners
servers:
  - url: https://api.content.somo.financial
    description: Production
  - url: https://api.staging.content.somo.financial
    description: Staging (available first; no real publishing)
tags:
  - name: Auth
    description: Exchange a signed client assertion for an access token.
  - name: Submissions
    description: Submit articles, read their status, answer change requests, withdraw.
  - name: Assets
    description: Upload images through a presigned URL; every image is scanned and re-encoded.
  - name: Webhooks
    description: Register the endpoint that receives signed status events.
  - name: Properties
    description: The properties you may submit to, with their categories and tags.
  - name: Health
    description: Liveness.
security:
  - oauth2: []
  - apiKey: []
paths:
  /oauth/token:
    post:
      tags: [Auth]
      operationId: createToken
      summary: Get an access token
      description: |
        OAuth 2.0 client credentials grant with `private_key_jwt` client authentication.

        The `client_assertion` is a JWT signed with your private key, using the algorithm registered for that key
        (`RS256` or `ES256`). Its header names a registered `kid` and carries no `jku`, `jwk`, `x5u`, `x5c` or
        `crit` (refused). Its claims are:
        - `iss` and `sub`: your `client_id`;
        - `aud`: a single string, exactly this endpoint's URL (`https://api.content.somo.financial/oauth/token` in
          production, `https://api.staging.content.somo.financial/oauth/token` in staging). Arrays are refused;
        - `iat`, `exp`: `exp` no more than 300 seconds after `iat`;
        - `jti`: a unique value, never reused (a replay is refused).

        Send the assertion as a 3-part compact JWS (not a JWE), and no other client authentication: an
        `Authorization: Basic` header alongside it is refused. If you also send `client_id`, it must equal `iss`. The token lives 15 minutes (`expires_in: 900`). There is no refresh token: sign a new assertion.
        Errors follow RFC 6749 §5.2, not problem details.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
            examples:
              privateKeyJwt:
                summary: Client credentials with private_key_jwt
                value:
                  grant_type: client_credentials
                  client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
                  client_assertion: eyJhbGciOiJFUzI1NiIsImtpZCI6InBhcnRuZXIta2V5LTIwMjYtMTAiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiI3ZjNjMmE5MC0xYjRkLTRlNWYtOGE2Yi0wYzFkMmUzZjRhNWIiLCJzdWIiOiI3ZjNjMmE5MC0xYjRkLTRlNWYtOGE2Yi0wYzFkMmUzZjRhNWIiLCJhdWQiOiJodHRwczovL2FwaS5jb250ZW50LnNvbW8uZmluYW5jaWFsL29hdXRoL3Rva2VuIiwiaWF0IjoxNzYwNzk2ODAwLCJleHAiOjE3NjA3OTcxMDAsImp0aSI6IjNiOWY1YzJlLTdkNGEtNGYxYi05YzhlLTJhNmQ0ZTdmMWIzYyJ9.c2lnbmF0dXJl
                  scope: submissions:write submissions:read:own assets:write submit:property:example-property
      responses:
        '200':
          description: Token issued.
          headers:
            Cache-Control:
              description: Always `no-store`.
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
              examples:
                issued:
                  value:
                    access_token: scs_at_q8Yp2mZr7KcJx4LwVn3TfB6sHd1GaE9uQo5RiXy0PkM
                    token_type: Bearer
                    expires_in: 900
                    scope: submissions:write submissions:read:own assets:write submit:property:example-property
        '400':
          description: Bad request (`invalid_request`, `invalid_grant`, `invalid_scope`, `unsupported_grant_type`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
              examples:
                invalidScope:
                  value:
                    error: invalid_scope
                    error_description: The credential does not hold scope submit:property:example-property.
        '401':
          description: Client authentication failed (`invalid_client`). For security, the response does not say which check failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
              examples:
                invalidClient:
                  value:
                    error: invalid_client
        '403':
          description: The request did not reach us through the documented endpoint; send it to `https://api.content.somo.financial/oauth/token` (or the staging token URL). Returned as problem details, not an OAuth error.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Problem'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/submissions:
    post:
      tags: [Submissions]
      operationId: createSubmission
      summary: Submit an article
      description: |
        Creates a submission in status `received` with revision 1. Requires `submissions:write` and
        `submit:property:<property>`. Counts against your quota for that property.

        Images must be uploaded first (`POST /v1/assets`) and referenced by id, in `assets` and, for inline
        images, as `asset:<id>` in the body. External image URLs are refused.
      security:
        - oauth2: [submissions:write]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionCreate'
            examples:
              markdownArticle:
                $ref: '#/components/examples/SubmissionCreateMarkdown'
      responses:
        '201':
          description: Submission accepted for review.
          headers:
            Location:
              description: URL of the new submission.
              schema:
                type: string
                format: uri
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Submission'
              examples:
                received:
                  $ref: '#/components/examples/SubmissionReceived'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    get:
      tags: [Submissions]
      operationId: listSubmissions
      summary: List your submissions
      description: |
        Your own submissions, newest first. Requires `submissions:read:own`. Cursor-paginated: pass `nextCursor`
        from one page as `cursor` to get the next; `nextCursor` is `null` on the last page.
      security:
        - oauth2: [submissions:read:own]
        - apiKey: []
      parameters:
        - name: status
          in: query
          description: Only submissions in these statuses (comma-separated).
          required: false
          style: form
          explode: false
          schema:
            type: array
            items:
              $ref: '#/components/schemas/SubmissionStatus'
            maxItems: 9
          example: [changes_requested, approved]
        - name: property
          in: query
          description: Only submissions to this property.
          required: false
          schema:
            $ref: '#/components/schemas/PropertyId'
        - name: updatedSince
          in: query
          description: Only submissions updated at or after this time (status change, revision, publication, or a correction request's status). Use it to sync.
          required: false
          schema:
            type: string
            format: date-time
          example: '2026-10-19T00:00:00Z'
        - name: externalRef
          in: query
          description: Submissions with this `externalRef` (a list; usually one, plus any older rejected or withdrawn ones).
          required: false
          schema:
            type: string
            pattern: '^[A-Za-z0-9._:/-]{1,128}$'
        - name: cursor
          in: query
          description: Opaque cursor from the previous page's `nextCursor`.
          required: false
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          description: Page size.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: A page of submissions.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubmissionList'
              examples:
                page:
                  value:
                    data:
                      - id: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                        externalRef: ptr-2026-10-0042
                        property: example-property
                        status: changes_requested
                        title: How a PCS move affects your state income taxes
                        currentRevision: 1
                        createdAt: '2026-10-18T15:04:05Z'
                        updatedAt: '2026-10-19T10:12:00Z'
                        statusChangedAt: '2026-10-19T10:12:00Z'
                    nextCursor: eyJjIjoiMjAyNi0xMC0xOFQxNTowNDowNVoiLCJpIjoiMDE5MjBjNGUifQ
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/submissions/{id}:
    parameters:
      - $ref: '#/components/parameters/SubmissionId'
    get:
      tags: [Submissions]
      operationId: getSubmission
      summary: Read one of your submissions
      description: |
        Status, the latest editor feedback addressed to you, the schedule, the published URL and the content of
        your latest revision. Requires `submissions:read:own`. Another client's submission is `404`.
      security:
        - oauth2: [submissions:read:own]
        - apiKey: []
      responses:
        '200':
          description: The submission.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Submission'
              examples:
                changesRequested:
                  $ref: '#/components/examples/SubmissionChangesRequested'
                published:
                  $ref: '#/components/examples/SubmissionPublished'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/submissions/{id}/revisions:
    parameters:
      - $ref: '#/components/parameters/SubmissionId'
    post:
      tags: [Submissions]
      operationId: createRevision
      summary: Send a revised version after changes were requested
      description: |
        Allowed only while the submission is `changes_requested`; anything else is `409 invalid-state`. The
        revision replaces the whole article (send every field, not a patch) and moves the submission back to
        `in_review`. Requires `submissions:write`, and the same property checks as a create (scope, binding,
        paused, YMYL). Another client's submission is `404`. Does not count against your quota.
      security:
        - oauth2: [submissions:write]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RevisionCreate'
            examples:
              revised:
                $ref: '#/components/examples/RevisionCreateMarkdown'
      responses:
        '201':
          description: Revision accepted; the submission is back in review.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Submission'
        '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'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/submissions/{id}/withdraw:
    parameters:
      - $ref: '#/components/parameters/SubmissionId'
    post:
      tags: [Submissions]
      operationId: withdrawSubmission
      summary: Withdraw a submission
      description: |
        Allowed from `received`, `in_review`, `changes_requested`, `approved` and `scheduled`, as long as
        publishing has not started; otherwise `409 publishing-in-progress` (see that response). Once publishing has
        started, we complete it, or confirm it did not happen, before any other change takes effect. An absent or empty body is the same as `{}` for idempotency. Withdrawing an already withdrawn submission returns it unchanged (`200`).
        After publication, file a correction request instead. Requires `submissions:write`. Another client's
        submission is `404`.
      security:
        - oauth2: [submissions:write]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyOptional'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WithdrawRequest'
            examples:
              withReason:
                value:
                  reason: The figures in section 2 are out of date; we will resubmit next month.
      responses:
        '200':
          description: Withdrawn.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Submission'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/WithdrawConflict'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/submissions/{id}/published:
    parameters:
      - $ref: '#/components/parameters/SubmissionId'
    get:
      tags: [Submissions]
      operationId: getPublishedContent
      summary: Read the final published text
      description: |
        The article as published, including any edits our editors made, and where it is live. Available once the
        submission has been published at least once; before that `409 invalid-state`. Requires
        `submissions:read:own`. Another client's submission is `404`.
      security:
        - oauth2: [submissions:read:own]
        - apiKey: []
      responses:
        '200':
          description: The published text.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublishedContent'
              examples:
                published:
                  value:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    revisionPublished: 2
                    title: How a PCS move affects your state income taxes
                    excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
                    html: '<h2>Your state of legal residence usually stays the same</h2><p>Under the <a href="https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra" rel="noopener">Servicemembers Civil Relief Act</a>, moving on military orders does not by itself change your state of legal residence for income tax.</p>'
                    seo:
                      metaTitle: PCS moves and state income tax, explained
                      metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
                      slug: pcs-move-state-income-taxes
                    byline:
                      name: Alex Example
                      reviewedBy: Example Reviewer
                    publications:
                      - property: example-property
                        url: https://www.example.com/article/pcs-move-state-income-taxes
                        publishedAt: '2026-10-21T13:00:10Z'
                        state: live
        '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'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/submissions/{id}/correction-requests:
    parameters:
      - $ref: '#/components/parameters/SubmissionId'
    post:
      tags: [Submissions]
      operationId: createCorrectionRequest
      summary: Ask for a correction or takedown of a published article
      description: |
        Allowed only while the submission is `published` and has no other `open` request of the same `kind`
        (otherwise `409 invalid-state`); a takedown request can be filed while a correction request is open. The request is recorded and queued to our editors, who handle it under your
        agreement's SLA. A `proposedRevision` is validated and sanitized exactly like a revision (`422` on
        failure), then kept as a suggestion for the editor, never applied as is. A correction an editor applies is
        updated in place (same URL) and reported as `submission.updated`. A `takedown` request works even if your
        access to the property has since been revoked; a `correction` request with a `proposedRevision` needs your
        current access to the property, like a revision. Follow its `status` in `GET /v1/submissions/{id}`
        (`correctionRequests`). Requires `submissions:write`. Another client's submission is `404`.
      security:
        - oauth2: [submissions:write]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CorrectionRequestCreate'
            examples:
              correction:
                value:
                  kind: correction
                  reason: The state filing deadline in section 3 changed for the 2026 tax year.
                  details: The state revenue department moved the deadline to May 1; the article still says April 15.
      responses:
        '201':
          description: Recorded and queued to the editors.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CorrectionRequest'
              examples:
                open:
                  value:
                    id: 01921500-1234-7abc-8def-0123456789ab
                    kind: correction
                    status: open
                    reason: The state filing deadline in section 3 changed for the 2026 tax year.
                    details: The state revenue department moved the deadline to May 1; the article still says April 15.
                    editorNote: null
                    createdAt: '2026-11-02T09:00:00Z'
                    updatedAt: '2026-11-02T09:00:00Z'
        '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'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/assets:
    post:
      tags: [Assets]
      operationId: createAsset
      summary: Register an image and get a presigned upload URL
      description: |
        Declare the file (type, exact size, SHA-256) and its license; the response holds a presigned `PUT` URL
        valid for 15 minutes. Upload the exact bytes with exactly the headers returned in `upload.headers`. The
        asset then moves `pending_upload` → `scanning` → `processing` → `clean` (or `rejected`). Poll
        `GET /v1/assets/{id}`. An asset can be referenced by a submission before it is `clean`, but the
        submission cannot be approved until every asset is. Requires `assets:write`.

        Retrying with the same `Idempotency-Key` returns the asset with a freshly signed `upload` while it is still
        `pending_upload`, and `upload: null` once it has moved on.

        Accepted types: JPEG, PNG, WebP, AVIF; at most 10 MB and 40 megapixels; one frame (no animation). Every image is malware-scanned,
        checked by its bytes (not its name), re-encoded and stripped of metadata. Originals are never published.
      security:
        - oauth2: [assets:write]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssetCreate'
            examples:
              heroImage:
                value:
                  filename: pcs-moving-truck.jpg
                  contentType: image/jpeg
                  sizeBytes: 2483112
                  sha256: 9f2b5c0e4a1d7e3f6b8c2a4d1e9f0b7c3a5d8e2f1c4b7a0d9e6f3c2b1a0d4e5f
                  aiGenerated: false
                  licence:
                    source: Photographer under contract to the partner
                    type: work_for_hire
                    proof: Agreement WFH-2026-031, clause 4 (assignment of copyright)
      responses:
        '201':
          description: Asset registered; upload the file to `upload.url`.
          headers:
            Idempotent-Replayed:
              $ref: '#/components/headers/Idempotent-Replayed'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetCreated'
              examples:
                created:
                  value:
                    asset:
                      id: 01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f
                      status: pending_upload
                      filename: pcs-moving-truck.jpg
                      contentType: image/jpeg
                      sizeBytes: 2483112
                      sha256: 9f2b5c0e4a1d7e3f6b8c2a4d1e9f0b7c3a5d8e2f1c4b7a0d9e6f3c2b1a0d4e5f
                      aiGenerated: false
                      licence:
                        source: Photographer under contract to the partner
                        type: work_for_hire
                        proof: Agreement WFH-2026-031, clause 4 (assignment of copyright)
                      rejectReason: null
                      width: null
                      height: null
                      createdAt: '2026-10-18T15:01:00Z'
                    upload:
                      method: PUT
                      url: https://uploads.example.com/01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f?expires=900&signature=3c1d9a7e5b2f4c6d8e0a1b3c5d7e9f20
                      headers:
                        Content-Type: image/jpeg
                        Content-Length: '2483112'
                        x-amz-checksum-sha256: nytcDkodfj9rjCpNHp8LfDpdjiHxxLcNnm88KxoNTl8=
                      expiresAt: '2026-10-18T15:16:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

    get:
      tags: [Assets]
      operationId: listAssets
      summary: List your assets
      description: Your own assets, newest first, cursor-paginated like `GET /v1/submissions`. Requires `assets:write`.
      security:
        - oauth2: [assets:write]
        - apiKey: []
      parameters:
        - name: status
          in: query
          description: Only assets in these statuses (comma-separated).
          required: false
          style: form
          explode: false
          schema:
            type: array
            items:
              $ref: '#/components/schemas/AssetStatus'
            maxItems: 6
        - name: cursor
          in: query
          description: Opaque cursor from the previous page's `nextCursor`.
          required: false
          schema:
            type: string
            maxLength: 512
        - name: limit
          in: query
          description: Page size.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: A page of assets.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssetList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/assets/{id}:
    parameters:
      - $ref: '#/components/parameters/AssetId'
    get:
      tags: [Assets]
      operationId: getAsset
      summary: Read one of your assets
      description: Status, and once `clean`, the dimensions of the processed image. Requires `assets:write`.
      security:
        - oauth2: [assets:write]
        - apiKey: []
      responses:
        '200':
          description: The asset.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Asset'
              examples:
                clean:
                  value:
                    id: 01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f
                    status: clean
                    filename: pcs-moving-truck.jpg
                    contentType: image/jpeg
                    sizeBytes: 2483112
                    sha256: 9f2b5c0e4a1d7e3f6b8c2a4d1e9f0b7c3a5d8e2f1c4b7a0d9e6f3c2b1a0d4e5f
                    aiGenerated: false
                    licence:
                      source: Photographer under contract to the partner
                      type: work_for_hire
                      proof: Agreement WFH-2026-031, clause 4 (assignment of copyright)
                    rejectReason: null
                    width: 2560
                    height: 1707
                    createdAt: '2026-10-18T15:01:00Z'
                rejected:
                  value:
                    id: 01920c4d-9f8e-7d6c-8b5a-4f3e2d1c0b9a
                    status: rejected
                    filename: chart.png
                    contentType: image/png
                    sizeBytes: 88120
                    sha256: 0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9
                    aiGenerated: false
                    licence:
                      source: Created in-house by the partner
                      type: owned
                      proof: Created by partner staff for this article
                    rejectReason: type_mismatch
                    width: null
                    height: null
                    createdAt: '2026-10-18T15:02:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: Read your webhook endpoint
      description: Your registered endpoint, or `404` if none. The signing secret is never returned here.
      security:
        - oauth2: [webhooks:manage]
        - apiKey: []
      responses:
        '200':
          description: Your endpoint.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
              examples:
                registered:
                  value:
                    url: https://hooks.partner.example/scs
                    events: [submission.status_changed, submission.received, submission.changes_requested, submission.approved, submission.published, submission.updated, submission.rejected, submission.unpublished]
                    status: active
                    secretRotatingUntil: null
                    createdAt: '2026-10-18T14:00:00Z'
                    updatedAt: '2026-10-18T14:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
    put:
      tags: [Webhooks]
      operationId: putWebhookEndpoint
      summary: Create or replace your webhook endpoint
      description: |
        One endpoint per partner. A new endpoint, or a changed URL, must pass verification first: we POST a signed
        `endpoint.verification` event and your endpoint must echo its `challenge` within 10 seconds (see
        `webhooks`). Until then it is `pending_verification`: events are held (up to 24 hours, then dropped) and
        delivered once it is verified. A new `PUT` cancels any verification still pending. **Verification requests
        are limited to 5 per partner per hour** (`429 rate-limited`). Every verification we issue counts: creating
        the endpoint, changing its URL, and re-sending the same URL while it is `pending_verification` or
        `verification_failed`. An optional `Idempotency-Key` makes the call safe to retry: a replay within 10
        minutes returns the same response, including the same new `signingSecret` while it is still current (see the
        parameter). So a lost rotation response is recoverable. HTTPS on port 443 only, a
        hostname resolving only to public addresses;
        redirects are not followed. The signing secret is returned **only** in the response that creates the
        endpoint (`201`) or rotates it (`rotateSecret: true`); store it then. During a rotation the old secret
        keeps signing alongside the new one for 24 hours; another rotation inside that window is
        `409 secret-rotation-in-progress`. The URL must be `https` on port 443 with a DNS host name: no IP
        literals, no `user@`, no fragment. Requires `webhooks:manage`.
      security:
        - oauth2: [webhooks:manage]
        - apiKey: []
      parameters:
        - $ref: '#/components/parameters/WebhookIdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointPut'
            examples:
              allEvents:
                value:
                  url: https://hooks.partner.example/scs
                  events: [submission.status_changed, submission.received, submission.changes_requested, submission.approved, submission.published, submission.updated, submission.rejected, submission.unpublished]
      responses:
        '200':
          description: Endpoint replaced. `signingSecret` is present only if `rotateSecret` was true.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointWithSecret'
        '201':
          description: Endpoint created. Store `signingSecret` now; it is not shown again, except to a retry with the same `Idempotency-Key` within 10 minutes.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointWithSecret'
              examples:
                created:
                  value:
                    url: https://hooks.partner.example/scs
                    events: [submission.status_changed, submission.received, submission.changes_requested, submission.approved, submission.published, submission.updated, submission.rejected, submission.unpublished]
                    status: pending_verification
                    secretRotatingUntil: null
                    signingSecret: scs_wh_EXAMPLE0nly0n0tAReal0Secret0000000000
                    createdAt: '2026-10-18T14:00:00Z'
                    updatedAt: '2026-10-18T14:00:00Z'
        '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'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Remove your webhook endpoint
      description: Stops all deliveries; pending events are dropped. `404` if you have none. Requires `webhooks:manage`.
      security:
        - oauth2: [webhooks:manage]
        - apiKey: []
      responses:
        '204':
          description: Removed.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/webhooks/test:
    post:
      tags: [Webhooks]
      operationId: testWebhookEndpoint
      summary: Send a test event to your endpoint
      description: |
        Queues one signed `webhook.test` event to your registered endpoint and returns its event id. Watch for it
        to confirm your signature check works. `404` if you have no endpoint; `409 invalid-state` until it is
        verified; at most 5 per hour (`429`). Requires `webhooks:manage`.
      security:
        - oauth2: [webhooks:manage]
        - apiKey: []
      responses:
        '202':
          description: Queued.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                type: object
                required: [eventId]
                properties:
                  eventId:
                    type: string
                    format: uuid
              examples:
                queued:
                  value:
                    eventId: 01921600-aaaa-7bbb-8ccc-ddddeeeeffff
        '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'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

  /v1/properties:
    get:
      tags: [Properties]
      operationId: listProperties
      summary: List the properties you may submit to
      description: |
        Only properties you hold `submit:property:<id>` for and that your agreement covers, with the categories
        and tags they accept (kept in sync with the site and refreshed hourly) and your remaining quota. Requires
        `submissions:read:own`.
      security:
        - oauth2: [submissions:read:own]
        - apiKey: []
      responses:
        '200':
          description: Your properties.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimit-Limit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimit-Remaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyList'
              examples:
                one:
                  value:
                    data:
                      - id: example-property
                        name: Example Property
                        siteUrl: https://www.example.com
                        status: active
                        ymylAllowed: true
                        languages: [en-US]
                        categories:
                          - slug: money
                            name: Money
                          - slug: pcs-and-moving
                            name: PCS & Moving
                          - slug: family-life
                            name: Family Life
                        tags:
                          - slug: taxes
                            name: Taxes
                          - slug: pcs
                            name: PCS
                          - slug: budgeting
                            name: Budgeting
                        quota:
                          limit: 10
                          windowDays: 7
                          remaining: 7
                          nextSlotAt: '2026-10-21T09:30:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'

  /healthz:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Liveness
      description: No authentication. Says only that the API is up.
      security: []
      responses:
        '200':
          description: Up.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
              examples:
                ok:
                  value:
                    status: ok
        '503':
          $ref: '#/components/responses/ServiceUnavailable'

webhooks:
  submission.status_changed:
    post:
      tags: [Webhooks]
      operationId: onSubmissionStatusChanged
      summary: The status changed
      description: 'Sent on every status change, alongside the specific event. Treat it as the authoritative record of state. Not sent on creation (there is no previous status; `submission.received` covers it) or for changes that keep the status (a reschedule sends `submission.scheduled`, a correction `submission.updated`). Correction requests are not covered: their close comes as `correction_request.closed`, and `accepted` shows only via `GET /v1/submissions/{id}` (or `updatedSince`).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionStatusChangedEvent'
            examples:
              example:
                value:
                  id: 01921233-0c0d-7e0f-8a1b-2c3d4e5f6a70
                  type: submission.status_changed
                  schemaVersion: '1'
                  occurredAt: '2026-10-21T13:00:12Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: published
                    revision: 2
                    from: approved
                    to: published
                    at: '2026-10-21T13:00:12Z'
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.received:
    post:
      tags: [Webhooks]
      operationId: onSubmissionReceived
      summary: A submission was received
      description: 'Sent after `POST /v1/submissions`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionReceivedEvent'
            examples:
              example:
                value:
                  id: 01920c4e-90ab-7cde-8f01-23456789abcd
                  type: submission.received
                  schemaVersion: '1'
                  occurredAt: '2026-10-18T15:04:05Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: received
                    revision: 1
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.revised:
    post:
      tags: [Webhooks]
      operationId: onSubmissionRevised
      summary: A revision was received
      description: 'Sent after each accepted `POST /v1/submissions/{id}/revisions`. The submission is back in review.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionRevisedEvent'
            examples:
              example:
                value:
                  id: 01920f9c-0d1e-7f20-8132-435465768798
                  type: submission.revised
                  schemaVersion: '1'
                  occurredAt: '2026-10-19T18:02:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: in_review
                    revision: 2
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.changes_requested:
    post:
      tags: [Webhooks]
      operationId: onSubmissionChangesRequested
      summary: An editor asked for changes
      description: 'Answer with `POST /v1/submissions/{id}/revisions`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionChangesRequestedEvent'
            examples:
              example:
                value:
                  id: 01920f1a-0b1c-7d2e-8f3a-4b5c6d7e8f90
                  type: submission.changes_requested
                  schemaVersion: '1'
                  occurredAt: '2026-10-19T10:12:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: changes_requested
                    revision: 1
                    feedback: >-
                      Please cite the state revenue department for the residency rules in section 2, and remove
                      the link to the tax-software comparison page or declare it as sponsored.
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.approved:
    post:
      tags: [Webhooks]
      operationId: onSubmissionApproved
      summary: The submission was approved for publication
      description: 'Publication follows, now or on a schedule (`submission.scheduled`).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionApprovedEvent'
            examples:
              example:
                value:
                  id: 01921201-aa00-7b11-8c22-3d44e55f6a77
                  type: submission.approved
                  schemaVersion: '1'
                  occurredAt: '2026-10-20T16:00:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: approved
                    revision: 2
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.scheduled:
    post:
      tags: [Webhooks]
      operationId: onSubmissionScheduled
      summary: Publication was scheduled
      description: 'Carries the planned time. It may be rescheduled; the latest event wins.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionScheduledEvent'
            examples:
              example:
                value:
                  id: 01921206-bb11-7c22-8d33-4e55f66a7b88
                  type: submission.scheduled
                  schemaVersion: '1'
                  occurredAt: '2026-10-20T16:05:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: scheduled
                    revision: 2
                    publishAt: '2026-10-21T13:00:00Z'
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.published:
    post:
      tags: [Webhooks]
      operationId: onSubmissionPublished
      summary: The article is live
      description: 'Carries the live URL. Sent once for each site the article goes live on.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionPublishedEvent'
            examples:
              example:
                value:
                  id: 01921233-0c0d-7e0f-8a1b-2c3d4e5f6a7b
                  type: submission.published
                  schemaVersion: '1'
                  occurredAt: '2026-10-21T13:00:12Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    status: published
                    revision: 2
                    property: example-property
                    url: https://www.example.com/article/pcs-move-state-income-taxes
                    publishedAt: '2026-10-21T13:00:10Z'
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.updated:
    post:
      tags: [Webhooks]
      operationId: onSubmissionUpdated
      summary: Published content changed
      description: 'Sent when published content changed: an approved correction applied in place, the article re-published after its page was changed on the site, or re-published after it was removed from the site (then `url` is the new address).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionUpdatedEvent'
            examples:
              example:
                value:
                  id: 01921b00-4444-7555-8666-777788889999
                  type: submission.updated
                  schemaVersion: '1'
                  occurredAt: '2026-11-04T09:15:02Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    status: published
                    revision: 2
                    property: example-property
                    url: https://www.example.com/article/pcs-move-state-income-taxes
                    updatedAt: '2026-11-04T09:15:00Z'
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.rejected:
    post:
      tags: [Webhooks]
      operationId: onSubmissionRejected
      summary: The submission was rejected
      description: 'Rejection is final for this submission.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionRejectedEvent'
            examples:
              example:
                value:
                  id: 019212ff-1111-7222-8333-444455556666
                  type: submission.rejected
                  schemaVersion: '1'
                  occurredAt: '2026-10-20T11:30:00Z'
                  data:
                    submissionId: 01920c55-0000-7111-8222-333344445555
                    externalRef: ptr-2026-10-0043
                    property: example-property
                    status: rejected
                    revision: 1
                    feedback: The article substantially duplicates a piece already published on the site.
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.withdrawn:
    post:
      tags: [Webhooks]
      operationId: onSubmissionWithdrawn
      summary: You withdrew the submission
      description: 'Confirms a `POST /v1/submissions/{id}/withdraw`.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionWithdrawnEvent'
            examples:
              example:
                value:
                  id: 01921310-2222-7333-8444-555566667777
                  type: submission.withdrawn
                  schemaVersion: '1'
                  occurredAt: '2026-10-20T12:00:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: withdrawn
                    revision: 1
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  submission.unpublished:
    post:
      tags: [Webhooks]
      operationId: onSubmissionUnpublished
      summary: The article was taken down
      description: 'Our editors unpublished it, for example after a takedown request.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmissionUnpublishedEvent'
            examples:
              example:
                value:
                  id: 01921a00-3333-7444-8555-666677778888
                  type: submission.unpublished
                  schemaVersion: '1'
                  occurredAt: '2026-11-03T10:00:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: unpublished
                    revision: 2
      responses:
        '2XX':
          description: Received. Any 2xx within 10 seconds counts as delivered.
  correction_request.closed:
    post:
      tags: [Webhooks]
      operationId: onCorrectionRequestClosed
      summary: An editor closed one of your correction requests
      description: 'Sent when an editor marks a correction request `done` or `declined`, with their note.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CorrectionRequestClosedEvent'
            examples:
              example:
                value:
                  id: 01921c00-5555-7666-8777-888899990000
                  type: correction_request.closed
                  schemaVersion: '1'
                  occurredAt: '2026-11-04T09:30:00Z'
                  data:
                    submissionId: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
                    externalRef: ptr-2026-10-0042
                    property: example-property
                    status: published
                    revision: 2
                    correctionRequest:
                      id: 01921500-1234-7abc-8def-0123456789ab
                      status: done
                      note: The filing deadline in section 3 now reads May 1.
      responses:
        '2XX':
          description: Received.
  webhook.test:
    post:
      tags: [Webhooks]
      operationId: onWebhookTest
      summary: A test event you asked for
      description: 'Sent by `POST /v1/webhooks/test`. Always delivered, whatever you subscribed to.'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookTestEvent'
            examples:
              example:
                value:
                  id: 01921600-aaaa-7bbb-8ccc-ddddeeeeffff
                  type: webhook.test
                  schemaVersion: '1'
                  occurredAt: '2026-10-18T14:05:00Z'
                  data: {}
      responses:
        '2XX':
          description: Received.
  endpoint.verification:
    post:
      tags: [Webhooks]
      operationId: onEndpointVerification
      summary: Prove you control the endpoint
      description: 'Sent after `PUT /v1/webhooks` creates the endpoint, changes its URL, or re-sends the same URL while the endpoint is `pending_verification` or `verification_failed`. Answer `200` within 10 seconds with `{"challenge": <the same value>}`; until then other events are held (up to 24 hours).'
      security: []
      parameters:
        - $ref: '#/components/parameters/WebhookSignature'
        - $ref: '#/components/parameters/WebhookEventId'
        - $ref: '#/components/parameters/WebhookEventType'
        - $ref: '#/components/parameters/WebhookDeliveryAttempt'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EndpointVerificationEvent'
            examples:
              example:
                value:
                  id: 01921600-0000-7111-8222-333344440000
                  type: endpoint.verification
                  schemaVersion: '1'
                  occurredAt: '2026-10-18T14:00:01Z'
                  data:
                    challenge: 3q2-7wEXAMPLEchallengeValue0000
      responses:
        '200':
          description: Echo the challenge.
          content:
            application/json:
              schema:
                type: object
                required: [challenge]
                properties:
                  challenge:
                    type: string
              examples:
                echo:
                  value:
                    challenge: 3q2-7wEXAMPLEchallengeValue0000

components:
  securitySchemes:
    oauth2:
      type: oauth2
      description: |
        Client credentials with `private_key_jwt` client authentication (RFC 7523). Property scopes
        `submit:property:<id>` are requested alongside these.
      flows:
        clientCredentials:
          tokenUrl: https://api.content.somo.financial/oauth/token
          scopes:
            submissions:write: Create submissions and revisions, withdraw your own, and file correction requests.
            submissions:read:own: Read your own submissions and the properties you may submit to.
            assets:write: Upload and read your own images.
            webhooks:manage: Read, replace, delete and test your webhook endpoint.
    apiKey:
      type: http
      scheme: bearer
      description: API keys are issued only by exception; contact us.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Unique per request you intend to make once (a UUID is ideal). Retrying with the same key and the same body
        returns the original response with `Idempotent-Replayed: true`; the same key with a different body is
        `409 idempotency-key-reused`. Scoped to your credential; kept 30 days.
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9._:-]+$'
      example: 6f1c2b3a-9d8e-4f7a-b6c5-d4e3f2a1b0c9
    IdempotencyKeyOptional:
      name: Idempotency-Key
      in: header
      required: false
      description: Optional on this call; same semantics as on create calls.
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9._:-]+$'
    WebhookIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional; send one when creating the endpoint or rotating its secret.
        - **Within 10 minutes**, a retry with the same key and body returns the same status and body, including the
          same new `signingSecret`, as long as that secret is still the endpoint's current one.
        - **After 10 minutes**, or once the secret has been rotated again, a retry returns the original status code
          with the endpoint's current view and **no** `signingSecret`.
        - The same key with a different body is `409 idempotency-key-reused`.
        - After `DELETE /v1/webhooks`, a retry answers `404 not-found`.
        - We never store the secret with the key.
      schema:
        type: string
        minLength: 8
        maxLength: 128
        pattern: '^[A-Za-z0-9._:-]+$'
    SubmissionId:
      name: id
      in: path
      required: true
      description: Submission id.
      schema:
        type: string
        format: uuid
    AssetId:
      name: id
      in: path
      required: true
      description: Asset id.
      schema:
        type: string
        format: uuid
    WebhookSignature:
      name: SCS-Signature
      in: header
      required: true
      description: |
        `t=<unix seconds>,v1=<hex HMAC-SHA256>`. The HMAC is over the string `<t>.<raw request body>` with your
        signing secret. During a secret rotation two `v1=` values are present; accept if either matches. Reject
        if `t` is more than 300 seconds from your clock.
      schema:
        type: string
        pattern: '^t=[0-9]{10},v1=[0-9a-f]{64}(,v1=[0-9a-f]{64})?$'
      example: t=1760796245,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
    WebhookEventId:
      name: SCS-Event-Id
      in: header
      required: true
      description: The event id, the same on every retry. Use it to ignore duplicates.
      schema:
        type: string
        format: uuid
    WebhookEventType:
      name: SCS-Event-Type
      in: header
      required: true
      description: The event type, equal to the body's `type`.
      schema:
        type: string
    WebhookDeliveryAttempt:
      name: SCS-Delivery-Attempt
      in: header
      required: true
      description: 1 for the first delivery, then 2, 3, … on retries.
      schema:
        type: integer
        minimum: 1

  headers:
    RateLimit-Limit:
      description: The capacity of this credential's token bucket (IETF RateLimit header draft semantics, applied to a token bucket).
      schema:
        type: integer
    RateLimit-Remaining:
      description: Tokens left in the bucket now.
      schema:
        type: integer
    RateLimit-Reset:
      description: Seconds until the bucket is full again.
      schema:
        type: integer
    Retry-After:
      description: Seconds to wait before retrying.
      schema:
        type: integer
    Idempotent-Replayed:
      description: '`true` when this is the stored response to an earlier request with the same `Idempotency-Key`.'
      schema:
        type: string
        enum: ['true']

  responses:
    BadRequest:
      description: Malformed request (`invalid-request`, `idempotency-key-required`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            missingKey:
              value:
                type: https://api.content.somo.financial/problems/idempotency-key-required
                title: Idempotency-Key header required
                status: 400
                detail: POST /v1/submissions requires an Idempotency-Key header of 8 to 128 characters.
                instance: /v1/submissions
                requestId: req_01920c4e8b7a
    Unauthorized:
      description: Missing, expired or revoked token (`invalid-token`).
      headers:
        WWW-Authenticate:
          description: '`Bearer error="invalid_token"`'
          schema:
            type: string
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            expired:
              value:
                type: https://api.content.somo.financial/problems/invalid-token
                title: Invalid or expired access token
                status: 401
                requestId: req_01920c4e8b7b
    Forbidden:
      description: The token lacks a required scope, or the property is not yours (`insufficient-scope`, `property-not-allowed`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            scope:
              value:
                type: https://api.content.somo.financial/problems/insufficient-scope
                title: Insufficient scope
                status: 403
                detail: This call requires scope submissions:write.
                requiredScope: submissions:write
                requestId: req_01920c4e8b7c
            property:
              value:
                type: https://api.content.somo.financial/problems/property-not-allowed
                title: Property not allowed
                status: 403
                detail: You may not submit to property example-property, or it does not exist.
                requestId: req_01920c4e8b7d
    NotFound:
      description: No such resource, or it belongs to another client (`not-found`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            notFound:
              value:
                type: https://api.content.somo.financial/problems/not-found
                title: Not found
                status: 404
                requestId: req_01920c4e8b7e
    Conflict:
      description: |
        `invalid-state`, `idempotency-key-reused`, `duplicate-external-ref` or `secret-rotation-in-progress`
        (with `Retry-After`).
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            invalidState:
              value:
                type: https://api.content.somo.financial/problems/invalid-state
                title: Not allowed in the current status
                status: 409
                detail: Revisions are accepted only while the submission is changes_requested; it is in_review.
                currentStatus: in_review
                requestId: req_01920c4e8b7f
            keyReused:
              value:
                type: https://api.content.somo.financial/problems/idempotency-key-reused
                title: Idempotency-Key reused with a different request
                status: 409
                requestId: req_01920c4e8b80
    WithdrawConflict:
      description: |
        `invalid-state` (the status no longer allows a withdrawal, e.g. already published), or
        `publishing-in-progress`: a publication of this submission is under way or waiting on an editor.
        `Retry-After` is how long to wait before trying again, at least 5 s and at most 3600 s. It is 3600 s when
        one of our editors needs to complete the publication, which can take until an editor acts.
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            publishingInProgress:
              value:
                type: https://api.content.somo.financial/problems/publishing-in-progress
                title: Publishing in progress
                status: 409
                detail: This submission is being published. Try again after the Retry-After interval.
                requestId: req_01920c4e8b83
            alreadyPublished:
              value:
                type: https://api.content.somo.financial/problems/invalid-state
                title: Not allowed in the current status
                status: 409
                detail: The submission is published; file a correction request instead.
                currentStatus: published
                requestId: req_01920c4e8b84
    PayloadTooLarge:
      description: Request body over 256 KB (`payload-too-large`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    UnsupportedMediaType:
      description: '`Content-Type` must be `application/json` (`unsupported-media-type`).'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    ValidationFailed:
      description: |
        The body breaks the schema or a content rule (`validation-failed`), or a referenced asset, YMYL flag or
        property state prevents it (`asset-not-usable`, `ymyl-not-allowed`, `property-paused`).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            validation:
              value:
                type: https://api.content.somo.financial/problems/validation-failed
                title: The submission is not valid
                status: 422
                errors:
                  - pointer: /seo/metaDescription
                    detail: Must be 50 to 160 characters; got 212.
                  - pointer: /sources
                    detail: At least one source is required.
                  - pointer: /body/content
                    detail: Image source https://cdn.example.com/a.jpg is not an asset reference; upload it with POST /v1/assets and use asset:<id>.
                requestId: req_01920c4e8b81
    TooManyRequests:
      description: Rate limited (`rate-limited`) or over your submission quota (`quota-exceeded`).
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimit-Limit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimit-Remaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimit-Reset'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            quota:
              value:
                type: https://api.content.somo.financial/problems/quota-exceeded
                title: Submission quota reached
                status: 429
                detail: 10 of 10 submissions to example-property used in the last 7 days. The next slot opens at 2026-10-21T09:30:00Z.
                requestId: req_01920c4e8b82
    InternalError:
      description: Our fault (`internal-error`). Retry with the same `Idempotency-Key`.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
    ServiceUnavailable:
      description: The request couldn't be completed right now (`service-unavailable`). Retry after `Retry-After`, with the same `Idempotency-Key`.
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

  schemas:
    Problem:
      type: object
      description: RFC 9457 problem details. Extension members may be added.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: Stable identifier of the problem; branch on this.
          examples: [https://api.content.somo.financial/problems/validation-failed]
        title:
          type: string
        status:
          type: integer
          minimum: 400
          maximum: 599
        detail:
          type: string
        instance:
          type: string
        requestId:
          type: string
          description: Same as the `X-Request-Id` response header.
        errors:
          type: array
          description: For `validation-failed`; one entry per problem, with a JSON Pointer (RFC 6901) into the request body.
          items:
            type: object
            required: [pointer, detail]
            properties:
              pointer:
                type: string
              detail:
                type: string
        requiredScope:
          type: string
          description: For `insufficient-scope`.
        reason:
          type: string
          description: For `asset-not-usable` (`not_found`, `rejected`, `expired`). Tolerate other values.
        currentStatus:
          $ref: '#/components/schemas/SubmissionStatus'

    TokenRequest:
      type: object
      required: [grant_type, client_assertion_type, client_assertion]
      properties:
        grant_type:
          type: string
          const: client_credentials
        client_assertion_type:
          type: string
          const: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
        client_assertion:
          type: string
          description: The signed JWT (compact serialization).
          maxLength: 8192
        scope:
          type: string
          description: Space-separated scopes. Omit to receive every scope your credential holds.
          maxLength: 2048
        client_id:
          type: string
          format: uuid
          description: Optional. If sent, must equal the assertion's `iss`.
    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token:
          type: string
          description: Opaque bearer token. Do not parse it.
        token_type:
          type: string
          const: Bearer
        expires_in:
          type: integer
          const: 900
        scope:
          type: string
          description: Space-separated scopes granted.
    OAuthError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant, invalid_scope, unauthorized_client, unsupported_grant_type]
        error_description:
          type: string

    PropertyId:
      type: string
      description: Property id, e.g. `example-property`.
      pattern: '^[a-z0-9][a-z0-9-]{1,40}$'
      examples: [example-property]
    SubmissionStatus:
      type: string
      enum: [received, in_review, changes_requested, approved, scheduled, published, rejected, withdrawn, unpublished]

    ArticleContent:
      type: object
      description: The article itself. Shared by submissions and revisions.
      required: [schemaVersion, title, excerpt, body, seo, sources, author, disclosure]
      properties:
        schemaVersion:
          type: string
          const: '1'
        title:
          type: string
          minLength: 20
          maxLength: 110
          pattern: '^[^<>]*$'
        excerpt:
          type: string
          description: Plain text summary.
          minLength: 50
          maxLength: 300
          pattern: '^[^<>]*$'
        body:
          $ref: '#/components/schemas/Body'
        seo:
          $ref: '#/components/schemas/Seo'
        category:
          type: string
          description: A category slug from `GET /v1/properties`.
          maxLength: 80
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        tags:
          type: array
          maxItems: 10
          uniqueItems: true
          items:
            type: string
            maxLength: 80
            pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        sources:
          type: array
          minItems: 1
          maxItems: 50
          items:
            $ref: '#/components/schemas/Source'
        author:
          $ref: '#/components/schemas/AuthorClaim'
        disclosure:
          $ref: '#/components/schemas/Disclosure'
        language:
          type: string
          description: BCP 47 language tag (language, optional region), any case; normalized to canonical case (`en-us` → `en-US`). Must be one the property accepts (`GET /v1/properties`).
          pattern: '^[A-Za-z]{2,3}(-[A-Za-z]{2})?$'
          default: en-US
        requestedPublishDate:
          type: string
          format: date
          description: Advisory only. Editors decide the schedule.
        ymyl:
          type: boolean
          description: Set true if the article gives money, health, benefits or legal guidance. Such articles get an additional subject-matter review. This is a submission-level flag; in responses `content.ymyl` echoes the submission's current flag (which an editor may also have set). Refused (`422 ymyl-not-allowed`) if your agreement does not cover YMYL for this property.
          default: false
        assets:
          type: array
          description: At most one `hero`, and each `assetId` at most once (checked by the API, `422`).
          maxItems: 20
          items:
            $ref: '#/components/schemas/AssetRef'
    Body:
      type: object
      additionalProperties: false
      required: [format, content]
      properties:
        format:
          type: string
          enum: [markdown, html]
          description: Markdown (CommonMark + GFM tables) is preferred. HTML is sanitized to an allow-list.
        content:
          type: string
          minLength: 1
          maxLength: 204800
          description: |
            At most 204,800 **bytes** of UTF-8, counted in bytes by the API (`maxLength` here is only an upper
            bound in characters). An `h1` is turned into `h2` (the title is the heading).
            - Links must be absolute `https` URLs or a same-page `#fragment`; any other link (including `http:`) is
              refused with `422 validation-failed`. We set every link's `rel`.
            - HTML containing a CDATA section, a declaration (e.g. DOCTYPE), a processing instruction or a comment
              that contains markup is refused with `422 validation-failed`. Plain comments are removed.
            - Images only as `asset:<id>` of an asset you uploaded and list in `assets`.
    Seo:
      type: object
      additionalProperties: false
      required: [metaTitle, metaDescription]
      properties:
        metaTitle:
          type: string
          minLength: 10
          maxLength: 60
          pattern: '^[^<>]*$'
        metaDescription:
          type: string
          minLength: 50
          maxLength: 160
          pattern: '^[^<>]*$'
        slug:
          type: string
          description: A suggestion; our editors or the site may change it.
          minLength: 3
          maxLength: 80
          pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$'
        focusKeyword:
          type: string
          maxLength: 80
          pattern: '^[^<>]*$'
    Source:
      type: object
      additionalProperties: false
      required: [url, title]
      properties:
        url:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 2048
        title:
          type: string
          minLength: 1
          maxLength: 300
          pattern: '^[^<>]*$'
        publisher:
          type: string
          maxLength: 200
          pattern: '^[^<>]*$'
        accessedAt:
          type: string
          format: date
    AuthorClaim:
      type: object
      additionalProperties: false
      description: Who wrote or reviewed the piece. A claim; editors decide the published byline.
      required: [name, role, consentOnFile]
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 120
          pattern: '^[^<>]*$'
        role:
          type: string
          enum: [writer, expert, staff]
        credentials:
          type: string
          description: Relevant qualifications, e.g. "Enrolled Agent; former installation tax center volunteer".
          maxLength: 300
          pattern: '^[^<>]*$'
        bio:
          type: string
          maxLength: 1000
          pattern: '^[^<>]*$'
        profileUrl:
          type: string
          format: uri
          pattern: '^https://'
          maxLength: 2048
        consentOnFile:
          type: boolean
          description: The named person consented to be credited, and the partner holds that consent.
    Disclosure:
      type: object
      additionalProperties: false
      required: [aiAssisted, sponsored]
      if:
        properties:
          sponsored:
            const: true
        required: [sponsored]
      then:
        properties:
          sponsorName:
            minLength: 2
        required: [sponsorName]
      properties:
        aiAssisted:
          type: boolean
          description: Whether AI tools were used to draft or edit any part of the text.
        aiAssistanceNotes:
          type: string
          maxLength: 1000
          pattern: '^[^<>]*$'
        sponsored:
          type: boolean
          description: Whether anyone paid, or will pay, for the article or any link in it.
        sponsorName:
          type: string
          description: Required when `sponsored` is true.
          minLength: 2
          maxLength: 200
          pattern: '^[^<>]*$'
        sponsoredLinks:
          type: array
          description: Every paid or affiliate URL in the body. These get `rel="sponsored"`.
          maxItems: 50
          default: []
          items:
            type: string
            format: uri
            pattern: '^https://'
            maxLength: 2048
    AssetRef:
      type: object
      additionalProperties: false
      required: [assetId, role, alt]
      properties:
        assetId:
          type: string
          format: uuid
        role:
          type: string
          enum: [hero, inline]
          description: At most one `hero`.
        alt:
          type: string
          minLength: 5
          maxLength: 250
          pattern: '^[^<>]*$'
        caption:
          type: string
          maxLength: 300
          pattern: '^[^<>]*$'
        credit:
          type: string
          maxLength: 200
          pattern: '^[^<>]*$'
    SubmissionCreate:
      allOf:
        - type: object
          required: [property]
          properties:
            property:
              $ref: '#/components/schemas/PropertyId'
            externalRef:
              type: string
              description: Your own id for the article. Unique among your submissions that are not rejected or withdrawn.
              pattern: '^[A-Za-z0-9._:/-]{1,128}$'
        - $ref: '#/components/schemas/ArticleContent'
      unevaluatedProperties: false
    RevisionCreate:
      allOf:
        - $ref: '#/components/schemas/ArticleContent'
        - type: object
          properties:
            noteToEditor:
              type: string
              description: What changed, for the editor.
              maxLength: 2000
              pattern: '^[^<>]*$'
      unevaluatedProperties: false
    WithdrawRequest:
      type: object
      additionalProperties: false
      properties:
        reason:
          type: string
          maxLength: 1000
          pattern: '^[^<>]*$'

    SubmissionSummary:
      type: object
      required: [id, property, status, title, currentRevision, createdAt, updatedAt, statusChangedAt]
      properties:
        id:
          type: string
          format: uuid
        externalRef:
          type: [string, 'null']
        property:
          $ref: '#/components/schemas/PropertyId'
        status:
          $ref: '#/components/schemas/SubmissionStatus'
        title:
          type: string
        currentRevision:
          type: integer
          minimum: 1
          description: Your latest revision's number (1 for the original, 2 for your first revision, …). Counts your revisions only; editor edits are never numbered here.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        statusChangedAt:
          type: string
          format: date-time
    Submission:
      allOf:
        - $ref: '#/components/schemas/SubmissionSummary'
        - type: object
          required: [content, feedback, publishAt, publications, correctionRequests, history]
          properties:
            content:
              $ref: '#/components/schemas/ArticleContent'
            feedback:
              description: The latest editor note addressed to you (with changes requested or a rejection), or null.
              oneOf:
                - type: 'null'
                - type: object
                  required: [note, at]
                  properties:
                    note:
                      type: string
                    at:
                      type: string
                      format: date-time
            publishAt:
              type: [string, 'null']
              format: date-time
              description: Set while `scheduled`.
            publications:
              type: array
              description: Every site this submission went live on (empty until published).
              items:
                $ref: '#/components/schemas/PublicationRef'
            correctionRequests:
              type: array
              description: Your correction and takedown requests for this submission, oldest first.
              items:
                $ref: '#/components/schemas/CorrectionRequest'
            history:
              type: array
              description: Status changes, oldest first.
              items:
                type: object
                required: [status, at]
                properties:
                  status:
                    $ref: '#/components/schemas/SubmissionStatus'
                  at:
                    type: string
                    format: date-time
    SubmissionList:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SubmissionSummary'
        nextCursor:
          type: [string, 'null']

    Licence:
      type: object
      description: The image's license. Field and value names keep the spelling `licence` (for example `stock_licence`).
      additionalProperties: false
      required: [source, type, proof]
      properties:
        source:
          type: string
          description: Who made the image or where it came from.
          minLength: 2
          maxLength: 300
          pattern: '^[^<>]*$'
        type:
          type: string
          enum: [owned, work_for_hire, stock_licence, creative_commons, public_domain, ai_generated_owned, other]
        proof:
          type: string
          description: License id, agreement reference or URL proving the right to publish.
          minLength: 2
          maxLength: 500
          pattern: '^[^<>]*$'
    AssetStatus:
      type: string
      enum: [pending_upload, scanning, processing, clean, rejected, expired]
    AssetCreate:
      type: object
      additionalProperties: false
      required: [filename, contentType, sizeBytes, sha256, licence, aiGenerated]
      properties:
        filename:
          type: string
          minLength: 1
          maxLength: 200
          pattern: '^[^<>]*$'
        contentType:
          type: string
          enum: [image/jpeg, image/png, image/webp, image/avif]
        sizeBytes:
          type: integer
          minimum: 1
          maximum: 10485760
        sha256:
          type: string
          description: Hex SHA-256 of the exact bytes you will upload.
          pattern: '^[0-9a-f]{64}$'
        licence:
          $ref: '#/components/schemas/Licence'
        aiGenerated:
          type: boolean
    Asset:
      type: object
      required: [id, status, filename, contentType, sizeBytes, sha256, licence, aiGenerated, rejectReason, width, height, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          $ref: '#/components/schemas/AssetStatus'
        filename:
          type: string
        contentType:
          type: string
        sizeBytes:
          type: integer
        sha256:
          type: string
        licence:
          $ref: '#/components/schemas/Licence'
        aiGenerated:
          type: boolean
        rejectReason:
          type: [string, 'null']
          enum: [malware, scan_failed, type_mismatch, unsupported_type, too_large, too_many_pixels, decode_failed, null]
        width:
          type: [integer, 'null']
          description: Of the processed image, once clean.
        height:
          type: [integer, 'null']
        createdAt:
          type: string
          format: date-time
    AssetCreated:
      type: object
      required: [asset, upload]
      properties:
        asset:
          $ref: '#/components/schemas/Asset'
        upload:
          description: Null on an idempotent replay once the asset is no longer `pending_upload`.
          type: [object, 'null']
          required: [method, url, headers, expiresAt]
          properties:
            method:
              type: string
              const: PUT
            url:
              type: string
              format: uri
            headers:
              type: object
              description: Send exactly these headers with the upload; they are part of the signature.
              additionalProperties:
                type: string
            expiresAt:
              type: string
              format: date-time

    WebhookEventType:
      type: string
      description: Subscribable event types. More may be added; ignore types you do not know.
      enum: [submission.status_changed, submission.received, submission.revised, submission.changes_requested, submission.approved, submission.scheduled, submission.published, submission.updated, submission.rejected, submission.withdrawn, submission.unpublished, correction_request.closed]
    WebhookEndpointPut:
      type: object
      additionalProperties: false
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: https, port 443 only, a DNS host name (no IP literal; IDN as `xn--` punycode), no userinfo, no fragment; a query string is allowed. This pattern is a pre-filter; the address is checked again when we connect.
          pattern: '^https://(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{1,59})(?::443)?(?:[/?][^#\s]*)?$'
          maxLength: 2048
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: '#/components/schemas/WebhookEventType'
        rotateSecret:
          type: boolean
          default: false
          description: Issue a new signing secret; the old one keeps signing for 24 hours.
    WebhookEndpoint:
      type: object
      required: [url, events, status, secretRotatingUntil, createdAt, updatedAt]
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        status:
          type: string
          enum: [pending_verification, active, verification_failed, disabled]
        secretRotatingUntil:
          type: [string, 'null']
          format: date-time
          description: While set, deliveries are signed with both the old and the new secret.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            signingSecret:
              type: string
              description: Shown only when created or rotated.

    Term:
      type: object
      required: [slug, name]
      properties:
        slug:
          type: string
        name:
          type: string
    Property:
      type: object
      required: [id, name, siteUrl, status, ymylAllowed, languages, categories, tags, quota]
      properties:
        id:
          $ref: '#/components/schemas/PropertyId'
        name:
          type: string
        siteUrl:
          type: string
          format: uri
        status:
          type: string
          enum: [active, paused]
        ymylAllowed:
          type: boolean
          description: Whether your agreement lets you submit YMYL articles to this property.
        languages:
          type: array
          description: Accepted `language` values.
          items:
            type: string
        categories:
          type: array
          items:
            $ref: '#/components/schemas/Term'
        tags:
          type: array
          items:
            $ref: '#/components/schemas/Term'
        quota:
          type: object
          required: [limit, windowDays, remaining, nextSlotAt]
          properties:
            limit:
              type: integer
            windowDays:
              type: integer
            remaining:
              type: integer
            nextSlotAt:
              type: [string, 'null']
              format: date-time
              description: When the oldest counted submission leaves the window; null if none counted.
    PropertyList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Property'

    PublicationRef:
      type: object
      required: [property, url, publishedAt, state]
      properties:
        property:
          $ref: '#/components/schemas/PropertyId'
        url:
          type: string
          format: uri
        publishedAt:
          type: string
          format: date-time
        state:
          type: string
          enum: [live, unpublished]
    PublishedContent:
      type: object
      required: [submissionId, revisionPublished, title, excerpt, html, seo, byline, publications]
      properties:
        submissionId:
          type: string
          format: uuid
        revisionPublished:
          type: integer
          description: Your revision number the published text is based on. Editors may have edited it; this is the text as published.
        title:
          type: string
        excerpt:
          type: string
        html:
          type: string
          description: The sanitized body as published.
        seo:
          type: object
          required: [metaTitle, metaDescription]
          properties:
            metaTitle:
              type: string
            metaDescription:
              type: string
            slug:
              type: string
        byline:
          type: object
          required: [name]
          properties:
            name:
              type: string
            reviewedBy:
              type: string
        publications:
          type: array
          items:
            $ref: '#/components/schemas/PublicationRef'
        editedAtDestination:
          type: boolean
          description: True when an editor accepted changes made directly on the site; the live page may then differ from this text.
          default: false
    CorrectionRequestCreate:
      type: object
      additionalProperties: false
      required: [kind, reason]
      properties:
        kind:
          type: string
          enum: [correction, takedown]
        reason:
          type: string
          minLength: 10
          maxLength: 1000
          pattern: '^[^<>]*$'
        details:
          type: string
          maxLength: 5000
          pattern: '^[^<>]*$'
        proposedRevision:
          $ref: '#/components/schemas/RevisionCreate'
    CorrectionRequest:
      type: object
      required: [id, kind, status, reason, details, editorNote, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum: [correction, takedown]
        status:
          type: string
          enum: [open, accepted, declined, done]
          description: '`open`: received, not yet looked at. `accepted`: an editor is acting on it. `done`: the editor closed it as completed; the note says what was done. `declined`: the editor closed it without the requested change; the note says why. Only our editors change it, and closing it sends `correction_request.closed`.'
        reason:
          type: string
        details:
          type: [string, 'null']
        editorNote:
          type: [string, 'null']
          description: Our editor's note to you, once handled.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    AssetList:
      type: object
      required: [data, nextCursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Asset'
        nextCursor:
          type: [string, 'null']

    Health:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status:
          type: string
          const: ok

    EventBase:
      type: object
      required: [id, type, schemaVersion, occurredAt, data]
      properties:
        id:
          type: string
          format: uuid
          description: Equal to the `SCS-Event-Id` header.
        type:
          type: string
          description: The event type. Tolerate types you do not know.
        schemaVersion:
          type: string
          const: '1'
        occurredAt:
          type: string
          format: date-time
    EventSubmissionData:
      type: object
      required: [submissionId, externalRef, property, status, revision]
      properties:
        submissionId:
          type: string
          format: uuid
        externalRef:
          type: [string, 'null']
        property:
          $ref: '#/components/schemas/PropertyId'
        status:
          $ref: '#/components/schemas/SubmissionStatus'
        revision:
          type: integer
          minimum: 1
          description: Your revision number at the time of the event (editor edits are not counted).
    SubmissionReceivedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.received
            data:
              $ref: '#/components/schemas/EventSubmissionData'
    SubmissionRevisedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.revised
            data:
              $ref: '#/components/schemas/EventSubmissionData'
    SubmissionChangesRequestedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.changes_requested
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [feedback]
                  properties:
                    feedback:
                      type: string
    SubmissionApprovedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.approved
            data:
              $ref: '#/components/schemas/EventSubmissionData'
    SubmissionScheduledEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.scheduled
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [publishAt]
                  properties:
                    publishAt:
                      type: string
                      format: date-time
    SubmissionPublishedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.published
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [url, publishedAt]
                  properties:
                    url:
                      type: string
                      format: uri
                    publishedAt:
                      type: string
                      format: date-time
    SubmissionStatusChangedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.status_changed
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [from, to, at]
                  properties:
                    from:
                      $ref: '#/components/schemas/SubmissionStatus'
                    to:
                      $ref: '#/components/schemas/SubmissionStatus'
                    at:
                      type: string
                      format: date-time
    SubmissionUpdatedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.updated
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [url, updatedAt]
                  properties:
                    url:
                      type: string
                      format: uri
                    updatedAt:
                      type: string
                      format: date-time
    SubmissionRejectedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.rejected
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [feedback]
                  properties:
                    feedback:
                      type: string
    SubmissionWithdrawnEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.withdrawn
            data:
              $ref: '#/components/schemas/EventSubmissionData'
    SubmissionUnpublishedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: submission.unpublished
            data:
              $ref: '#/components/schemas/EventSubmissionData'
    EndpointVerificationEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: endpoint.verification
            data:
              type: object
              additionalProperties: false
              required: [challenge]
              properties:
                challenge:
                  type: string
    CorrectionRequestClosedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: correction_request.closed
            data:
              allOf:
                - $ref: '#/components/schemas/EventSubmissionData'
                - type: object
                  required: [correctionRequest]
                  properties:
                    correctionRequest:
                      type: object
                      required: [id, status, note]
                      properties:
                        id:
                          type: string
                          format: uuid
                        status:
                          type: string
                          enum: [done, declined]
                        note:
                          type: string
    WebhookTestEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: webhook.test
            data:
              type: object
              additionalProperties: false

  examples:
    SubmissionCreateMarkdown:
      summary: A Markdown article with a hero image
      value:
        schemaVersion: '1'
        property: example-property
        externalRef: ptr-2026-10-0042
        title: How a PCS move affects your state income taxes
        excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
        body:
          format: markdown
          content: |
            ## Your state of legal residence usually stays the same

            Under the [Servicemembers Civil Relief Act](https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra),
            moving on military orders does not by itself change your state of legal residence for income tax.

            ![Moving truck outside base housing](asset:01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f)

            ## What changes for spouses

            The Military Spouses Residency Relief Act and later amendments let an eligible spouse keep the same
            state of residence as the service member. Check your new state's revenue department for how it applies.
        seo:
          metaTitle: PCS moves and state income tax, explained
          metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
          slug: pcs-move-state-income-taxes
          focusKeyword: pcs state income tax
        category: money
        tags: [taxes, pcs]
        sources:
          - url: https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra
            title: Servicemembers Civil Relief Act (SCRA)
            publisher: U.S. Department of Justice
            accessedAt: '2026-10-12'
          - url: https://www.irs.gov/individuals/military
            title: Military tax information
            publisher: Internal Revenue Service
            accessedAt: '2026-10-12'
        author:
          name: Alex Example
          role: expert
          credentials: Enrolled Agent; former installation tax center volunteer
          consentOnFile: true
        disclosure:
          aiAssisted: true
          aiAssistanceNotes: Outline drafted with an AI tool; text written and fact-checked by the author.
          sponsored: false
          sponsoredLinks: []
        ymyl: true
        assets:
          - assetId: 01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f
            role: hero
            alt: Moving truck parked outside base housing
            credit: Partner staff photographer
    RevisionCreateMarkdown:
      summary: A revision answering the editor's feedback
      value:
        schemaVersion: '1'
        noteToEditor: Added the state revenue source for section 2 and removed the tax-software link.
        title: How a PCS move affects your state income taxes
        excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
        body:
          format: markdown
          content: |
            ## Your state of legal residence usually stays the same

            Under the [Servicemembers Civil Relief Act](https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra),
            moving on military orders does not by itself change your state of legal residence for income tax.
        seo:
          metaTitle: PCS moves and state income tax, explained
          metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
        sources:
          - url: https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra
            title: Servicemembers Civil Relief Act (SCRA)
        author:
          name: Alex Example
          role: expert
          consentOnFile: true
        disclosure:
          aiAssisted: true
          sponsored: false
        ymyl: true
    SubmissionReceived:
      summary: Just received
      value:
        id: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
        externalRef: ptr-2026-10-0042
        property: example-property
        status: received
        title: How a PCS move affects your state income taxes
        currentRevision: 1
        createdAt: '2026-10-18T15:04:05Z'
        updatedAt: '2026-10-18T15:04:05Z'
        statusChangedAt: '2026-10-18T15:04:05Z'
        content:
          schemaVersion: '1'
          title: How a PCS move affects your state income taxes
          excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
          body:
            format: markdown
            content: '## Your state of legal residence usually stays the same …'
          seo:
            metaTitle: PCS moves and state income tax, explained
            metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
          sources:
            - url: https://www.irs.gov/individuals/military
              title: Military tax information
          author:
            name: Alex Example
            role: expert
            consentOnFile: true
          disclosure:
            aiAssisted: true
            sponsored: false
        feedback: null
        publishAt: null
        publications: []
        correctionRequests: []
        history:
          - status: received
            at: '2026-10-18T15:04:05Z'
    SubmissionChangesRequested:
      summary: Changes requested
      value:
        id: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
        externalRef: ptr-2026-10-0042
        property: example-property
        status: changes_requested
        title: How a PCS move affects your state income taxes
        currentRevision: 1
        createdAt: '2026-10-18T15:04:05Z'
        updatedAt: '2026-10-19T10:12:00Z'
        statusChangedAt: '2026-10-19T10:12:00Z'
        content:
          schemaVersion: '1'
          title: How a PCS move affects your state income taxes
          excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
          body:
            format: markdown
            content: '## Your state of legal residence usually stays the same …'
          seo:
            metaTitle: PCS moves and state income tax, explained
            metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
          sources:
            - url: https://www.irs.gov/individuals/military
              title: Military tax information
          author:
            name: Alex Example
            role: expert
            consentOnFile: true
          disclosure:
            aiAssisted: true
            sponsored: false
        feedback:
          note: Please cite the state revenue department for the residency rules in section 2, and remove the link to the tax-software comparison page or declare it as sponsored.
          at: '2026-10-19T10:12:00Z'
        publishAt: null
        publications: []
        correctionRequests: []
        history:
          - status: received
            at: '2026-10-18T15:04:05Z'
          - status: in_review
            at: '2026-10-19T09:40:00Z'
          - status: changes_requested
            at: '2026-10-19T10:12:00Z'
    SubmissionPublished:
      summary: Published
      value:
        id: 01920c4e-8b7a-7c3d-9e2f-5a6b7c8d9e0f
        externalRef: ptr-2026-10-0042
        property: example-property
        status: published
        title: How a PCS move affects your state income taxes
        currentRevision: 2
        createdAt: '2026-10-18T15:04:05Z'
        updatedAt: '2026-10-21T13:00:12Z'
        statusChangedAt: '2026-10-21T13:00:12Z'
        content:
          schemaVersion: '1'
          title: How a PCS move affects your state income taxes
          excerpt: Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.
          body:
            format: markdown
            content: '## Your state of legal residence usually stays the same …'
          seo:
            metaTitle: PCS moves and state income tax, explained
            metaDescription: How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.
          sources:
            - url: https://www.irs.gov/individuals/military
              title: Military tax information
          author:
            name: Alex Example
            role: expert
            consentOnFile: true
          disclosure:
            aiAssisted: true
            sponsored: false
        feedback: null
        publishAt: null
        publications:
          - property: example-property
            url: https://www.example.com/article/pcs-move-state-income-taxes
            publishedAt: '2026-10-21T13:00:10Z'
            state: live
        correctionRequests: []
        history:
          - status: received
            at: '2026-10-18T15:04:05Z'
          - status: in_review
            at: '2026-10-19T09:40:00Z'
          - status: changes_requested
            at: '2026-10-19T10:12:00Z'
          - status: in_review
            at: '2026-10-19T18:02:00Z'
          - status: approved
            at: '2026-10-20T16:00:00Z'
          - status: published
            at: '2026-10-21T13:00:12Z'
