> ## 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.

# Upload an Image

> Uploads one image of a post, once per image, in the background. Never on the scoring path.

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

  <Note>
    **In MVP scope, but separate from scoring.** DCinside sends each post's text once and its images once, when the post becomes a candidate. Images travel on this call, not on the cycle call, so a slow or missing image never delays or blocks a score.
  </Note>

  The **Upload an Image** API stores one image of a post. DCinside uploads each image once, in the background, in the numbering of the v1.3 delivery: `<gall_id>/<post_no>/<NN>.jpg`.

  The current model reads only the image count and total height, which come from `candidate_scores`, so images do not change today's scores. Images are kept for later model versions and for the operators' review.

  <Warning>
    `NN` is the image's **position in the original body**: one-based, zero-padded to at least two digits, and gaps are allowed. In v1.3, 509 of 559,329 images (0.09%) came back empty or with a 403, so a position may be missing. Say so in `candidates[].images_in_post` and `image_positions` when you send the post, and we can tell a late upload from a missing one.
  </Warning>

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

  <CardGroup cols={2}>
    <Card title="Once per image" icon="image">
      Sending the same image again is safe.
    </Card>

    <Card title="In the background" icon="layer-group">
      Separate from the cycle call, so it cannot slow it down.
    </Card>
  </CardGroup>

  ***

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

  <ParamField path="gall_id" type="string" required>
    Gallery id.
  </ParamField>

  <ParamField path="post_no" type="integer" required>
    Post number.
  </ParamField>

  <ParamField path="NN" type="string" required>
    Position of the image in the original body, one-based, zero-padded to at least two digits. Examples: `01`, `02`, `10`, `100`.
  </ParamField>

  <ParamField header="Content-Type" type="string" required>
    `image/jpeg`.
  </ParamField>

  <ParamField header="X-Content-SHA256" type="string">
    Hex SHA-256 of the body. When present we verify it, and a mismatch is rejected with `400`.
  </ParamField>

  <ParamField body="(body)" type="bytes" required>
    The JPEG, 1024 px on the long edge. Smaller originals are not upscaled.
  </ParamField>

  ***

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

  The response has no body. The status says what happened:

  | Status | Meaning |
  | - | - |
  | `201` | Stored. |
  | `200` | Already stored with the same bytes. |
  | `413` | Too large. |
  | `415` | Not a JPEG. |

  ***
</div>

<RequestExample>
  ```bash cURL theme={null}
  curl --request PUT \
    --url "https://rtb.example.com/v1/images/example_gallery/31900001/01" \
    --header "Authorization: <api-key>" \
    --header "Content-Type: image/jpeg" \
    --header "X-Content-SHA256: <sha256-hex>" \
    --data-binary @01.jpg
  ```

  ```python Python theme={null}
  import hashlib
  import requests

  data = open("01.jpg", "rb").read()

  response = requests.put(
      "https://rtb.example.com/v1/images/example_gallery/31900001/01",
      headers={
          "Authorization": "<api-key>",
          "Content-Type": "image/jpeg",
          "X-Content-SHA256": hashlib.sha256(data).hexdigest(),
      },
      data=data,
  )
  print(response.status_code)  # 201 stored, 200 already stored
  ```

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

  const data = readFileSync("01.jpg");

  const response = await fetch(
    "https://rtb.example.com/v1/images/example_gallery/31900001/01",
    {
      method: "PUT",
      headers: {
        Authorization: "<api-key>",
        "Content-Type": "image/jpeg",
        "X-Content-SHA256": createHash("sha256").update(data).digest("hex"),
      },
      body: data,
    },
  );
  console.log(response.status); // 201 stored, 200 already stored
  ```
</RequestExample>

<ResponseExample>
  ```json 415 — not a JPEG theme={null}
  {
    "error": {
      "code": "unsupported_media_type",
      "message": "expected image/jpeg"
    }
  }
  ```
</ResponseExample>


## OpenAPI

````yaml openapi.json PUT /v1/images/{gall_id}/{post_no}/{NN}
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/images/{gall_id}/{post_no}/{NN}:
    put:
      tags:
        - Images
      summary: Upload an Image
      description: >-
        Uploads one image of a post, once per image, in the background. Never on
        the scoring path: a score is not held for images. Sending the same image
        again is safe.
      operationId: putImage
      parameters:
        - name: gall_id
          in: path
          required: true
          schema:
            type: string
            minLength: 1
            example: example_gallery
          description: Gallery id.
        - name: post_no
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
            example: 31900001
          description: Post number.
        - name: NN
          in: path
          required: true
          schema:
            type: string
            pattern: ^\d{2,}$
            example: '01'
          description: >-
            The image's position in the original body: one-based, zero-padded to
            at least two digits, gaps allowed. The v1.3 layout
            `<gall_id>/<post_no>/<NN>.jpg`.
        - name: X-Content-SHA256
          in: header
          required: false
          schema:
            type: string
            pattern: ^[0-9a-f]{64}$
          description: >-
            Hex SHA-256 of the body. When present we verify it, and a mismatch
            is rejected with 400.
      requestBody:
        required: true
        content:
          image/jpeg:
            schema:
              type: string
              format: binary
              description: >-
                The JPEG, 1024 px on the long edge. Smaller originals are not
                upscaled.
      responses:
        '200':
          description: Already stored with the same bytes.
        '201':
          description: Stored.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/TooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
components:
  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
    UnsupportedMediaType:
      description: The body is not a JPEG.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unsupported_media_type
              message: expected image/jpeg
    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
  schemas:
    Error:
      type: object
      properties:
        error:
          $ref: '#/components/schemas/ErrorBody'
      required:
        - error
    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
  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.

````