Send the URL of a public image and get back SEO metadata for it: title, alt text, caption, long description and, optionally, an SEO-friendly file name. The API works on any website or platform, not only WordPress.
The API is asynchronous and has two calls:
- POST /add-url.php queues an image and returns a job id.
- POST /get-url.php with that id returns the job status, then the generated fields once it is finished.
Base URL: https://capi-gpt.shortpixel.com. All requests and responses are JSON (Content-Type: application/json).
The image must be reachable over the public internet, because our servers download it. Supported formats: JPG/JPEG/JPE, PNG, GIF, WebP, AVIF. Image URLs can be up to 1000 characters long.
Authentication #
Use your ShortPixel API key in the Authorization header. The key comes from your ShortPixel account, and your account plan must include the AI (Image SEO) feature.
Authorization: ApiKey YOUR_API_KEY
A call made with an API key returns a session token (JWT) that is valid for 1 hour. You get it in two places: the X-New-JWT response header, and the jwt field of the JSON body (next to user_id). For the next hour you can send that token instead of the key:
Authorization: Bearer YOUR_JWT
There is no automatic fallback once the token expires: the call returns HTTP 401, and you have to authenticate again with ApiKey. Integrations that only make a few calls can simply send ApiKey every time.
One key per site (recommended). Create an alias API key in your ShortPixel account and set its allowed domains. Requests for images on any other domain are then rejected with The domain <domain> is not allowed for this API key. Every image domain you send is also linked to your ShortPixel account. On free accounts, a domain that is already used by other free accounts is rejected.
Keep the key on your server. Never embed it in browser or mobile app code.
Submit an image: POST /add-url.php #
Send the image URL plus one object for each field you want generated. Only the fields you request are generated. Each object takes a maximum length (chars) and optional instructions (context).
| Field | Type | Required | Notes |
|---|---|---|---|
| url | string | yes | Public image URL, up to 1000 characters |
| version | string | recommended | Send “v_2”. Quota is then checked up front, and the call is rejected right away when you are over quota |
| languages | string | no | Output language: ISO 639-1 code (en, de), optionally with a country (pt-BR). Default en |
| context | string | no | General description of the image or page, up to 500 characters |
| title | object | no | Generates title. chars max 100, default 20 |
| alt | object | no | Generates alt. chars max 200, default 72 |
| caption | object | no | Generates caption. chars max 250, default 128 |
| image_description | object | no | Generates image_description. chars max 500, default 300 |
| file | object | no | Generates generated_file_name, a lowercase slug without extension. chars 1–100, default 48. Also takes existing_filename and prefer_keep_filename_if_relevant (see below) |
| use_parent_post_title + parent_post_title | bool + string | no | Title of the page that shows the image, used as context. Up to 500 characters |
| use_parent_post_category + parent_post_category_list | bool + string[] | no | Categories or tags of that page, used as context. Up to 50 items |
Each context is capped at 500 characters. chars values above the maximum are reduced to the maximum.
All fields with their default values. Leave out any object you don’t need; only url is required.
{
"url": "https://example.com/wp-content/uploads/2025/dog.jpg",
"languages": "en",
"context": "optional global context",
"version": "v_2",
"file": {
"chars": 48,
"context": "filename with SEO keywords",
"prefer_keep_filename_if_relevant": true,
"existing_filename": "my-dog-in-garden"
},
"title": {
"chars": 20,
"context": "..."
},
"alt": {
"chars": 72,
"context": "..."
},
"caption": {
"chars": 128,
"context": "..."
},
"image_description": {
"chars": 300,
"context": "..."
},
"use_parent_post_title": true,
"parent_post_title": "Dogs in the garden",
"use_parent_post_category": true,
"parent_post_category_list": ["Photography", "Dogs"]
}
File name. Pass the current name without its extension in file.existing_filename. When prefer_keep_filename_if_relevant is true, generated_file_name is returned only if the current name is not good for SEO. When the key is missing from the result, keep your current file name.
Example request
{
"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"]
}
The job was queued if the response contains an id:
{ "id": 3786, "status": 0 }
Otherwise the response holds error and no job was created (see Errors). The response may also include warning when your account is over its fair-usage threshold. The job is still accepted, but it is processed with a delay of 20–60 seconds.
Get the result: POST /get-url.php #
Poll with the job id until status is 2, -1 or -2. All three are final. Start about 5 seconds after submitting, then wait a little longer before each new check. You can only read your own jobs. Any other id returns HTTP 404.
{ "id": 3786 }
| status | Meaning | What to do |
|---|---|---|
| 0 | Queued | Poll again later |
| 1 | Processing | Poll again later |
| 2 | Finished: the generated fields are in the response | Save them |
| -1 | Failed: the reason is in error (download failed, timeout, quota, …) | Fix the cause and submit again |
| -2 | Image host temporarily blocked (see Errors) | Stop polling. Submit again in about an hour |
A rejected add-url creates no job. A job that was accepted always ends as 2, -1 or -2.
A finished response contains only the fields you requested:
{
"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"
}
Results can be read again as often as you need, for 7 days. After 7 days finished and failed jobs are deleted, so store the fields on your side.
Errors and limits #
Always check the response body for an error key. Business rejections come back as HTTP 200 with {“error”: “…”} and no id. Only malformed requests, authentication and unknown jobs use other HTTP codes.
| HTTP | Example error | Cause |
|---|---|---|
| 400 | Invalid or missing ‘url’. / Missing or invalid ‘id’. | Missing or malformed field, or the body is not valid JSON |
| 401 | Unauthorized | Missing or wrong API key, or expired JWT |
| 404 | Not found / Job not found. | Unknown job, or the job belongs to another account |
| 200 | Too many jobs in queue. Please wait for some to complete. | You already have 100 jobs waiting |
| 200 | AI Image SEO quota reached (N images). Upgrade to the Unlimited AI plan. | Plan quota used up (checked up front only with “version”: “v_2”) |
| 200 | The domain <domain> is not allowed for this API key. | The image domain is not in the key’s allow list |
| 200 | This image host is temporarily blocked … | Over 90% of recent jobs for that host failed |
Queue limit. Each account can have up to 100 jobs waiting. Submit in batches and poll; send more as jobs finish.
Host block. When at least 90% of the jobs for one image host fail within an hour (minimum 3 jobs), that host is blocked for 1 hour. This usually happens when a site blocks our downloader, for example with a firewall, hotlink protection or a login wall. Jobs already queued for that host switch to status -2.
Quota. Every processed image counts toward the AI Image SEO quota of your plan. Unlimited plans have a fair-usage threshold. Above it, jobs are still processed, but with a delay of 20–60 seconds, and add-url returns a warning.
Code examples #
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}}'
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);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: ApiKey ' . getenv('SHORTPIXEL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$res = json_decode(curl_exec($ch), true) ?? [];
curl_close($ch);
if (isset($res['error'])) {
throw new RuntimeException($res['error']);
}
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),
});
const data = await r.json();
if (data.error) throw new Error(data.error);
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):
data = requests.post(BASE + path, json=body, headers=HEADERS, timeout=30).json()
if "error" in data:
raise RuntimeError(data["error"])
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"])
In these examples, a failed job (-1) or a blocked host (-2) raises an error, because the response includes error.