> ## Documentation Index
> Fetch the complete documentation index at: https://surf-dcinside-api.kr.ask.surf/llms.txt
> Use this file to discover all available pages before exploring further.

# Send Outcomes

> How placed posts performed, one record per placement, with readings at 1, 6, 24 and 48 hours. This is what the model learns from.

<div style={{ maxWidth: 760 }}>
  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Overview</h2>

  <Warning>
    **Not in MVP scope.** Outcomes are documented here for later. The first trial does not depend on this call, and nothing in the cycle call needs it.
  </Warning>

  The **Send Outcomes** API carries how posts performed after they were placed on a board. Each record is one placement: who chose the post, its counts when it was placed, and cumulative readings on the board copy at 1, 6, 24 and 48 hours. Surf retrains the model from these records.

  <Note>
    The 48-hour reading is what makes a post a **training label**. Send the earlier readings as they become available, and send the same `placement_id` again to add later ones. That updates the record.
  </Note>

  <Warning>
    Net counts are the reading minus the count at placement. Readings on the board copy start from the origin post's values, so `views_at_placement` must be exact, from the board's `origin_hit`.
  </Warning>

  <h3 style={{ marginTop: 20 }}>Cadence & freshness</h3>

  <CardGroup cols={2}>
    <Card title="Refresh" icon="clock">
      Daily during the dress rehearsal, for operator-placed posts. In the live trial, as they occur or in hourly batches, to be agreed.
    </Card>

    <Card title="All or nothing" icon="layer-group">
      If any record fails validation, nothing in the call is stored, and the error lists every failing path.
    </Card>
  </CardGroup>

  <Note>
    **Hides and deletions.** DCinside's admin does not record when a placed post is hidden, only the `hidden_after_exposure` label, so send that label and leave `hidden_at` as `null` until timed hides are logged through the unpublish path. Deletions come from the `deletion` table: `deleted_by`, `deleted_at`, and `deleted_relative`, which says whether the deletion came before or after the operator's decision.
  </Note>

  Model-based and operator picks go through the same call. `picked_by` and `score_at_placement` let us compare them on the same board. The model's history features use only Main and Light outcomes that are at least 48 hours old, and they take the post's original title from the stored candidate record.

  ***

  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Request Attributes</h2>

  The body is gzip-compressed JSON.

  <ParamField body="outcomes" type="array of objects" required>
    Up to 1,000 records. Each record is one placement.

    <Expandable title="item properties">
      <ParamField body="placement_id" type="string" required>
        DCinside's id for this placement. Sending it again with more readings updates the record. Delivered files: `selected_id`.
      </ParamField>

      <ParamField body="gall_id" type="string" required>
        Post key, together with `post_no`.
      </ParamField>

      <ParamField body="post_no" type="integer" required>
        Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.
      </ParamField>

      <ParamField body="tier" type="string" required>
        The board the post was placed on. Delivered files: `rtb_head_tag`. One of `main`, `light`, `night`, `app`.
      </ParamField>

      <ParamField body="placed_at" type="string" required>
        When the post went onto the board. Also fixes the day and time block used for outcome percentiles. Delivered files: `exposed_at`.
      </ParamField>

      <ParamField body="picked_by" type="object" required>
        Who chose the post: `{"type": "operator"}`, or `{"type": "model", "model_version", "weights_version", "cycle_id"}`.

        <Expandable title="chosen by an operator">
          <ParamField body="type" type="string" required>
            The post was chosen by an operator.
          </ParamField>
        </Expandable>

        <Expandable title="chosen from the model's ranking">
          <ParamField body="type" type="string" required>
            The post was chosen from the model's ranking.
          </ParamField>

          <ParamField body="model_version" type="string" required>
            `model_version` of the scores the choice was made from.
          </ParamField>

          <ParamField body="weights_version" type="string" required>
            `weights_version` of those scores.
          </ParamField>

          <ParamField body="cycle_id" type="string" required>
            The cycle whose response the choice was made from.
          </ParamField>
        </Expandable>
      </ParamField>

      <ParamField body="score_at_placement" type="number | null">
        The publish score the post had when placed. Lets us compare model and operator picks on the same board.
      </ParamField>

      <ParamField body="views_at_placement" type="integer" required>
        Exact, from the board's `origin_hit`. Net views are readings minus this. Delivered files: `rtb_start_counts.views_at_placement`.
      </ParamField>

      <ParamField body="up_at_placement" type="integer | null">
        Last gallery-side up-votes before placement.
      </ParamField>

      <ParamField body="down_at_placement" type="integer | null">
        Last gallery-side down-votes before placement.
      </ParamField>

      <ParamField body="comment_at_placement" type="integer | null">
        Last gallery-side comment count before placement.
      </ParamField>

      <ParamField body="readings" type="array of objects" required>
        Up to four readings, at 1, 6, 24 and 48 hours. Send them as they become available.

        <Expandable title="item properties">
          <ParamField body="age_h" type="integer" required>
            Hours since placement: 1, 6, 24 or 48. One of `1`, `6`, `24`, `48`.
          </ParamField>

          <ParamField body="observed_at" type="string" required>
            When the counters were read.
          </ParamField>

          <ParamField body="views" type="integer | null" required>
            Cumulative views on the board copy.
          </ParamField>

          <ParamField body="up" type="integer | null" required>
            Cumulative up-votes on the board copy.
          </ParamField>

          <ParamField body="down" type="integer | null" required>
            Cumulative down-votes on the board copy.
          </ParamField>

          <ParamField body="comment_cnt" type="integer | null" required>
            Cumulative comments on the board copy.
          </ParamField>
        </Expandable>
      </ParamField>

      <ParamField body="hidden_after_exposure" type="boolean | null">
        The label DCinside has today: the post was hidden after it was exposed. Send it now, and use `hidden_at` once timed hides exist.
      </ParamField>

      <ParamField body="hidden_at" type="string | null">
        When the post was taken down. DCinside's admin does not record this today, so send `null` until timed hides are logged through the unpublish path.
      </ParamField>

      <ParamField body="deleted_at" type="string | null">
        When the post was deleted, if it was. Delivered files: `deletion.deleted_at`.
      </ParamField>

      <ParamField body="deleted_by" type="string | null">
        Who deleted it: `author`, `admin` or `auto`. Delivered files: `deletion.deleted_by`. One of `author`, `admin`, `auto`.
      </ParamField>

      <ParamField body="deleted_relative" type="string | null">
        Whether the deletion came before or after the operator's decision. Only `after_decision` is an outcome of the placement. Delivered files: `deletion.deleted_relative`. One of `before_decision`, `after_decision`.
      </ParamField>
    </Expandable>
  </ParamField>

  ***

  <h2 style={{ fontSize: "22px", marginBottom: 8 }}>Response Attributes</h2>

  <ResponseField name="received" type="integer" required>
    Records in the request.
  </ResponseField>

  <ResponseField name="created" type="integer" required>
    Records stored as new placements.
  </ResponseField>

  <ResponseField name="updated" type="integer" required>
    Records that matched an existing `placement_id` and updated it.
  </ResponseField>

  ***
</div>

<RequestExample>
  ```bash cURL theme={null}
  gzip -c outcomes.json | curl --request POST \
    --url "https://rtb.example.com/v1/outcomes" \
    --header "Authorization: <api-key>" \
    --header "Content-Type: application/json" \
    --header "Content-Encoding: gzip" \
    --data-binary @-
  ```

  ```python Python theme={null}
  import gzip, json
  import requests

  body = json.load(open("outcomes.json"))

  response = requests.post(
      "https://rtb.example.com/v1/outcomes",
      headers={
          "Authorization": "<api-key>",
          "Content-Type": "application/json",
          "Content-Encoding": "gzip",
      },
      data=gzip.compress(json.dumps(body).encode("utf-8")),
  )
  result = response.json()
  ```

  ```javascript JavaScript theme={null}
  import { readFileSync } from "node:fs";
  import { gzipSync } from "node:zlib";

  const response = await fetch("https://rtb.example.com/v1/outcomes", {
    method: "POST",
    headers: {
      Authorization: "<api-key>",
      "Content-Type": "application/json",
      "Content-Encoding": "gzip",
    },
    body: gzipSync(readFileSync("outcomes.json")),
  });
  const result = await response.json();
  ```

  ```json Request body theme={null}
  {
    "outcomes": [
      {
        "placement_id": "example-4390001",
        "gall_id": "example_gallery",
        "post_no": 31899120,
        "tier": "main",
        "placed_at": "2026-09-29T02:40:00+09:00",
        "picked_by": {
          "type": "model",
          "model_version": "m0924-1",
          "weights_version": "w-1",
          "cycle_id": "20260929T0240"
        },
        "score_at_placement": 0.62,
        "views_at_placement": 1875,
        "up_at_placement": 40,
        "down_at_placement": 3,
        "comment_at_placement": 21,
        "readings": [
          {
            "age_h": 1,
            "observed_at": "2026-09-29T03:40:00+09:00",
            "views": 9120,
            "up": 66,
            "down": 8,
            "comment_cnt": 30
          }
        ],
        "hidden_at": null,
        "deleted_at": null,
        "deleted_by": null
      }
    ]
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 200 — stored theme={null}
  {
    "received": 1,
    "created": 1,
    "updated": 0
  }
  ```

  ```json 400 — fields failed validation theme={null}
  {
    "error": {
      "code": "bad_request",
      "message": "2 fields failed validation",
      "fields": [
        {
          "path": "candidates[0].created_at",
          "problem": "missing +09:00 offset"
        },
        {
          "path": "engagement[3].views",
          "problem": "must be a non-negative integer or null"
        }
      ]
    }
  }
  ```
</ResponseExample>


## OpenAPI

````yaml openapi.json POST /v1/outcomes
openapi: 3.0.3
info:
  title: DCinside RTB API
  description: >-
    Publish scores for DCinside's Real-Time Best candidate pool. DCinside makes
    one call per five-minute cycle and reads the scores in the response. Version
    1 is a proposal for the technical exchange with DCinside: calls, field names
    and limits can change until both sides agree.
  version: 1.0.0
servers:
  - url: https://{host}
    description: Issued by Surf together with the API key.
    variables:
      host:
        default: rtb.example.com
        description: >-
          Host name issued by Surf for the environment (rehearsal or
          production). The default is a placeholder.
security:
  - ApiKey: []
tags:
  - name: Scoring
    description: One call per five-minute cycle, and re-reading a stored cycle.
  - name: Images
    description: Post images, uploaded once per post, separately from scoring.
  - name: Outcomes
    description: Not in MVP scope. How placed posts performed. Used for learning.
  - name: Weights
    description: Not in MVP scope. The weights of the publish score.
  - name: Health
    description: Service state.
paths:
  /v1/outcomes:
    post:
      tags:
        - Outcomes
      summary: Send Outcomes
      description: >-
        Not in MVP scope. Sends how placed posts performed: one record per
        placement, with readings at 1, 6, 24 and 48 hours. Sent daily during the
        dress rehearsal, for operator-placed posts. In the live trial: as they
        occur or in hourly batches, to be agreed. All-or-nothing: if any record
        fails validation, nothing is stored.
      operationId: postOutcomes
      parameters:
        - name: Content-Encoding
          in: header
          required: false
          schema:
            type: string
            enum:
              - gzip
          description: >-
            Send `gzip` when the body is gzip-compressed. Recommended for this
            call: a busy cycle is about 0.6 MB of JSON.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OutcomesRequest'
            example:
              outcomes:
                - placement_id: example-4390001
                  gall_id: example_gallery
                  post_no: 31899120
                  tier: main
                  placed_at: '2026-09-29T02:40:00+09:00'
                  picked_by:
                    type: model
                    model_version: m0924-1
                    weights_version: w-1
                    cycle_id: 20260929T0240
                  score_at_placement: 0.62
                  views_at_placement: 1875
                  up_at_placement: 40
                  down_at_placement: 3
                  comment_at_placement: 21
                  readings:
                    - age_h: 1
                      observed_at: '2026-09-29T03:40:00+09:00'
                      views: 9120
                      up: 66
                      down: 8
                      comment_cnt: 30
                  hidden_at: null
                  deleted_at: null
                  deleted_by: null
      responses:
        '200':
          description: Stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OutcomesResponse'
              example:
                received: 1
                created: 1
                updated: 0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/TooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
components:
  schemas:
    OutcomesRequest:
      type: object
      properties:
        outcomes:
          type: array
          maxItems: 1000
          items:
            $ref: '#/components/schemas/OutcomeRecord'
          description: Up to 1,000 records. Each record is one placement.
      required:
        - outcomes
    OutcomesResponse:
      type: object
      properties:
        received:
          type: integer
          minimum: 0
          example: 1
          description: Records in the request.
        created:
          type: integer
          minimum: 0
          example: 1
          description: Records stored as new placements.
        updated:
          type: integer
          minimum: 0
          example: 0
          description: Records that matched an existing `placement_id` and updated it.
      required:
        - received
        - created
        - updated
    OutcomeRecord:
      type: object
      properties:
        placement_id:
          type: string
          minLength: 1
          example: example-4390001
          description: >-
            DCinside's id for this placement. Sending it again with more
            readings updates the record. Delivered files: `selected_id`.
        gall_id:
          type: string
          minLength: 1
          description: Post key, together with `post_no`.
          example: example_gallery
        post_no:
          type: integer
          minimum: 1
          description: >-
            Post number. Unique only within a gallery, so a post is always keyed
            by `gall_id` and `post_no` together.
          example: 31900001
        tier:
          type: string
          enum:
            - main
            - light
            - night
            - app
          example: main
          description: 'The board the post was placed on. Delivered files: `rtb_head_tag`.'
        placed_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the post went onto the board. Also fixes the day and time block
            used for outcome percentiles. Delivered files: `exposed_at`.
          example: '2026-09-29T02:40:00+09:00'
        picked_by:
          oneOf:
            - $ref: '#/components/schemas/PickedByOperator'
            - $ref: '#/components/schemas/PickedByModel'
          description: >-
            Who chose the post: `{"type": "operator"}`, or `{"type": "model",
            "model_version", "weights_version", "cycle_id"}`.
        score_at_placement:
          type: number
          nullable: true
          example: 0.62
          description: >-
            The publish score the post had when placed. Lets us compare model
            and operator picks on the same board.
        views_at_placement:
          type: integer
          minimum: 0
          example: 1875
          description: >-
            Exact, from the board's `origin_hit`. Net views are readings minus
            this. Delivered files: `rtb_start_counts.views_at_placement`.
        up_at_placement:
          type: integer
          minimum: 0
          description: Last gallery-side up-votes before placement.
          example: 40
          nullable: true
        down_at_placement:
          type: integer
          minimum: 0
          description: Last gallery-side down-votes before placement.
          example: 3
          nullable: true
        comment_at_placement:
          type: integer
          minimum: 0
          description: Last gallery-side comment count before placement.
          example: 21
          nullable: true
        readings:
          type: array
          maxItems: 4
          items:
            $ref: '#/components/schemas/Reading'
          description: >-
            Up to four readings, at 1, 6, 24 and 48 hours. Send them as they
            become available.
        hidden_after_exposure:
          type: boolean
          nullable: true
          example: true
          description: >-
            The label DCinside has today: the post was hidden after it was
            exposed. Send it now, and use `hidden_at` once timed hides exist.
        hidden_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the post was taken down. DCinside's admin does not record this
            today, so send `null` until timed hides are logged through the
            unpublish path.
          example: '2026-09-29T05:00:00+09:00'
          nullable: true
        deleted_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: >-
            When the post was deleted, if it was. Delivered files:
            `deletion.deleted_at`.
          example: '2026-09-29T06:00:00+09:00'
          nullable: true
        deleted_by:
          type: string
          enum:
            - author
            - admin
            - auto
            - null
          nullable: true
          example: null
          description: >-
            Who deleted it: `author`, `admin` or `auto`. Delivered files:
            `deletion.deleted_by`.
        deleted_relative:
          type: string
          enum:
            - before_decision
            - after_decision
            - null
          nullable: true
          example: null
          description: >-
            Whether the deletion came before or after the operator's decision.
            Only `after_decision` is an outcome of the placement. Delivered
            files: `deletion.deleted_relative`.
      required:
        - placement_id
        - gall_id
        - post_no
        - tier
        - placed_at
        - picked_by
        - views_at_placement
        - readings
      description: One placement of a post on a board, with its outcome readings.
    Error:
      type: object
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
      required:
        - error
    PickedByOperator:
      type: object
      properties:
        type:
          type: string
          enum:
            - operator
          example: operator
          description: The post was chosen by an operator.
      required:
        - type
      description: Chosen by an operator.
    PickedByModel:
      type: object
      properties:
        type:
          type: string
          enum:
            - model
          example: model
          description: The post was chosen from the model's ranking.
        model_version:
          type: string
          example: m0924-1
          description: '`model_version` of the scores the choice was made from.'
        weights_version:
          type: string
          example: w-1
          description: '`weights_version` of those scores.'
        cycle_id:
          type: string
          pattern: ^\d{8}T\d{4}$
          example: 20260929T0240
          description: The cycle whose response the choice was made from.
      required:
        - type
        - model_version
        - weights_version
        - cycle_id
      description: Chosen from the model's ranking.
    Reading:
      type: object
      properties:
        age_h:
          type: integer
          enum:
            - 1
            - 6
            - 24
            - 48
          example: 1
          description: 'Hours since placement: 1, 6, 24 or 48.'
        observed_at:
          type: string
          format: date-time
          pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$
          description: When the counters were read.
          example: '2026-09-29T03:40:00+09:00'
        views:
          type: integer
          minimum: 0
          description: Cumulative views on the board copy.
          example: 9120
          nullable: true
        up:
          type: integer
          minimum: 0
          description: Cumulative up-votes on the board copy.
          example: 66
          nullable: true
        down:
          type: integer
          minimum: 0
          description: Cumulative down-votes on the board copy.
          example: 8
          nullable: true
        comment_cnt:
          type: integer
          minimum: 0
          description: Cumulative comments on the board copy.
          example: 30
          nullable: true
      required:
        - age_h
        - observed_at
        - views
        - up
        - down
        - comment_cnt
      description: >-
        Counts on the board copy at a fixed age. They start from the origin
        post's values, so net = reading − at placement. The 48-hour reading is
        what makes a post a training label. Delivered files: `rtb_performance`.
    ErrorBody:
      type: object
      properties:
        code:
          type: string
          example: bad_request
          description: Machine-readable code. See Errors.
        message:
          type: string
          example: 2 fields failed validation
          description: Human-readable summary.
        fields:
          type: array
          items:
            $ref: '#/components/schemas/ErrorField'
          description: Every failing field, when the error is about the body.
      required:
        - code
        - message
    ErrorField:
      type: object
      properties:
        path:
          type: string
          example: candidates[0].created_at
          description: Path of the failing field.
        problem:
          type: string
          example: missing +09:00 offset
          description: What is wrong with it.
      required:
        - path
        - problem
  responses:
    BadRequest:
      description: >-
        The body is not valid JSON, or a field fails the schema. The error lists
        every failing path. Nothing is stored.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: bad_request
              message: 2 fields failed validation
              fields:
                - path: candidates[0].created_at
                  problem: missing +09:00 offset
                - path: engagement[3].views
                  problem: must be a non-negative integer or null
    Unauthorized:
      description: The API key is missing or unknown.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unauthorized
              message: API key missing or unknown
    Forbidden:
      description: The key has no scope for this call.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: forbidden
              message: this key cannot call PUT /v1/weights
    TooLarge:
      description: Over the size or row limits.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: too_large
              message: candidates has 214 rows, the limit is 200
    Unprocessable:
      description: >-
        The body is valid JSON and matches the schema, but the values are not
        acceptable. Nothing is stored.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unprocessable
              message: cycle_time is not on the 5-minute grid
              fields:
                - path: cycle_time
                  problem: must be a multiple of 5 minutes
    RateLimited:
      description: >-
        Too many calls. Wait for `Retry-After` seconds, then repeat the same
        call.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema:
            type: integer
            example: 10
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: rate_limited
              message: too many calls, retry in 10 s
    ServerError:
      description: Our failure. No scores. DCinside skips the cycle and our alert fires.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: server_error
              message: internal error
  securitySchemes:
    ApiKey:
      type: apiKey
      name: Authorization
      in: header
      description: >-
        The API key issued for the environment, sent as the whole header value.
        Two keys are valid during a rotation.

````