openapi: 3.1.0
info:
  title: ReWords.AI Edit Text in Image API
  version: 1.0.0
  contact:
    name: ReWords.AI support
    email: support@rewords.ai
    url: https://rewords.ai/developers/
  license:
    name: Proprietary
    url: https://rewords.ai/terms/
  description: |
    Replace text inside images while keeping the original font, colour and background.

    **Flow**

    1. `POST /ocr` finds every line of text and its position (optional).
    2. `POST /edits` submits the replacements. Send a `box` for each one, or only the old text and we locate it.
    3. `GET /edits/{id}` (or a webhook) returns the results.

    **Results.** Every successful job returns result **A** (AI edit) and result **B** (blended into the original).
    Result **C** (4K) is not guaranteed: our system adds it automatically when the image is suitable and reports why
    when it is skipped.

    **Billing.** An edit is charged once, only when A or B succeeds. If both fail the call is free and the response
    explains why. Changing several pieces of text in one image counts as one edit.
    OCR costs $0.01 per call (bundled with every edit pack, or $20 for 2,000 calls). OCR that runs inside an edit is
    free, and the same image is counted once within 24 hours.

    **Queue.** Up to 10 jobs per account run at once and up to 30 wait in the queue. A job may run for up to 10 minutes
    after it starts.

    **Rate limits.** Per account, shared by its keys: 30 submissions (`POST`) and 240 reads (`GET`) per minute.

    **Images.** JPEG, PNG or WebP up to 20 MB, longest side up to 8000 px. Results are JPEG files at public links.
    See the Images guide for every upload method, EXIF, transparency and result sizes.

    Boxes are `[x, y, width, height]` normalised to 0–1000 of the image size, measured from the top-left corner.
servers:
  - url: https://api.rewords.ai/v1
security:
  - bearerAuth: []
tags:
  - name: OCR
    description: Find text and its position in an image.
  - name: Edits
    description: Replace text in an image.
  - name: Account
    description: Balances and limits.

paths:
  /ocr:
    post:
      tags: [OCR]
      operationId: createOcr
      summary: Find text in an image
      description: |
        Returns every line of text with its position. Costs 1 OCR call (the same image file within 24 hours is counted
        once). Every call returns a new `image_id`, which can be reused in `POST /edits` for 24 hours.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OcrRequest' }
            example: { image_url: 'https://example.com/banner.png', language: en }
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image: { $ref: '#/components/schemas/ImageFile' }
                language: { $ref: '#/components/schemas/Language' }
      responses:
        '200':
          description: Text found in the image.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OcrResult' }
              example:
                image_id: img_118ba6012ee7
                width: 2560
                height: 1302
                texts:
                  - { id: t1, text: SALE, box: [120, 80, 300, 60] }
                  - { id: t2, text: 50% OFF, box: [140, 160, 260, 50] }
                  - { id: t3, text: Summer Collection, box: [100, 700, 800, 80] }
                charged_ocr: 1
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: '`insufficient_ocr_balance` — no OCR calls left.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: insufficient_ocr_balance, message: No OCR calls left. Buy an OCR pack., details: {} } }
        '403': { $ref: '#/components/responses/Blocked' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /edits:
    post:
      tags: [Edits]
      operationId: createEdit
      summary: Replace text in an image
      description: |
        Queues an edit and returns immediately. Pass `callback_url`, or poll `GET /edits/{id}`: every 5 seconds while
        the job is `processing` or `queued` with `queue_position` 0, and every 30 seconds while it is further back
        in the queue. One edit is reserved now and refunded automatically if neither A nor B
        succeeds.

        If a replacement has no `box`, we read the image before answering, so this call usually takes 5–20 seconds
        (allow 90) and can return `422 text_not_found`.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Resending the same key within 24 hours returns `202` with the original job's `id` and its current status
            (it may already be `processing` or finished). It is not charged again, even if the request body differs.
            Keys are per account, shared by all of its API keys.
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditRequest' }
            example:
              image_id: img_abc123
              replacements:
                - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
                - { from: 50% OFF, to: 70% OFF }
              match: all
              language: en
              callback_url: https://example.com/rewords-webhook
          multipart/form-data:
            schema:
              type: object
              required: [image, replacements]
              properties:
                image: { $ref: '#/components/schemas/ImageFile' }
                replacements:
                  type: string
                  description: 'The `replacements` array as a JSON string, e.g. `[{"from":"SALE","to":"HOT DEAL"}]`.'
                  example: '[{"from":"SALE","to":"HOT DEAL"}]'
                match: { type: string, enum: [all, first], default: all }
                language: { $ref: '#/components/schemas/Language' }
                callback_url: { type: string, format: uri, pattern: '^https://' }
      responses:
        '202':
          description: Edit accepted and queued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Edit' }
              example:
                id: edit_9f8e7d
                status: queued
                queue_position: 3
                estimated_seconds: 210
                replacements:
                  - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
                  - { from: 50% OFF, to: 70% OFF, box: [140, 160, 260, 50] }
                balance: 299
                created_at: '2026-11-04T10:00:00Z'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: '`insufficient_balance` — no edits left.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: insufficient_balance, message: No edits left. Buy an edit pack., details: {} } }
        '403': { $ref: '#/components/responses/Blocked' }
        '422':
          description: |
            `text_not_found` — a replacement without `box` matched no text. Not charged. `error.details.texts`
            contains the OCR result so you can retry with boxes. Only returned here, never as a job failure.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: text_not_found
                  message: 'No text matched "50% OFF".'
                  details:
                    unmatched: ['50% OFF']
                    texts: [{ id: t1, text: SALE, box: [120, 80, 300, 60] }, { id: t2, text: 40% OFF, box: [140, 160, 260, 50] }]
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: '`queue_full` — 30 jobs are already waiting. Retry after `Retry-After` seconds. Not charged.'
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: queue_full, message: 30 jobs are already waiting. Retry in 30 seconds., details: {} } }
        '500':
          description: |
            `upstream_failed` — something failed on our side. You cannot tell whether a job was created: retry with the
            same `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: { code: upstream_failed, message: Something went wrong on our side. Retry shortly., details: {} } }

  /edits/{id}:
    get:
      tags: [Edits]
      operationId: getEdit
      summary: Get an edit and its results
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          example: edit_9f8e7d
      responses:
        '200':
          description: The edit.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Edit' }
              examples:
                succeeded:
                  summary: Succeeded (charged 1)
                  value:
                    id: edit_9f8e7d
                    status: succeeded
                    results:
                      A: { status: succeeded, url: 'https://img.rewords.ai/api/edit_9f8e7d_a.jpg' }
                      B: { status: succeeded, url: 'https://img.rewords.ai/api/edit_9f8e7d_b.jpg' }
                      C: { status: skipped, reason: aspect_ratio_unsupported }
                    replacements:
                      - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
                    charged: 1
                    balance: 299
                    created_at: '2026-11-04T10:00:00Z'
                    completed_at: '2026-11-04T10:01:12Z'
                processing:
                  summary: Still running
                  value:
                    id: edit_9f8e7d
                    status: processing
                    replacements:
                      - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
                    balance: 299
                    created_at: '2026-11-04T10:00:00Z'
                failed:
                  summary: Failed (not charged)
                  value:
                    id: edit_9f8e7d
                    status: failed
                    results:
                      A: { status: failed, reason: ai_generation_failed }
                      B: { status: failed, reason: ai_generation_failed }
                      C: { status: skipped, reason: ai_generation_failed }
                    replacements:
                      - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
                    charged: 0
                    balance: 300
                    error: { code: ai_generation_failed, message: The AI model could not produce a usable image., details: {} }
                    created_at: '2026-11-04T10:00:00Z'
                    completed_at: '2026-11-04T10:01:12Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Get balances and limits
      responses:
        '200':
          description: Balances and limits for the API key's account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
              example:
                edits_remaining: 299
                ocr_remaining: 1840
                packs:
                  - { kind: edits, remaining: 299, expires_at: '2027-11-04T10:00:00Z' }
                  - { kind: ocr, remaining: 1840, expires_at: '2027-11-04T10:00:00Z' }
                limits: { requests_per_minute: 30, read_requests_per_minute: 240, concurrent_jobs: 10, max_queued_jobs: 30 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

webhooks:
  editCompleted:
    post:
      operationId: editCompleted
      summary: An edit finished
      description: |
        Sent to `callback_url` when an edit reaches `succeeded` or `failed`. The body is the same as `GET /edits/{id}`.
        Verify `X-Rewords-Signature: sha256=<64 hex characters: HMAC-SHA256 of the raw body with your webhook secret>`.
        The webhook secret belongs to your account (one for all keys) and can be reset on the API keys page.

        Any response other than `2xx` within 10 seconds counts as failed. A failed delivery is retried 1, 5 and 30
        minutes after the previous attempt (the last one about 36 minutes after the first); after that we stop, and
        `GET /edits/{id}` still returns the result. Every retry sends the same bytes with the same signature.
        A delivery can arrive more than once, so ignore an `id` you have already handled.
      security: []
      parameters:
        - name: X-Rewords-Signature
          in: header
          required: true
          schema: { type: string, pattern: '^sha256=[0-9a-f]{64}$' }
          example: sha256=4df15417117b648c72e2a97d528973c3cf83dd05fae0bbf139b18c121cdd9386
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Edit' }
            example:
              id: edit_9f8e7d
              status: succeeded
              results:
                A: { status: succeeded, url: 'https://img.rewords.ai/api/edit_9f8e7d_a.jpg' }
                B: { status: succeeded, url: 'https://img.rewords.ai/api/edit_9f8e7d_b.jpg' }
                C: { status: skipped, reason: aspect_ratio_unsupported }
              replacements:
                - { from: SALE, to: HOT DEAL, box: [120, 80, 300, 60] }
              charged: 1
              balance: 299
              created_at: '2026-11-04T10:00:00Z'
              completed_at: '2026-11-04T10:01:12Z'
      responses:
        '200': { description: Return any 2xx within 10 seconds to acknowledge. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'Send `Authorization: Bearer rw_live_...`. Create up to 5 keys at https://rewords.ai/developers/keys/ once your account has API balance.'

  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 1 }

  responses:
    BadRequest:
      description: |
        `invalid_request` (malformed body, or not exactly one image input) or `invalid_image` (not JPEG/PNG/WebP,
        over 20 MB, longest side over 8000 px, CMYK, or `image_url` could not be downloaded). Not charged.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: invalid_image, message: 'Unsupported format: image/gif. Send JPEG, PNG or WebP.', details: {} } }
    Unauthorized:
      description: '`invalid_api_key` — missing, unknown or revoked key.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: invalid_api_key, message: Missing or invalid API key., details: {} } }
    Blocked:
      description: '`image_blocked` — the image failed content review. Not charged.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: image_blocked, message: The image failed content review., details: {} } }
    NotFound:
      description: '`not_found` — no edit with this id for your account.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: No edit with this id., details: {} } }
    RateLimited:
      description: |
        `rate_limited` — too many requests. Per account, shared by its keys: 30 submissions (`POST`) and 240 reads
        (`GET`) per minute.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: rate_limited, message: Too many requests. Retry in 12 seconds., details: {} } }

  schemas:
    Language:
      type: string
      description: |
        Main language of the text: `en`, `zh`, `ja`, `ko`, `fr`, `de`, `es`, `pt`, `it` or `ru`. Case and a region part
        are ignored (`pt-BR` counts as `pt`). The language is not detected automatically: without it, or with any other
        value, OCR reads Chinese and English (usually Japanese too), and other languages such as Korean or Russian come
        out wrong. Send it whenever you know it. In an edit it is also used when we read the text: to find `from`
        when a replacement has no `box`, and to fit the new text into result B.
      example: en

    ImageFile:
      type: string
      format: binary
      description: |
        JPEG, PNG or WebP file, up to 20 MB (20 × 1024 × 1024 bytes), longest side up to 8000 px and shortest side at
        least 32 px. The format is detected from the file's bytes, so the part's `Content-Type` and file name do not
        matter.

    ImageInput:
      description: |
        Provide exactly one of `image_id`, `image_url` or `image_base64`; none or more than one returns
        `400 invalid_request`.
      oneOf:
        - title: image_url
          type: object
          required: [image_url]
          properties:
            image_url:
              type: string
              format: uri
              description: |
                Public `http(s)` URL of a JPEG, PNG or WebP image up to 20 MB. We download it once, while handling the
                request; we follow up to 3 redirects and give up after 15 seconds. A URL that cannot be downloaded, or
                that points to a private network address (such as `localhost`, `10.x.x.x` or `192.168.x.x`), returns
                `400 invalid_image`.
        - title: image_base64
          type: object
          required: [image_base64]
          properties:
            image_base64:
              type: string
              description: |
                Standard base64 of the file (`A–Z a–z 0–9 + /`, with or without `=` padding), or a data URL such as
                `data:image/png;base64,...` with any media type (the format is detected from the bytes). Spaces and
                line breaks are ignored. The whole JSON body may be up to 28 MB. Text that is not valid base64
                returns `400 invalid_request`.
        - title: image_id
          type: object
          required: [image_id]
          properties:
            image_id:
              type: string
              example: img_abc123
              description: |
                From a previous `POST /ocr` on this account; valid for 24 hours. An unknown, expired or other
                account's id returns `400 invalid_request`.

    OcrRequest:
      allOf:
        - $ref: '#/components/schemas/ImageInput'
        - type: object
          properties:
            language: { $ref: '#/components/schemas/Language' }

    Box:
      type: array
      description: |
        `[x, y, width, height]` as whole numbers on a 0–1000 scale of the image width and height, measured from the
        top-left corner of the upright image. `width` and `height` are at least 1; `x + width` and `y + height` are
        at most 1000.
        Pixels: `left = x / 1000 * imageWidth`, `top = y / 1000 * imageHeight`.
      items: { type: integer, minimum: 0, maximum: 1000 }
      minItems: 4
      maxItems: 4
      example: [120, 80, 300, 60]

    OcrText:
      type: object
      required: [id, text, box]
      properties:
        id: { type: string, example: t1 }
        text: { type: string, example: SALE }
        box: { $ref: '#/components/schemas/Box' }

    OcrResult:
      type: object
      required: [image_id, width, height, texts]
      properties:
        image_id: { type: string, example: img_abc123 }
        width: { type: integer, description: Pixel width after applying EXIF orientation., example: 1080 }
        height: { type: integer, description: Pixel height after applying EXIF orientation., example: 1350 }
        texts:
          type: array
          items: { $ref: '#/components/schemas/OcrText' }
        charged_ocr: { type: integer, description: OCR calls charged (0 if this image was read in the last 24 hours)., example: 1 }

    Replacement:
      type: object
      required: [from, to]
      properties:
        from:
          type: string
          maxLength: 200
          description: |
            Text currently in the image. Required to be non-empty when `box` is omitted. Matching ignores case and
            surrounding spaces. If some lines equal `from`, only those lines change; otherwise lines that contain it
            change, and in them only the matched part, at every occurrence in the line: `OFF` → `ON` on the line
            `50% OFF` gives `50% ON`. If nothing matches, one more try ignores every space (`여름 세일` finds `여름세일`).
          example: SALE
        to:
          type: string
          maxLength: 200
          description: |
            Replacement text. `\n` starts a new line. An empty string removes the text and fills in the background.
            `from` and `to` are limited to 200 Unicode characters each in the request; the full lines shown in the
            response can be longer. In the response, `from` and `to` are the OCR reading of the whole line with your
            change applied, so they can be lower case or miss spaces; the new text is drawn in the style of the
            original line.
          example: HOT DEAL
        box:
          $ref: '#/components/schemas/Box'
          description: Where the text is. Omit it and we locate `from` with OCR (free inside an edit).

    EditRequest:
      allOf:
        - $ref: '#/components/schemas/ImageInput'
      type: object
      required: [replacements]
      properties:
        replacements:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: '#/components/schemas/Replacement' }
        match:
          type: string
          enum: [all, first]
          default: all
          description: |
            Only for replacements without `box`: replace every matching line (`all`) or the first one, top to bottom,
            left to right (`first`). Matching ignores case and surrounding spaces, and tries an exact line first, then a
            line that contains the text, then both again ignoring every space.
        language: { $ref: '#/components/schemas/Language' }
        callback_url: { type: string, format: uri, pattern: '^https://', description: An `https` URL that receives the `editCompleted` webhook. }

    VariantResult:
      type: object
      required: [status]
      properties:
        status: { type: string, enum: [succeeded, failed, skipped] }
        url:
          type: string
          format: uri
          description: |
            Present when succeeded. A JPEG file at a public link (no API key needed to download). Kept for at least
            7 days; download it if you need it longer. Send a `User-Agent` header: Python `urllib`'s default one is
            refused with `403`.
        reason:
          type: string
          description: |
            Present when failed or skipped: `aspect_ratio_unsupported` (C only, wider or taller than 3:1),
            `ai_generation_failed`, `content_blocked`, `timeout`, `upstream_failed` or `invalid_image`.

    Edit:
      type: object
      required: [id, status]
      properties:
        id: { type: string, example: edit_9f8e7d }
        status: { type: string, enum: [queued, processing, succeeded, failed] }
        queue_position: { type: integer, description: 'While queued: jobs waiting ahead of this one. Jobs already running are not counted, so 0 means next to start.' }
        estimated_seconds: { type: integer, description: 'While queued, our estimate of the seconds until this job will finish.' }
        results:
          type: object
          properties:
            A: { $ref: '#/components/schemas/VariantResult' }
            B: { $ref: '#/components/schemas/VariantResult' }
            C: { $ref: '#/components/schemas/VariantResult' }
        replacements:
          type: array
          description: |
            Replacements as applied: one item per line that is changed, with the box we located and the line's full
            text in `from` and `to`. Items keep the order of your request; a replacement that matched several lines
            becomes several items, top to bottom.
          items: { $ref: '#/components/schemas/Replacement' }
        charged: { type: integer, description: 'Edits charged (1 when A or B succeeded, otherwise 0).' }
        balance:
          type: integer
          description: |
            Edits left on the account when this response was made — the same as `edits_remaining` in `GET /account`
            (edits reserved for unfinished jobs, including this one, are already taken off).
        error:
          $ref: '#/components/schemas/ErrorBody'
          description: |
            Present when `failed`. `code` is one of `content_blocked`, `ai_generation_failed`, `timeout`,
            `upstream_failed` or `invalid_image` (the file passed the upload checks but could not be decoded).
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }

    Account:
      type: object
      properties:
        edits_remaining: { type: integer, example: 299 }
        ocr_remaining: { type: integer, example: 1840 }
        packs:
          type: array
          items:
            type: object
            properties:
              kind: { type: string, enum: [edits, ocr] }
              remaining: { type: integer }
              expires_at: { type: string, format: date-time }
        limits:
          type: object
          properties:
            requests_per_minute: { type: integer, description: Submissions (`POST`) per minute., example: 30 }
            read_requests_per_minute: { type: integer, description: Reads (`GET`) per minute., example: 240 }
            concurrent_jobs: { type: integer, example: 10 }
            max_queued_jobs: { type: integer, example: 30 }

    ErrorBody:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            One of the codes below. New codes may be added within `/v1`, so treat an unknown code like `upstream_failed`.
          examples:
            - invalid_api_key
            - insufficient_balance
            - insufficient_ocr_balance
            - image_blocked
            - not_found
            - invalid_image
            - invalid_request
            - text_not_found
            - rate_limited
            - queue_full
            - upstream_failed
            - ai_generation_failed
            - content_blocked
            - timeout
        message: { type: string }
        details: { type: object, additionalProperties: true }

    Error:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/ErrorBody' }
