# Bulk Image Alt Text Generator - AI Captions from Datasets (`automa-flow/bulk-image-alt-text-generator`) Actor

Generate SEO and accessibility alt text for thousands of product images from image URLs or a Dataset. SKU and row kept, 36 languages, length limit, CSV-ready columns. Pay per image.

- **URL**: https://apify.com/automa-flow/bulk-image-alt-text-generator.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** AI, E-commerce, SEO tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 alt text generateds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Bulk Image Alt Text Generator - AI Captions from Datasets

Write alt text for a whole product catalog in one run. Paste image URLs, or point the Actor at the Dataset of a scraper or of Parallel Bulk File Downloader, and every image comes back as one row with your SKU, the image URL and a short, factual alt text that is ready for your store's bulk import. Up to 10,000 images per run, in 36 languages.

The prefilled input describes three openly licensed product photos from Wikimedia Commons: an antique desk chair, a mug and a bottle of olive oil. It finishes in under a minute. You should see three rows with status `SUCCESS` and an `altText` such as "Clear glass bottle filled with golden olive oil and sealed with a cork, beside a small glass bowl." Open the **Alt text** view of the Dataset to see them as a table next to the images.

**Price:** $0.01 per image that gets alt text, platform usage included. The prefill costs about $0.03, and 1,000 images cost $10. Repeated URLs, failed images and retries are free. Update history is in [CHANGELOG.md](CHANGELOG.md).

### Alt text for a Dataset of product images

The usual chain: a scraper, or Parallel Bulk File Downloader, leaves a Dataset with one image URL per row. Point this Actor at it and keep each row's SKU:

```json
{
  "datasetId": "YOUR_DATASET_ID",
  "imageField": "source_url",
  "idField": "metadata.sku",
  "contextFields": ["title", "brand"],
  "language": "en",
  "altTextMaxLength": 125
}
```

`imageField` is the column with the image URL: `source_url` after Parallel Bulk File Downloader, `url` or `imageUrl` after most scrapers, `images.0` for the first entry of a list. Dot paths reach nested fields. `idField` is copied to `sourceId` on each row; after the Downloader the SKU sits in `metadata.sku`. Rows without it use their Dataset row number.

`contextFields` (up to 10) pass product fields such as the title, brand or color to the model as context. It uses them when the image agrees, so "Linen shirt, Acme" in your data becomes part of the alt text, and it never adds a brand that is neither in your data nor printed clearly on the product.

The run reads up to `maxImages` rows (default 1,000, maximum 10,000) from `datasetOffset`. When the Dataset has more rows, `RUN_SUMMARY` gives `nextDatasetOffset` and its `nextAction` names the offset for the next run. A row without an image URL gets `INVALID_INPUT` and the other rows continue.

To run it every time the scraper or Downloader finishes, open that Actor in Apify Console, go to **Integrations**, connect this Actor and use `"datasetId": "{{resource.defaultDatasetId}}"` in the input. Under limited permissions this Actor can read only the Dataset named in `datasetId`; a Dataset ID that sits only inside the integration's `payload` is not read, and the run stops with an `INVALID_INPUT` summary that says how to fix it.

### Import the alt text into your store

Each row has `sourceId` (your SKU or id), `imageUrl` and `altText`. Download them as CSV:

```text
https://api.apify.com/v2/datasets/<datasetId>/items?format=csv&fields=sourceId,imageUrl,altText,status
```

Keep the rows whose `status` is `SUCCESS`, match `sourceId` to your SKU or handle column and `imageUrl` to the image, and put `altText` in the image alt column (in a Shopify product CSV it is called Image Alt Text). A failed row has an empty `altText`, and its `status` says why.

### Language, length and captions

- `language`: one of 36 languages, English by default. The model writes in that language; context fields can stay in any language.
- `altTextMaxLength`: 40-250 characters, 125 by default, which suits most screen readers and store fields. The model aims a little below the limit. A longer answer is cut at the last sentence or clause, never in the middle of a word, and `RUN_SUMMARY.shortenedAltTexts` counts those.
- `includeCaption`: adds a `caption` of one to three sentences with the details that did not fit, at the same price.

The alt text describes what is visible: product type, color, material, pattern, shape, and how it is shown (worn by a model, on a table, in a room). It does not start with "image of", it names no real person and guesses nothing about anyone's identity, age or ethnicity, and it reads a brand only from your data or from large, legible print on the product.

### Output

One row per input image, in the order images finish; sort by `inputIndex` for input order. Field names are camelCase and `schemaVersion` is 1; fields are only ever added. The values below are illustrative.

```json
{
  "inputIndex": 12,
  "sourceId": "SHIRT-LINEN-BLUE-M",
  "imageUrl": "https://cdn.example-shop.com/p/shirt-blue.jpg",
  "status": "SUCCESS",
  "altText": "Light blue linen shirt with a button-down collar and chest pocket, laid flat on white.",
  "caption": null,
  "language": "en",
  "chars": 87,
  "model": "gpt-6-luna",
  "imageSha256": "9f2c...",
  "charged": true,
  "duplicateOf": null,
  "errorCode": null,
  "error": null,
  "scrapedAt": "2026-10-23T12:00:00Z",
  "schemaVersion": 1
}
```

When the same URL appears more than once, it is downloaded and described once. The first row carries the charge (`charged: true`); the others get the same alt text for free and point at it with `duplicateOf`. `imageSha256` is the hash of the downloaded image, so a later run can tell that an image did not change.

Dataset views:

- **Alt text**: `sourceId`, the image, `status`, `altText`, `caption`, `language` and `chars`.
- **Status and errors**: `sourceId`, the image URL, `status`, `errorCode` and `error`.
- **All rows**: every row with its charge and duplicate details.

Views choose columns, not rows: every view lists all rows, so filter on `status` for the successful ones.

`RUN_SUMMARY` in the Key-Value Store holds the overall status (`SUCCESS`, `PARTIAL`, `ALL_FAILED`, `NO_RESULTS` or `FAILED`), counts per row status, charged images, Dataset paging and `nextAction`. Read it first. When some images can be finished by another run (time limit, charge limit, a model or network error), `RETRY_INPUT` holds them with their ids and product fields: paste it as the JSON input of a new run. Successful images are not in it, so they are never paid for twice.

### Pricing

| Event | When it is charged | Price |
| --- | --- | --- |
| `apify-actor-start` | Actor start, once per GB of memory (minimum one) | $0.00005 |
| `alt-text-generated` | one unique image that got a valid alt text, charged when its row is stored | $0.01 |

Examples: the prefill (3 images) is about $0.03. 1,000 images cost $10, and a Dataset of 5,000 product photos costs $50. Platform usage is included in the price. Console runs start with a $10 limit from the default run options; raise `maxTotalChargeUsd` for a bigger batch, and pass your own limit on API starts.

Failed downloads, files that are not images, refusals, model errors, duplicate URLs, retries and images stopped by the run timeout are free. Before each model call the run reserves one event inside `maxTotalChargeUsd`; when the limit cannot pay for another image, the remaining images get `BUDGET_EXCEEDED`, uncharged, and go to `RETRY_INPUT`. Rows that already succeeded stay in the Dataset.

### Limits

- 1-10,000 input rows per run (`maxImages`), 1-20 images at a time (`concurrency`, default 8), and at most 4 downloads at once from the same host so a shop's image server is not flooded
- JPEG, PNG, WebP, GIF (first frame), AVIF, TIFF and BMP; SVG is not supported
- 30 MB per file, 60 megapixels per image, at least 16 pixels on each side
- public `http` and `https` URLs on ports 80 and 443; a protocol-relative `//cdn.example.com/a.jpg`, common in shop exports, is read as `https`
- the model sees each image at most 512 pixels on its longest side; transparent areas are shown on white
- memory 1024 MB by default; run timeout 1 hour by default. About 2-4 seconds per image at concurrency 8, so 1,000 images take roughly 5-10 minutes

Near the run timeout, images that cannot start in time get `RUN_TIME_LIMIT`, uncharged, and the run still finishes with its summary and `RETRY_INPUT`.

### API, n8n, Make and MCP

Start the Actor with the Apify run API and one of the JSON inputs above. In n8n use the Apify node, or an HTTP Request node that POSTs to `https://api.apify.com/v2/acts/automa-flow~bulk-image-alt-text-generator/run-sync-get-dataset-items?view=altText` with your Apify token in the `Authorization` header: it waits for the run and returns one row per image; keep those whose `status` is `SUCCESS`. In Make, use the Apify app's "Run an Actor" module with "wait for finish", then "Get Dataset Items" with view `altText`.

API callers can send images with their own ids and product fields in `images`:

```json
{
  "images": [
    {"url": "https://cdn.example-shop.com/p/1.jpg", "id": "SKU-1", "context": {"title": "Oak side table"}},
    {"url": "https://cdn.example-shop.com/p/2.jpg", "id": "SKU-2"}
  ],
  "language": "de"
}
```

Ask an agent: "Write English alt text for these product image URLs and keep my SKUs." The direct MCP endpoint is <https://mcp.apify.com?tools=automa-flow/bulk-image-alt-text-generator>. An agent should pass image file URLs or a Dataset id, bound the call with `maxImages` and `maxTotalChargeUsd`, read `RUN_SUMMARY` first, and on `PARTIAL` or `ALL_FAILED` resend only `RETRY_INPUT`. Execution uses the caller's Apify account.

A second run usually means new products: schedule the Actor over the Dataset your scraper refreshes, or connect it as an integration so each new batch of product images gets alt text without manual steps.

### Failure statuses

| Status | Meaning |
| --- | --- |
| `SUCCESS` | Alt text written and stored |
| `INVALID_INPUT` | No image URL in the row, an unsupported scheme, or a private or local address |
| `IMAGE_FETCH_FAILED` | HTTP 404, 403 or another HTTP error, DNS or network failure, download timeout |
| `NOT_AN_IMAGE` | The URL returned a web page, an SVG or another file that is not a readable image, or a damaged image |
| `IMAGE_TOO_LARGE` | Over 30 MB or 60 megapixels |
| `IMAGE_TOO_SMALL` | Under 16 pixels on a side, usually a placeholder or tracking pixel |
| `MODEL_REFUSED` | The model declined to describe the image |
| `MODEL_FAILED` | The model returned no usable alt text or was unavailable |
| `BUDGET_EXCEEDED` | `maxTotalChargeUsd` could not pay for another image |
| `RUN_TIME_LIMIT` | The run timeout came before the image finished |

Only `SUCCESS` rows have alt text and only they are charged. The run finishes successfully when per-image failures are the only problem. It fails the whole run when the model service cannot be used at all (for example it rejects the Actor's key or is down for every image), when the run timeout left no image started, or when the Dataset cannot be read or written; rows that were already stored stay in the Dataset.

If a run fails or looks wrong, open an Issue and include the run URL. Apify shares that run with us automatically, so there is no need to paste your input.

### Responsible use and privacy

Process only images you have the rights or permission to use. You need the rights or permission to process the files you submit.

Each image is downloaded by this Actor, reduced to at most 512 pixels as a JPEG and sent, with the product fields you selected, to OpenAI's API (model `gpt-6-luna`) to write the alt text. Requests are sent with response storage turned off (`store: false`). OpenAI's API terms apply to that processing. This Actor does not keep the images: each downloaded file is deleted once it is described. The run log shows image hosts and paths with query values redacted, and counts; it never shows alt texts. The prompt tells the model to describe people only by what they wear or do, and not to identify anyone.

Image URLs are fetched with a normal HTTP client that allows `http` and `https` on ports 80 and 443, rejects embedded passwords, localhost, private, reserved and metadata addresses, checks DNS before the request and on every redirect, and stops at the byte cap. It sends no cookies and no `Authorization` header to the image host.

### Technical details

Python 3.12 on the Apify Python image, Pillow for decoding and resizing, and one OpenAI Responses API call per unique image with a strict JSON schema and reasoning turned off. Model calls are retried up to three times on rate limits, server errors and broken connections, with backoff; refusals, bad requests and timeouts are not retried. Ten model outages in a row stop the batch instead of hammering a broken service. A restarted run (for example after a platform migration) skips every row already in the Dataset and never charges an image twice.

# Changelog

This Actor's version history is a separate document: https://apify.com/automa-flow/bulk-image-alt-text-generator/changelog.md

# Actor input Schema

## `imageUrls` (type: `array`):

Public http(s) URLs of JPEG, PNG, WebP, GIF or AVIF images, one per entry. Ignored when Dataset ID is set.

## `imageUrlsText` (type: `string`):

Paste many image URLs at once, one per line. Added after Image URLs.

## `datasetId` (type: `string`):

Apify Dataset to read instead of pasted URLs. In an integration, set it to {{resource.defaultDatasetId}}. A nested resource id is not read.

## `imageField` (type: `string`):

Field with the image URL in each row, as a dot path. url after most scrapers, source_url after Parallel Bulk File Downloader, images.0 for the first entry of a list.

## `idField` (type: `string`):

Optional field copied to sourceId on each row, for example sku, productId or handle. Rows without it use their Dataset row number.

## `contextFields` (type: `array`):

Up to 10 fields passed to the model as product context, for example title, brand or color. The model uses them when the image agrees and never invents a brand that is not given or visible.

## `datasetOffset` (type: `integer`):

Skip this many rows, then read up to Max images. When rows remain, RUN_SUMMARY gives nextDatasetOffset for the next run.

## `language` (type: `string`):

Language of the alt text.

## `altTextMaxLength` (type: `integer`):

Longest alt text to write. 125 suits most screen readers and stores. 40-250.

## `includeCaption` (type: `boolean`):

Add a longer description of one to three sentences in the caption column. Same price.

## `maxImages` (type: `integer`):

Most input rows to process in this run, duplicates included. 1-10,000. Pasted URLs beyond it go to RETRY_INPUT; Dataset rows beyond it are read with datasetOffset.

## `concurrency` (type: `integer`):

Images processed at the same time. 1-20.

## `images` (type: `array`):

For API calls and RETRY_INPUT: objects with url, an optional id (your SKU) and an optional context object of product fields. Added after the pasted URLs.

## `payload` (type: `object`):

Added by Apify Actor-to-Actor integrations. Not read: pass the Dataset in Dataset ID.

## Actor input object example

```json
{
  "imageUrls": [
    "https://upload.wikimedia.org/wikipedia/commons/c/cf/Desk_chair_MET_1970.181.2%2C3.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Coffee_steaming_in_a_mug.jpg/960px-Coffee_steaming_in_a_mug.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Bottle_of_olive_oil.jpg/960px-Bottle_of_olive_oil.jpg"
  ],
  "imageField": "url",
  "datasetOffset": 0,
  "language": "en",
  "altTextMaxLength": 125,
  "includeCaption": false,
  "maxImages": 1000,
  "concurrency": 8
}
```

# Actor output Schema

## `altText` (type: `string`):

No description

## `results` (type: `string`):

No description

## `csv` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

## `retryInput` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "imageUrls": [
        "https://upload.wikimedia.org/wikipedia/commons/c/cf/Desk_chair_MET_1970.181.2%2C3.jpg",
        "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Coffee_steaming_in_a_mug.jpg/960px-Coffee_steaming_in_a_mug.jpg",
        "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Bottle_of_olive_oil.jpg/960px-Bottle_of_olive_oil.jpg"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/bulk-image-alt-text-generator").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "imageUrls": [
        "https://upload.wikimedia.org/wikipedia/commons/c/cf/Desk_chair_MET_1970.181.2%2C3.jpg",
        "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Coffee_steaming_in_a_mug.jpg/960px-Coffee_steaming_in_a_mug.jpg",
        "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Bottle_of_olive_oil.jpg/960px-Bottle_of_olive_oil.jpg",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/bulk-image-alt-text-generator").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "imageUrls": [
    "https://upload.wikimedia.org/wikipedia/commons/c/cf/Desk_chair_MET_1970.181.2%2C3.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Coffee_steaming_in_a_mug.jpg/960px-Coffee_steaming_in_a_mug.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Bottle_of_olive_oil.jpg/960px-Bottle_of_olive_oil.jpg"
  ]
}' |
apify call automa-flow/bulk-image-alt-text-generator --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/bulk-image-alt-text-generator"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/IB13UZmGMjS1i1EXT/builds/o7WeQ1WPTTPH3Xq9x/openapi.json
