How to Generate AI Image SEO Metadata With the ShortPixel API (cURL, PHP, JavaScript & Python Examples)

Search engines can analyze what’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’re leaving all the explaining to a machine.
For a blog with 50 images, that’s an afternoon of work. For a store with 8,000 product photos, it’s a backlog that never gets cleared.
That’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’t tied to WordPress: any website or app that can make an HTTP request can use it.
Here’s what we’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’s a script that tags a small product catalog and saves everything to a CSV file.
Using WordPress? You can generate AI image metadata straight from the Media Library with the ShortPixel Image Optimizer or ShortPixel AI ALT Text Generator plugin, so you don’t need to build an API integration. This article is for custom CMSs, e-commerce platforms, DAM systems and scripts.
How the API works
It’s asynchronous. Generating text from an image takes a few seconds, and keeping a connection open that long doesn’t scale when you have thousands of files. So the work is split into two calls:
- POST /add-url.php queues an image and returns a job id.
- POST /get-url.php takes that id and returns the job status. When the job is finished, the generated text is in the response.
The base URL is https://capi-gpt.shortpixel.com, and everything is JSON (Content-Type: application/json).
The image has to be public. Our servers download it themselves. JPG, PNG, GIF, WebP and AVIF are supported.
The API doesn’t publish anything. It generates the metadata, and saving it in your CMS or database is up to you.
Authentication
Your ShortPixel API key goes in the Authorization header. Your plan must include the AI Image SEO feature.
Authorization: ApiKey YOUR_API_KEY
Calls made with the key also return a session token (JWT) that’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.
One key per site. 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.
Keep the key on your server. It should never appear in browser or mobile app code.
Submit an image
Only url is required. You get back just the fields you ask for.
| Field | What it does |
| url | Public image URL |
| version | Recommended: send “v_2”. Your quota is then checked up front, so an over-quota request is rejected immediately |
| languages | Output language as an ISO 639-1 code (en, de), optionally with a country (pt-BR). Default is en |
| context | General description of the image or page, up to 500 characters |
| title | Max 100 characters, default 20 |
| alt | Max 200 characters, default 72 |
| caption | Max 250 characters, default 128 |
| image_description | Max 500 characters, default 300 |
| file | SEO-friendly file name, 1 to 100 characters, default 48 |
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.
Here’s a request for a product photo:
{
"url": "https://example.com/images/IMG_2041.jpg",
"version": "v_2",
"languages": "en",
"context": "Product photo for an online garden furniture store",
"title": { "chars": 60 },
"alt": { "chars": 120, "context": "describe for accessibility and search" },
"caption": { "chars": 150 },
"image_description": { "chars": 300 },
"file": {
"existing_filename": "IMG_2041",
"prefer_keep_filename_if_relevant": true,
"chars": 60
},
"use_parent_post_title": true,
"parent_post_title": "Teak garden bench, 3 seats",
"use_parent_post_category": true,
"parent_post_category_list": ["Garden furniture", "Benches"]
}
If the job was queued, the response contains an id:
{ "id": 3786, "status": 0 }
If it contains error instead, no job was created.
Give the AI some context
This is the part that makes the biggest difference. A model that only sees pixels will describe your bench as “a wooden bench on grass.” 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’s still AI-generated text, so read it before it goes live, especially for products.
You can pass context in three places:
- Top level: context applies to the whole request, for example the kind of store you run.
- Per field: context inside a field applies to that field only, like “describe for accessibility and search” on alt.
- Page data: 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.
File names
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’t good for SEO. If generated_file_name is missing from the result, keep the name you have.
Get the result
Poll /get-url.php with the job id until the status is 2, -1 or -2. All three are final.
{ "id": 3786 }
| Status | Meaning | What to do |
| 0 | Queued | Poll again later |
| 1 | Processing | Poll again later |
| 2 | Finished | Save the generated fields |
| -1 | Failed (download error, timeout, quota…) | Read error, fix the cause, submit again |
| -2 | Image host temporarily blocked | Stop polling, submit again in about an hour |
Wait about 5 seconds after submitting, then a little longer before each new check. A finished job looks like this:
{
"id": 3786,
"status": 2,
"title": "Teak Garden Bench with Three Seats",
"alt": "Three-seat teak wooden bench on a green lawn",
"caption": "Our teak bench, built to last outdoors in any season.",
"image_description": "A natural teak garden bench with slatted seat and backrest ...",
"generated_file_name": "teak-garden-bench-three-seats"
}
Store the text right away. Results can be read for 7 days. After that, finished and failed jobs are deleted.
Code examples
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.
cURL can’t wait and poll on its own, so here you run the two calls by hand.
cURL
curl -s -X POST https://capi-gpt.shortpixel.com/add-url.php \
-H "Authorization: ApiKey $SHORTPIXEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/images/bench.jpg","version":"v_2","languages":"en","alt":{"chars":120},"title":{"chars":60}}'
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:
curl -s -X POST https://capi-gpt.shortpixel.com/get-url.php \
-H "Authorization: ApiKey $SHORTPIXEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":3786}'
PHP
<?php
const BASE = 'https://capi-gpt.shortpixel.com';
function capi(string $path, array $body): array {
$ch = curl_init(BASE . path);curlsetoptarray(ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: ApiKey ' . getenv('SHORTPIXEL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
raw=curlexec(ch);
if ($raw === false) {
err=curlerror(ch);
curl_close($ch);
throw new RuntimeException('Network error: ' . $err);
}
code=curlgetinfo(ch, CURLINFO_HTTP_CODE);
curl_close($ch);
res=jsondecode(raw, true) ?? [];
if (isset($res['error'])) {
throw new RuntimeException($res['error']);
}
if ($code >= 400) {
throw new RuntimeException("HTTP $code");
}
return $res;
}
$job = capi('/add-url.php', [
'url' => 'https://example.com/images/bench.jpg',
'version' => 'v_2',
'languages' => 'en',
'title' => ['chars' => 60],
'alt' => ['chars' => 120],
]);
$delay = 5;
do {
sleep($delay);
delay=min(delay * 2, 60);
$res = capi('/get-url.php', ['id' => $job['id']]);
} while (in_array($res['status'], [0, 1], true));
echo $res['alt'], PHP_EOL;
JavaScript (Node 18+)
const BASE = 'https://capi-gpt.shortpixel.com';
async function capi(path, body) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: {
Authorization: `ApiKey ${process.env.SHORTPIXEL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
const data = await r.json().catch(() => ({}));
if (data.error) throw new Error(data.error);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return data;
}
const job = await capi('/add-url.php', {
url: 'https://example.com/images/bench.jpg',
version: 'v_2',
languages: 'en',
title: { chars: 60 },
alt: { chars: 120 },
});
let res, delay = 5000;
do {
await new Promise((ok) => setTimeout(ok, delay));
delay = Math.min(delay * 2, 60000);
res = await capi('/get-url.php', { id: job.id });
} while (res.status === 0 || res.status === 1);
console.log(res.title, res.alt);
Python
import os, time, requests
BASE = "https://capi-gpt.shortpixel.com"
HEADERS = {"Authorization": f"ApiKey {os.environ['SHORTPIXEL_API_KEY']}"}
def capi(path, body):
r = requests.post(BASE + path, json=body, headers=HEADERS, timeout=30)
try:
data = r.json()
except ValueError:
data = {}
if "error" in data:
raise RuntimeError(data["error"])
r.raise_for_status()
return data
job = capi("/add-url.php", {
"url": "https://example.com/images/bench.jpg",
"version": "v_2",
"languages": "en",
"title": {"chars": 60},
"alt": {"chars": 120},
})
delay = 5
while True:
time.sleep(delay)
delay = min(delay * 2, 60)
res = capi("/get-url.php", {"id": job["id"]})
if res["status"] not in (0, 1):
break
print(res["title"], res["alt"])
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’ll also want retries for temporary network errors.
Example: tagging a product catalog
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.
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.
import csv, os, time, requests
BASE = "https://capi-gpt.shortpixel.com"
HEADERS = {"Authorization": f"ApiKey {os.environ['SHORTPIXEL_API_KEY']}"}
CHUNK = 50 # stay under the 100 waiting jobs limit
class ApiError(Exception):
"""The API answered, but rejected the request. Retrying won't help."""
def post(path, body):
r = requests.post(BASE + path, json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
data = r.json()
# Failed jobs (-1, -2) also carry "error", but together with "status"
if "error" in data and "status" not in data:
raise ApiError(data["error"])
return data
def is_temporary(exc):
"""Timeouts, connection errors, 429 and 5xx are worth another try."""
if isinstance(exc, requests.HTTPError):
code = exc.response.status_code
return code == 429 or code >= 500
return True
# (image URL, product page title, categories)
products = [
("https://example.com/img/bench.jpg", "Teak garden bench, 3 seats", ["Garden furniture", "Benches"]),
("https://example.com/img/parasol.jpg", "Cantilever parasol 3 m", ["Garden furniture", "Parasols"]),
# ...
]
with open("seo_results.csv", "w", newline="", encoding="utf-8") as ok_file, \
open("seo_failed.csv", "w", newline="", encoding="utf-8") as fail_file:
ok, failed = csv.writer(ok_file), csv.writer(fail_file)
ok.writerow(["image_url", "title", "alt", "caption"])
failed.writerow(["image_url", "job_id", "reason"])
for i in range(0, len(products), CHUNK):
pending = {} # job id -> image url
# 1. Submit the chunk
for url, page_title, cats in products[i:i + CHUNK]:
try:
job = post("/add-url.php", {
"url": url,
"version": "v_2",
"languages": "en",
"context": "Product photo for an online garden furniture store",
"title": {"chars": 60},
"alt": {"chars": 120},
"caption": {"chars": 150},
"use_parent_post_title": True,
"parent_post_title": page_title,
"use_parent_post_category": True,
"parent_post_category_list": cats,
})
pending[job["id"]] = url
except (ApiError, requests.RequestException, KeyError) as e:
failed.writerow([url, "", f"Not submitted: {e}"])
# 2. Poll until every job in the chunk is final
errors = {job_id: 0 for job_id in pending}
delay = 5
while pending:
time.sleep(delay)
delay = min(delay * 2, 60)
for job_id in list(pending):
try:
res = post("/get-url.php", {"id": job_id})
except ApiError as e: # definitive
failed.writerow([pending.pop(job_id), job_id, str(e)])
continue
except requests.RequestException as e:
if is_temporary(e) and errors[job_id] < 4:
errors[job_id] += 1 # keep the job, retry next round
continue
# The job may still finish: results stay readable for 7 days
failed.writerow([pending.pop(job_id), job_id, f"Polling failed: {e}"])
continue
errors[job_id] = 0
status = res.get("status")
if status in (0, 1):
continue
url = pending.pop(job_id)
if status == 2:
ok.writerow([url, res.get("title"), res.get("alt"), res.get("caption")])
else: # -1 failed, -2 host blocked
failed.writerow([url, job_id, res.get("error", f"Status {status}")])
ok_file.flush()
fail_file.flush()
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.
The product’s page title and categories go into every request, which helps the AI write “Teak garden bench” instead of “wooden seat.” And the results go to a CSV first, not straight into your database, so you can read a sample before anything goes live.
Errors and limits
Always check for an error key. Rejections like an exceeded quota come back as HTTP 200 with {“error”: “…”} and no job id, so looking only at the status code isn’t enough. Other HTTP codes are used for malformed requests (400), authentication problems (401) and unknown jobs (404).
Then there are the limits:
- Queue: each account can have up to 100 jobs waiting. Submit in batches and send more as jobs finish.
- Blocked hosts: 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.
- Allowed domains: if you’ve set them on your key, images from any other domain are rejected.
- Quota: every processed image counts toward your plan’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.
What this means for your site
Image metadata still matters. Search engines can analyze visual content, but descriptive alt text and relevant filenames provide additional context. They also make your images more accessible to people using screen readers.
For a website with thousands of images, the challenge isn’t knowing that metadata matters. It’s finding the time to write it. That’s where the API helps: you can generate metadata at scale, review the results, and add them to your existing workflow.
Before you run it on everything:
- Start with about 20 images. Read what comes back, adjust context and chars if needed, then scale up.
- Review before publishing. AI-generated text is a draft, especially for products where details like size, material or model matter.
- Don’t overwrite good text. If an image already has hand-written alt text, skip it.
- Keep alt text concise, descriptive and relevant to the image’s context.
Wrapping up
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.
Working on product photos? The ShortPixel API can also upscale small images and remove backgrounds, with the same kind of request.
Don’t have an API key yet? Create a free ShortPixel account and try it on a few images. The full reference is in the AI Image SEO via API article in our Knowledge Base.
Generate AI Image SEO for free!
Easily optimize your images and generate AI Image SEO using ShortPixel!