The AI service HTTP API
The plugin never talks to a model provider. It talks to this service, which holds the provider keys, owns the prompt and the model choice, meters credits before proxying anything, and answers in the shapes below.
This page is the contract both halves are written from. It is published because the plugin is GPLv2 and you are entitled to know exactly what the code you are running sends and receives.
Base URL #
https://ai.vergelabs.nl/v1
Hard-coded in the plugin, with no WordPress filter. The only override is the
VERGEML_AI_SERVICE constant in wp-config.php, for development, and non-HTTPS
values are refused client-side. A filter here would let any co-installed plugin
redirect requests that carry your licence key.
Authentication #
Every request carries the licence key in its JSON body. Both spellings are
accepted — key and license_key — because the plugin ships to wordpress.org and
freezes on other people's installs, while this half redeploys in a minute. This is
the half that bends.
Key format: VGML- followed by 34 characters from 0123456789ABCDEFGHJKMNPQRSTVWXYZ
(no I, L, O or U), 39 characters in total.
Server-side, keys are stored and looked up by hash. A leaked database read does not hand over working licences, and no log line can contain a key.
Common request fields #
| Field | Required | Notes |
|---|---|---|
key / license_key | yes | As above |
site | yes | Your site address, normalised server-side |
Common errors #
| Status | error | Meaning |
|---|---|---|
400 | invalid_body, invalid_site, invalid_image, image_too_large, invalid_speed, empty_text, empty_instruction | The request was not usable |
401 | invalid_key, bad_key | The key is not a key |
402 | insufficient_credits | Out of credits. Carries credits_remaining |
403 | not_found, not_entitled, site_not_activated | Licence unknown, lapsed, or this site is not on it |
409 | duplicate_image | The same image arrived twice in quick succession. Carries Retry-After |
429 | rate_limited, daily_cap | Carries Retry-After |
502 | model_unavailable, embedding_unavailable | The provider failed. The credit is refunded |
503 | licensing_disabled, ai_disabled | The service is not accepting work |
A malformed key answers 401, not 400, and the difference is a whole library.
The plugin reads status codes as instructions: 401/403 mean the licence was not
accepted and stop the run; any other non-200 means the service is having trouble,
so it skips that one file, writes an error stub, and carries on. A customer who
mistyped their key once got a run that walked their entire library writing stubs
and finished looking like it had worked.
401, 402 and 403 are fatal to the batch on the plugin side.
Rate limits #
| Window | Cap |
|---|---|
| 60 seconds | 240 credits |
| Rolling 24 hours | 25,000 credits, or 25% of the balance if that is higher |
| Planner | 40 folder proposals and plans a day per site |
Counted from the credit ledger, so the count survives a cold start. Deliberately loose enough that no honest run meets them: the plugin describes in batches with a round trip between them, so a real library of five thousand files takes hours.
The day window is tested first — when both are exceeded, the daily one is the more useful thing to be told, because it is the one that will still be true in a minute.
POST /describe #
One image in, one description out, one credit down.
Request #
{
"license_key": "VGML-…",
"site": "https://customer-site.example",
"environment": "production",
"filename": "IMG_4822.jpg",
"mime": "image/jpeg",
"image": "data:image/jpeg;base64,…",
"language": "Dutch",
"profile": "A Rotterdam architecture studio.",
"context": {
"title": "Hallway",
"post_title": "Sint-Jacobstraat renovation",
"page_keyphrase": "hallway renovation",
"page_description": "…",
"page_related": "…"
}
}
imagemay be a full data URI or bare base64. Both are accepted.- Accepted types are sniffed from the bytes, not trusted from the field:
image/jpeg,image/png,image/gif,image/webp. - Maximum 5 MB decoded. The plugin sends a downsized intermediate — the largest thumbnail under about 1.5 MB — never the original.
media_typeis accepted as an alias ofmime.
Response 200 #
{
"alt": "Short alt text for accessibility.",
"caption": "One factual sentence about what the image shows.",
"tags": ["three", "to", "six", "lowercase", "keywords"],
"title": "A Short Human Title",
"kind": "photo",
"has_people": true,
"has_text": false,
"document_type": null,
"embedding": [0.0123, -0.0044, "…"],
"dims": 768,
"embedding_model": "…",
"model": "…",
"model_version": "…",
"prompt_hash": "…",
"credits": { "remaining": 4312 },
"credits_remaining": 4312,
"credits_spent": 1,
"speed": "standard"
}
The balance rides along on every answer, so a balance check never needs its own request. It appears twice — nested and flat — for the same reason the request takes two spellings of the key; neither is worth a breaking change.
The embedding rides along too, computed from the caption that was just written rather than from the picture. A second endpoint would mean sending the same image twice or making the client hold state between two requests for no gain. A missing vector is a row to re-embed later from the stored caption, not a failed description.
The attributes #
Enums, not free text, because their whole purpose is to be queried: kind = document
has to mean the same thing on every row or the column is worth nothing.
{
"kind": "photo | illustration | screenshot | document | diagram | logo | other",
"has_people": true,
"has_text": false,
"document_type": "invoice | receipt | contract | form | slide | report | other | null"
}
document_typeisnullunlesskindisdocument.orientationis not asked for. It is portrait, landscape or square, the client already knows the dimensions, and paying a model to measure a rectangle would be absurd.- Unknown values are stored as given but never matched. A new enum member ships in this document before it ships in a response.
The release test: the same file, three times, the same enums. An attribute that is not stable across runs is not an attribute, it is a guess, and it must not become a column people filter on.
Provenance #
Every answer carries three fields the plugin stores verbatim:
| Field | Meaning |
|---|---|
model | The exact model that answered — never a latest alias |
model_version | The provider's version string for it |
prompt_hash | A stable hash of the prompt that produced this answer |
Without them a stored description is unreproducible: nothing says what asked for it, so nothing can decide whether it is worth re-running. The plugin invents none of the three — a hash the client made up would answer the question wrongly rather than not at all.
Server-side obligations #
- Meter first. The credit is decremented inside the same transaction that admits the request, and refunded on provider failure. Nothing is proxied before the licence and balance have cleared.
- Validate the licence against the site.
- Own the prompt. Prompt and model live here, so quality improvements ship without a plugin release.
- Keep nothing. Images are processed and discarded. Usage counters are stored; customer media is not.
POST /embed #
Text in, one vector out. This is what makes "seaside" find a beach.
Request #
{ "key": "VGML-…", "site": "https://…", "text": "seaside" }
Response 200 #
{ "embedding": [0.0123, "…"], "dims": 768, "model": "…" }
Storage is client-side, settled. Vectors live in the plugin's own
{prefix}vergeml_ai_index.embedding column as packed single-precision floats with a
dims count beside them. The service stores nothing per file — the same promise
/describe makes, and a service that held vectors would be holding a representation
of customer media.
dims is recorded per row rather than assumed, so changing embedding model does not
silently mix incomparable vectors: a row whose dims or model differs from the
current one is re-embedded rather than compared.
POST /folders #
An instruction plus your current folder names in, a revised folder plan out. No images.
Request #
{
"key": "VGML-…",
"site": "https://…",
"instruction": "drop nature, I want buildings, split into modern and classic",
"current": [ { "name": "Nature", "parent": "", "count": 214 } ],
"samples": [ "A glass office block at dusk.", "…" ]
}
current and samples are capped server-side. samples are captions from your own
index, not images.
Response 200 #
{
"folders": [
{ "name": "Modern", "parent": "Buildings", "matches": ["…"] }
],
"note": "…"
}
A parent naming a folder that is not in the returned list is flattened to the root
before the response leaves, so the client cannot be handed an orphan.
POST /batch #
Submit up to 100 images as one job. Used for large backlogs, so a failure costs a chunk rather than a run.
Request #
{
"key": "VGML-…",
"site": "https://…",
"speed": "overnight",
"items": [
{
"attachment_id": 1841,
"image": "…base64…",
"filename": "hero.jpg",
"title": "…",
"caption": "…",
"post_title": "…",
"product_categories": "…",
"page_keyphrase": "…",
"page_description": "…",
"page_related": "…"
}
]
}
- Maximum 100 items per request, 5 MB per image.
- Every image is validated before anything is spent. Half a batch submitted and half refused is a worse outcome than a refusal, because the customer then has to work out which half.
- A repeated
attachment_idin one request is refused (duplicate_attachment) rather than silently deduplicated — it would collide on the job's unique id and quietly drop one.
Response 200 #
{
"job_id": "…",
"images": 100,
"speed": "overnight",
"credits_spent": 100,
"credits_remaining": 4212,
"status": "running"
}
POST /batch/:id #
Poll a job. The body carries key.
{
"job_id": "…",
"status": "running",
"speed": "overnight",
"progress": { "total": 100, "done": 61, "failed": 2, "pending": 37, "percent": 63, "finished": false },
"results": [ { "attachment_id": 1841, "alt": "…", "caption": "…", "tags": ["…"] } ],
"failed": [ { "attachment_id": 1902, "error": "…" } ]
}
Polling also collects finished work rather than waiting for the nightly reconciliation — a batch that finished an hour after the nightly run would otherwise make "overnight" mean "tomorrow night", and the customer polling is the best signal that somebody wants the result now. Unclaimed credits for failed items are refunded at collection.
About speed #
The API accepts overnight, standard and priority. They all cost one credit
now.
They used to cost 1, 2 and 3, on the idea that an overnight run could be batched at half the price. Two things were wrong with that: the plugin never offered the choice, so it was a decision no customer could take; and the cheap tier depended on a provider batch API that the provider in use does not have, so the discount could not have been delivered even if somebody had asked for it.
One credit for one image is what actually happens, and it makes an allowance mean something you can hold in your head: 2,000 credits is 2,000 images. The names survive only because older plugin versions send them.
Do not build a pricing page on speed tiers.
Licensing endpoints #
These serve the account area and the licensed builds distributed from vergelabsmedia.com. The free plugin on wordpress.org uses none of them — it updates through wordpress.org like any other plugin, because gating a free plugin's updates would strand anyone whose subscription lapsed on an old version with known bugs.
POST /api/licence/verify #
{ "key": "VGML-…", "site": "https://…", "action": "check", "environment": "production" }
action is check, activate or deactivate.
{
"valid": true,
"plan": "five",
"status": "active",
"sites_used": 2,
"sites_allowed": 5,
"credits_remaining": 4312,
"expires_at": "2027-09-01T00:00:00.000Z",
"degraded": false,
"seat_free": false
}
- An unknown key is a
200withvalid: false, not a404. The client has to be able to tell "your licence lapsed" from "the server is broken", and only the first should turn features off. reasonon an invalid answer is one ofnot_found,expired,canceled,seat_limit.degraded: truemeans the licence is inside its 7-day grace period.seat_free: truemeans the site declared a non-production environment and no seat was consumed. It is a billing convenience, not a security control, and is not treated as one.- Rate limited to 30 attempts a minute per IP.
GET /api/plugin/update #
?slug=…&version=…&key=…&site=…
{
"slug": "…",
"version": "4.1.0",
"requires": "6.5",
"requires_php": "7.4",
"tested": "7.1",
"changelog": "…",
"update": true,
"package": "https://…"
}
A paid build with no valid licence still reports update: true with an empty
package and a reason — so the site is told an update exists and why it cannot
have it, rather than being told nothing.
Download links are short-lived signed tokens.
Caching #
Every /v1 and /api response is served no-store. A cached describe response is
somebody else's picture.
Written from version 3.15.0 of the plugin. Last updated 2026-10-02. All documentation · Legal · Support