{"id":15032,"date":"2026-10-29T13:54:23","date_gmt":"2026-10-29T11:54:23","guid":{"rendered":"https:\/\/shortpixel.com/blog\/?p=15032"},"modified":"2026-10-29T13:57:12","modified_gmt":"2026-10-29T11:57:12","slug":"generate-ai-image-seo-metadata-shortpixel-api","status":"publish","type":"post","link":"https:\/\/shortpixel.com\/blog\/generate-ai-image-seo-metadata-shortpixel-api\/","title":{"rendered":"How to Generate AI Image SEO Metadata With the ShortPixel API (cURL, PHP, JavaScript &amp; Python Examples)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Search engines can analyze what&#8217;s in an image, but descriptive alt text, captions and titles still give them, and your visitors, useful context. When those fields are empty, you&#8217;re leaving all the explaining to a machine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a blog with 50 images, that&#8217;s an afternoon of work. For a store with 8,000 product photos, it&#8217;s a backlog that never gets cleared.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s what the ShortPixel AI Image SEO API is for. You send the URL of an image, and you get back a title, alt text, a caption and a longer description. It can also suggest a better file name. And it isn&#8217;t tied to WordPress: any website or app that can make an HTTP request can use it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what we&#8217;ll cover: how the two API calls work, how to get better results by giving the AI some context, and how to call it from cURL, PHP, JavaScript and Python. Further down there&#8217;s a script that tags a small product catalog and saves everything to a CSV file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Using WordPress? You can generate AI image metadata<\/em> <a href=\"https:\/\/shortpixel.com\/blog\/seo-alt-text-titles-and-descriptions-in-bulk-with-shortpixel-ai\/\"><em>straight from the Media Library<\/em><\/a> <em>with the <a href=\"https:\/\/wordpress.org\/plugins\/shortpixel-image-optimiser\/\" target=\"_blank\" rel=\"noopener\">ShortPixel Image Optimizer<\/a> or <a href=\"https:\/\/wordpress.org\/plugins\/shortpixel-ai-alt-text-generator\/\" target=\"_blank\" rel=\"noopener\">ShortPixel AI ALT Text Generator<\/a> plugin, so you don&#8217;t need to build an API integration. This article is for custom CMSs, e-commerce platforms, DAM systems and scripts.<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>How the API works<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>It&#8217;s asynchronous.<\/strong> Generating text from an image takes a few seconds, and keeping a connection open that long doesn&#8217;t scale when you have thousands of files. So the work is split into two calls:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>POST \/add-url.php queues an image and returns a job id.<\/li>\n\n\n\n<li>POST \/get-url.php takes that id and returns the job status. When the job is finished, the generated text is in the response.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The base URL is https:\/\/capi-gpt.shortpixel.com, and everything is JSON (Content-Type: application\/json).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The image has to be public.<\/strong> Our servers download it themselves. JPG, PNG, GIF, WebP and AVIF are supported.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The API doesn&#8217;t publish anything.<\/strong> It generates the metadata, and saving it in your CMS or database is up to you.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Authentication<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your ShortPixel API key goes in the Authorization header. Your plan must include the AI Image SEO feature.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Authorization: ApiKey YOUR_API_KEY<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Calls made with the key also return a session token (JWT) that&#8217;s valid for one hour. You can send it instead of the key, as Authorization: Bearer YOUR_JWT. Once it expires, the API answers with HTTP 401 and you have to authenticate with the key again. If your integration makes only a few calls, sending the key every time is simpler.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One key per site.<\/strong> We recommend creating an alias API key for each site and setting its allowed domains. Requests for images on any other domain are then rejected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep the key on your server.<\/strong> It should never appear in browser or mobile app code.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Submit an image<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Only url is required. You get back just the fields you ask for.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Field<\/strong><\/td><td><strong>What it does<\/strong><\/td><\/tr><tr><td>url<\/td><td>Public image URL<\/td><\/tr><tr><td>version<\/td><td>Recommended: send &#8220;v_2&#8221;. Your quota is then checked up front, so an over-quota request is rejected immediately<\/td><\/tr><tr><td>languages<\/td><td>Output language as an ISO 639-1 code (en, de), optionally with a country (pt-BR). Default is en<\/td><\/tr><tr><td>context<\/td><td>General description of the image or page, up to 500 characters<\/td><\/tr><tr><td>title<\/td><td>Max 100 characters, default 20<\/td><\/tr><tr><td>alt<\/td><td>Max 200 characters, default 72<\/td><\/tr><tr><td>caption<\/td><td>Max 250 characters, default 128<\/td><\/tr><tr><td>image_description<\/td><td>Max 500 characters, default 300<\/td><\/tr><tr><td>file<\/td><td>SEO-friendly file name, 1 to 100 characters, default 48<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">title, alt, caption, image_description and file are objects. Each takes chars, the maximum length, and an optional context with instructions for that field only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a request for a product photo:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n\n\u00a0\u00a0\"url\": \"https:\/\/example.com\/images\/IMG_2041.jpg\",\n\n\u00a0\u00a0\"version\": \"v_2\",\n\n\u00a0\u00a0\"languages\": \"en\",\n\n\u00a0\u00a0\"context\": \"Product photo for an online garden furniture store\",\n\n\u00a0\u00a0\"title\": { \"chars\": 60 },\n\n\u00a0\u00a0\"alt\": { \"chars\": 120, \"context\": \"describe for accessibility and search\" },\n\n\u00a0\u00a0\"caption\": { \"chars\": 150 },\n\n\u00a0\u00a0\"image_description\": { \"chars\": 300 },\n\n\u00a0\u00a0\"file\": {\n\n\u00a0\u00a0\u00a0\u00a0\"existing_filename\": \"IMG_2041\",\n\n\u00a0\u00a0\u00a0\u00a0\"prefer_keep_filename_if_relevant\": true,\n\n\u00a0\u00a0\u00a0\u00a0\"chars\": 60\n\n\u00a0\u00a0},\n\n\u00a0\u00a0\"use_parent_post_title\": true,\n\n\u00a0\u00a0\"parent_post_title\": \"Teak garden bench, 3 seats\",\n\n\u00a0\u00a0\"use_parent_post_category\": true,\n\n\u00a0\u00a0\"parent_post_category_list\": &#91;\"Garden furniture\", \"Benches\"]\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the job was queued, the response contains an id:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{ \"id\": 3786, \"status\": 0 }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If it contains error instead, no job was created.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Give the AI some context<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the part that makes the biggest difference. A model that only sees pixels will describe your bench as &#8220;a wooden bench on grass.&#8221; Accurate, but not very useful. Tell the API the page is about a three-seat teak bench from a garden furniture store, and you can get a much more relevant description. It&#8217;s still AI-generated text, so read it before it goes live, especially for products.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can pass context in three places:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Top level:<\/strong> context applies to the whole request, for example the kind of store you run.<\/li>\n\n\n\n<li><strong>Per field:<\/strong> context inside a field applies to that field only, like &#8220;describe for accessibility and search&#8221; on alt.<\/li>\n\n\n\n<li><strong>Page data:<\/strong> parent_post_title and parent_post_category_list send the title and categories of the page that shows the image. Turn them on with use_parent_post_title and use_parent_post_category.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>File names<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add a file object and the result includes generated_file_name, a lowercase slug without extension, like teak-garden-bench-three-seats. Pass the current name, without extension, in existing_filename. With prefer_keep_filename_if_relevant set to true, you get a new name only if the current one isn&#8217;t good for SEO. If generated_file_name is missing from the result, keep the name you have.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Get the result<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Poll \/get-url.php with the job id until the status is 2, -1 or -2. All three are final.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{ \"id\": 3786 }<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td><strong>Status<\/strong><\/td><td><strong>Meaning<\/strong><\/td><td><strong>What to do<\/strong><\/td><\/tr><tr><td>0<\/td><td>Queued<\/td><td>Poll again later<\/td><\/tr><tr><td>1<\/td><td>Processing<\/td><td>Poll again later<\/td><\/tr><tr><td>2<\/td><td>Finished<\/td><td>Save the generated fields<\/td><\/tr><tr><td>-1<\/td><td>Failed (download error, timeout, quota\u2026)<\/td><td>Read error, fix the cause, submit again<\/td><\/tr><tr><td>-2<\/td><td>Image host temporarily blocked<\/td><td>Stop polling, submit again in about an hour<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Wait about 5 seconds after submitting, then a little longer before each new check. A finished job looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n\n\u00a0\u00a0\"id\": 3786,\n\n\u00a0\u00a0\"status\": 2,\n\n\u00a0\u00a0\"title\": \"Teak Garden Bench with Three Seats\",\n\n\u00a0\u00a0\"alt\": \"Three-seat teak wooden bench on a green lawn\",\n\n\u00a0\u00a0\"caption\": \"Our teak bench, built to last outdoors in any season.\",\n\n\u00a0\u00a0\"image_description\": \"A natural teak garden bench with slatted seat and backrest ...\",\n\n\u00a0\u00a0\"generated_file_name\": \"teak-garden-bench-three-seats\"\n\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Store the text right away.<\/strong> Results can be read for 7 days. After that, finished and failed jobs are deleted.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Code examples<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The PHP, JavaScript and Python examples do the same thing: they submit one image, poll with a growing delay and print the result. They stop with an error if the response contains error or the HTTP request fails.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">cURL can&#8217;t wait and poll on its own, so here you run the two calls by hand.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>cURL<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s -X POST https:\/\/capi-gpt.shortpixel.com\/add-url.php \\\n\n\u00a0\u00a0-H \"Authorization: ApiKey $SHORTPIXEL_API_KEY\" \\\n\n\u00a0\u00a0-H \"Content-Type: application\/json\" \\\n\n\u00a0\u00a0-d '{\"url\":\"https:\/\/example.com\/images\/bench.jpg\",\"version\":\"v_2\",\"languages\":\"en\",\"alt\":{\"chars\":120},\"title\":{\"chars\":60}}'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The response contains a job id. Put it in the second call (replace 3786) and repeat it until the status is 2, -1 or -2:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -s -X POST https:\/\/capi-gpt.shortpixel.com\/get-url.php \\\n  -H \"Authorization: ApiKey $SHORTPIXEL_API_KEY\" \\\n  -H \"Content-Type: application\/json\" \\\n  -d '{\"id\":3786}'<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>PHP<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\n\nconst BASE = 'https:\/\/capi-gpt.shortpixel.com';\n\nfunction capi(string $path, array $body): array {\n\n\u00a0\u00a0\u00a0\u00a0$ch = curl_init(BASE . path);curlsetoptarray(ch, &#91;\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0CURLOPT_POST => true,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0CURLOPT_RETURNTRANSFER => true,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0CURLOPT_TIMEOUT => 30,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0CURLOPT_HTTPHEADER => &#91;\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'Authorization: ApiKey ' . getenv('SHORTPIXEL_API_KEY'),\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'Content-Type: application\/json',\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0],\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0CURLOPT_POSTFIELDS => json_encode($body),\n\n\u00a0\u00a0\u00a0\u00a0]);\n\n\u00a0\u00a0\u00a0\u00a0raw=curlexec(ch);\n\n\u00a0\u00a0\u00a0\u00a0if ($raw === false) {\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0err=curlerror(ch);\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0curl_close($ch);\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0throw new RuntimeException('Network error: ' . $err);\n\n\u00a0\u00a0\u00a0\u00a0}\n\n\u00a0\u00a0\u00a0\u00a0code=curlgetinfo(ch, CURLINFO_HTTP_CODE);\n\n\u00a0\u00a0\u00a0\u00a0curl_close($ch);\n\n\u00a0\u00a0\u00a0\u00a0res=jsondecode(raw, true) ?? &#91;];\n\n\u00a0\u00a0\u00a0\u00a0if (isset($res&#91;'error'])) {\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0throw new RuntimeException($res&#91;'error']);\n\n\u00a0\u00a0\u00a0\u00a0}\n\n\u00a0\u00a0\u00a0\u00a0if ($code >= 400) {\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0throw new RuntimeException(\"HTTP $code\");\n\n\u00a0\u00a0\u00a0\u00a0}\n\n\u00a0\u00a0\u00a0\u00a0return $res;\n\n}\n\n$job = capi('\/add-url.php', &#91;\n\n\u00a0\u00a0\u00a0\u00a0'url' => 'https:\/\/example.com\/images\/bench.jpg',\n\n\u00a0\u00a0\u00a0\u00a0'version' => 'v_2',\n\n\u00a0\u00a0\u00a0\u00a0'languages' => 'en',\n\n\u00a0\u00a0\u00a0\u00a0'title' => &#91;'chars' => 60],\n\n\u00a0\u00a0\u00a0\u00a0'alt' => &#91;'chars' => 120],\n\n]);\n\n$delay = 5;\n\ndo {\n\n\u00a0\u00a0\u00a0\u00a0sleep($delay);\n\n\u00a0\u00a0\u00a0\u00a0delay=min(delay * 2, 60);\n\n\u00a0\u00a0\u00a0\u00a0$res = capi('\/get-url.php', &#91;'id' => $job&#91;'id']]);\n\n} while (in_array($res&#91;'status'], &#91;0, 1], true));\n\necho $res&#91;'alt'], PHP_EOL;<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>JavaScript (Node 18+)<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>const BASE = 'https:\/\/capi-gpt.shortpixel.com';\n\nasync function capi(path, body) {\n\n\u00a0\u00a0const r = await fetch(BASE + path, {\n\n\u00a0\u00a0\u00a0\u00a0method: 'POST',\n\n\u00a0\u00a0\u00a0\u00a0headers: {\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Authorization: `ApiKey ${process.env.SHORTPIXEL_API_KEY}`,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0'Content-Type': 'application\/json',\n\n\u00a0\u00a0\u00a0\u00a0},\n\n\u00a0\u00a0\u00a0\u00a0body: JSON.stringify(body),\n\n\u00a0\u00a0\u00a0\u00a0signal: AbortSignal.timeout(30000),\n\n\u00a0\u00a0});\n\n\u00a0\u00a0const data = await r.json().catch(() => ({}));\n\n\u00a0\u00a0if (data.error) throw new Error(data.error);\n\n\u00a0\u00a0if (!r.ok) throw new Error(`HTTP ${r.status}`);\n\n\u00a0\u00a0return data;\n\n}\n\nconst job = await capi('\/add-url.php', {\n\n\u00a0\u00a0url: 'https:\/\/example.com\/images\/bench.jpg',\n\n\u00a0\u00a0version: 'v_2',\n\n\u00a0\u00a0languages: 'en',\n\n\u00a0\u00a0title: { chars: 60 },\n\n\u00a0\u00a0alt: { chars: 120 },\n\n});\n\nlet res, delay = 5000;\n\ndo {\n\n\u00a0\u00a0await new Promise((ok) => setTimeout(ok, delay));\n\n\u00a0\u00a0delay = Math.min(delay * 2, 60000);\n\n\u00a0\u00a0res = await capi('\/get-url.php', { id: job.id });\n\n} while (res.status === 0 || res.status === 1);\n\nconsole.log(res.title, res.alt);<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Python<\/strong><\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import os, time, requests\n\nBASE = \"https:\/\/capi-gpt.shortpixel.com\"\n\nHEADERS = {\"Authorization\": f\"ApiKey {os.environ&#91;'SHORTPIXEL_API_KEY']}\"}\n\ndef capi(path, body):\n\n\u00a0\u00a0\u00a0\u00a0r = requests.post(BASE + path, json=body, headers=HEADERS, timeout=30)\n\n\u00a0\u00a0\u00a0\u00a0try:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0data = r.json()\n\n\u00a0\u00a0\u00a0\u00a0except ValueError:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0data = {}\n\n\u00a0\u00a0\u00a0\u00a0if \"error\" in data:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0raise RuntimeError(data&#91;\"error\"])\n\n\u00a0\u00a0\u00a0\u00a0r.raise_for_status()\n\n\u00a0\u00a0\u00a0\u00a0return data\n\njob = capi(\"\/add-url.php\", {\n\n\u00a0\u00a0\u00a0\u00a0\"url\": \"https:\/\/example.com\/images\/bench.jpg\",\n\n\u00a0\u00a0\u00a0\u00a0\"version\": \"v_2\",\n\n\u00a0\u00a0\u00a0\u00a0\"languages\": \"en\",\n\n\u00a0\u00a0\u00a0\u00a0\"title\": {\"chars\": 60},\n\n\u00a0\u00a0\u00a0\u00a0\"alt\": {\"chars\": 120},\n\n})\n\ndelay = 5\n\nwhile True:\n\n\u00a0\u00a0\u00a0\u00a0time.sleep(delay)\n\n\u00a0\u00a0\u00a0\u00a0delay = min(delay * 2, 60)\n\n\u00a0\u00a0\u00a0\u00a0res = capi(\"\/get-url.php\", {\"id\": job&#91;\"id\"]})\n\n\u00a0\u00a0\u00a0\u00a0if res&#91;\"status\"] not in (0, 1):\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0break\n\nprint(res&#91;\"title\"], res&#91;\"alt\"])<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A failed job (-1) and a blocked host (-2) both come back with an error key, so these helpers raise an exception for either. In production, you&#8217;ll also want retries for temporary network errors.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Example: tagging a product catalog<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Say you run a store with a few thousand products and no alt text at all. You probably already have what the API needs: an image URL, a product title and a category for each item.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script below sends images in chunks of 50, which keeps you under the limit of 100 waiting jobs per account. Results go into a CSV file you can read before importing anything. Anything that fails goes into a second file, with the reason.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import csv, os, time, requests\n\nBASE = \"https:\/\/capi-gpt.shortpixel.com\"\n\nHEADERS = {\"Authorization\": f\"ApiKey {os.environ&#91;'SHORTPIXEL_API_KEY']}\"}\n\nCHUNK = 50\u00a0 # stay under the 100 waiting jobs limit\n\nclass ApiError(Exception):\n\n\u00a0\u00a0\u00a0\u00a0\"\"\"The API answered, but rejected the request. Retrying won't help.\"\"\"\n\ndef post(path, body):\n\n\u00a0\u00a0\u00a0\u00a0r = requests.post(BASE + path, json=body, headers=HEADERS, timeout=30)\n\n\u00a0\u00a0\u00a0\u00a0r.raise_for_status()\n\n\u00a0\u00a0\u00a0\u00a0data = r.json()\n\n\u00a0\u00a0\u00a0\u00a0# Failed jobs (-1, -2) also carry \"error\", but together with \"status\"\n\n\u00a0\u00a0\u00a0\u00a0if \"error\" in data and \"status\" not in data:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0raise ApiError(data&#91;\"error\"])\n\n\u00a0\u00a0\u00a0\u00a0return data\n\ndef is_temporary(exc):\n\n\u00a0\u00a0\u00a0\u00a0\"\"\"Timeouts, connection errors, 429 and 5xx are worth another try.\"\"\"\n\n\u00a0\u00a0\u00a0\u00a0if isinstance(exc, requests.HTTPError):\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0code = exc.response.status_code\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0return code == 429 or code >= 500\n\n\u00a0\u00a0\u00a0\u00a0return True\n\n# (image URL, product page title, categories)\n\nproducts = &#91;\n\n\u00a0\u00a0\u00a0\u00a0(\"https:\/\/example.com\/img\/bench.jpg\", \"Teak garden bench, 3 seats\", &#91;\"Garden furniture\", \"Benches\"]),\n\n\u00a0\u00a0\u00a0\u00a0(\"https:\/\/example.com\/img\/parasol.jpg\", \"Cantilever parasol 3 m\", &#91;\"Garden furniture\", \"Parasols\"]),\n\n\u00a0\u00a0\u00a0\u00a0# ...\n\n]\n\nwith open(\"seo_results.csv\", \"w\", newline=\"\", encoding=\"utf-8\") as ok_file, \\\n\n\u00a0\u00a0\u00a0\u00a0\u00a0open(\"seo_failed.csv\", \"w\", newline=\"\", encoding=\"utf-8\") as fail_file:\n\n\u00a0\u00a0\u00a0\u00a0ok, failed = csv.writer(ok_file), csv.writer(fail_file)\n\n\u00a0\u00a0\u00a0\u00a0ok.writerow(&#91;\"image_url\", \"title\", \"alt\", \"caption\"])\n\n\u00a0\u00a0\u00a0\u00a0failed.writerow(&#91;\"image_url\", \"job_id\", \"reason\"])\n\n\u00a0\u00a0\u00a0\u00a0for i in range(0, len(products), CHUNK):\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0pending = {}\u00a0 # job id -> image url\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0# 1. Submit the chunk\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0for url, page_title, cats in products&#91;i:i + CHUNK]:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0try:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0job = post(\"\/add-url.php\", {\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"url\": url,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"version\": \"v_2\",\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"languages\": \"en\",\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"context\": \"Product photo for an online garden furniture store\",\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"title\": {\"chars\": 60},\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"alt\": {\"chars\": 120},\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"caption\": {\"chars\": 150},\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"use_parent_post_title\": True,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"parent_post_title\": page_title,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"use_parent_post_category\": True,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\"parent_post_category_list\": cats,\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0})\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0pending&#91;job&#91;\"id\"]] = url\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0except (ApiError, requests.RequestException, KeyError) as e:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0failed.writerow(&#91;url, \"\", f\"Not submitted: {e}\"])\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0# 2. Poll until every job in the chunk is final\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0errors = {job_id: 0 for job_id in pending}\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0delay = 5\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0while pending:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time.sleep(delay)\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0delay = min(delay * 2, 60)\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0for job_id in list(pending):\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0try:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0res = post(\"\/get-url.php\", {\"id\": job_id})\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0except ApiError as e:\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 # definitive\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0failed.writerow(&#91;pending.pop(job_id), job_id, str(e)])\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0continue\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0except requests.RequestException as e:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0if is_temporary(e) and errors&#91;job_id] &lt; 4:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0errors&#91;job_id] += 1\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 # keep the job, retry next round\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0continue\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0# The job may still finish: results stay readable for 7 days\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0failed.writerow(&#91;pending.pop(job_id), job_id, f\"Polling failed: {e}\"])\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0continue\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0errors&#91;job_id] = 0\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0status = res.get(\"status\")\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0if status in (0, 1):\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0continue\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0url = pending.pop(job_id)\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0if status == 2:\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ok.writerow(&#91;url, res.get(\"title\"), res.get(\"alt\"), res.get(\"caption\")])\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0else:\u00a0 # -1 failed, -2 host blocked\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0failed.writerow(&#91;url, job_id, res.get(\"error\", f\"Status {status}\")])\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ok_file.flush()\n\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0fail_file.flush()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When the run ends, seo_results.csv holds the text to review and import, and seo_failed.csv lists the images that need another look, with the job id and the reason. A blocked host (status -2) means you should wait about an hour and submit those images again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The product&#8217;s page title and categories go into every request, which helps the AI write &#8220;Teak garden bench&#8221; instead of &#8220;wooden seat.&#8221; And the results go to a CSV first, not straight into your database, so you can read a sample before anything goes live.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Errors and limits<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Always check for an<\/strong> <strong>error<\/strong> <strong>key.<\/strong> Rejections like an exceeded quota come back as HTTP 200 with {&#8220;error&#8221;: &#8220;&#8230;&#8221;} and no job id, so looking only at the status code isn&#8217;t enough. Other HTTP codes are used for malformed requests (400), authentication problems (401) and unknown jobs (404).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then there are the limits:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Queue:<\/strong> each account can have up to 100 jobs waiting. Submit in batches and send more as jobs finish.<\/li>\n\n\n\n<li><strong>Blocked hosts:<\/strong> if at least 90% of the jobs for one image host fail within an hour (minimum 3 jobs), that host is blocked for an hour. This usually points to a firewall, hotlink protection or a login wall that stops our downloader.<\/li>\n\n\n\n<li><strong>Allowed domains:<\/strong> if you&#8217;ve set them on your key, images from any other domain are rejected.<\/li>\n\n\n\n<li><strong>Quota:<\/strong> every processed image counts toward your plan&#8217;s AI Image SEO quota. On unlimited plans, going over the fair-usage threshold adds a 20 to 60 second delay per job, and add-url returns a warning.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>What this means for your site<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Image metadata still matters. Search engines can <a href=\"https:\/\/shortpixel.com\/blog\/how-ai-read-your-website-images\/\">analyze visual content<\/a>, but descriptive <a href=\"https:\/\/shortpixel.com\/blog\/why-alt-text-is-your-secret-weapon-for-ai-search-visibility-in-2026\/\">alt text<\/a> and relevant filenames provide additional context. They also make your images more accessible to people using screen readers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a website with thousands of images, the challenge isn&#8217;t knowing that metadata matters. It&#8217;s finding the time to write it. That&#8217;s where the API helps: you can generate metadata at scale, review the results, and add them to your existing workflow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Before you run it on everything:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Start with about 20 images.<\/strong> Read what comes back, adjust context and chars if needed, then scale up.<\/li>\n\n\n\n<li><strong>Review before publishing.<\/strong> AI-generated text is a draft, especially for products where details like size, material or model matter.<\/li>\n\n\n\n<li><strong>Don&#8217;t overwrite good text.<\/strong> If an image already has hand-written alt text, skip it.<\/li>\n\n\n\n<li><strong>Keep alt text concise, descriptive and relevant to the image&#8217;s context.<\/strong><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Wrapping up<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Alt text, captions and titles are small things that add up across thousands of images. With the API, they become one more step in your pipeline: send a URL, wait a few seconds, save the text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Working on product photos? The ShortPixel API can also <a href=\"https:\/\/shortpixel.com\/blog\/how-to-upscale-images-with-the-shortpixel-api-curl-php-javascript-python-examples\/\">upscale small images<\/a> and <a href=\"https:\/\/shortpixel.com\/blog\/how-to-remove-image-backgrounds-with-the-shortpixel-api-curl-php-javascript-python-examples\/\">remove backgrounds<\/a>, with the same kind of request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t have an API key yet? <a href=\"https:\/\/shortpixel.com\/free-sign-up\">Create a free ShortPixel account<\/a> and try it on a few images. The full reference is in the <a href=\"https:\/\/kb.shortpixel.com\/knowledge-base\/article\/ai-image-seo-via-api\/\">AI Image SEO via API<\/a> article in our Knowledge Base.<\/p>\n\n\n\n<div class=\"wp-block-group has-border-color has-palette-color-4-border-color has-palette-color-6-background-color has-background is-vertical is-content-justification-left is-layout-flex wp-container-core-group-is-layout-02a0377d wp-block-group-is-layout-flex\" style=\"border-width:1px;border-radius:20px;margin-top:var(--wp--preset--spacing--60);margin-bottom:var(--wp--preset--spacing--60);padding-top:var(--wp--preset--spacing--60);padding-right:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60);padding-left:var(--wp--preset--spacing--60)\">\n<h3 id=\"cta-heading\" class=\"wp-block-heading\" style=\"margin-top:0;margin-right:0;margin-bottom:0;margin-left:0;padding-top:0;padding-right:0;padding-bottom:0;padding-left:0\"><strong>Generate AI Image SEO for free!<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Easily optimize your images and generate AI Image SEO using ShortPixel!<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/wordpress.org\/plugins\/shortpixel-image-optimiser\/\" target=\"_blank\" rel=\"noreferrer noopener\">Get Started<\/a><\/div>\n<\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Search engines can analyze what&#8217;s in an image, but descriptive alt text, captions and titles still give them, and your visitors, useful context. When those fields are empty, you&#8217;re leaving all the explaining to a machine. For a blog with 50 images, that&#8217;s an afternoon of work. For a store with 8,000 product photos, it&#8217;s [&hellip;]<\/p>\n","protected":false},"author":37,"featured_media":15033,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-15032","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-updates-and-news"],"blocksy_meta":[],"_links":{"self":[{"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/posts\/15032","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/users\/37"}],"replies":[{"embeddable":true,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/comments?post=15032"}],"version-history":[{"count":3,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/posts\/15032\/revisions"}],"predecessor-version":[{"id":15037,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/posts\/15032\/revisions\/15037"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/media\/15033"}],"wp:attachment":[{"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/media?parent=15032"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/categories?post=15032"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shortpixel.com\/blog\/wp-json\/wp\/v2\/tags?post=15032"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}