Update manifest reference
The wire-level protocol between a customer's WordPress client plugin and the VelvetPress update API. Look this up when you need exact endpoint paths, auth, the manifest JSON shape, or what an error response looks like.
For the narrative version — "what triggers a release," "how do updates surface
in wp-admin" — see Publishing a plugin or theme
and Installing on a customer WordPress site.
Base URL
| Environment | Base URL |
|---|---|
| Production | https://api.velvetpress.dev |
| Dev | the API Gateway stage URL from your SAM deploy (ApiEndpoint output of sam deploy) |
All endpoints below are appended to the base URL. Production sites use
https://api.velvetpress.dev — it's hardcoded in the client plugin and
the settings UI has no URL field. To point at a non-prod environment,
define VELVETPRESS_API_URL in wp-config.php.
Authentication
Customer-facing endpoints authenticate with two query-string parameters:
| Parameter | Source |
|---|---|
api_key |
The per-site key the developer generated in the dashboard and gave the site admin. |
domain |
The site's host, taken from home_url() with port stripped. |
The backend looks up the API key in the sites table and verifies the
incoming domain is in that site's allowed-domains list. Both checks must
pass — a valid key from the wrong domain returns 403.
There is no Authorization header, no bearer token, no signing. The API key
is the bearer. Treat it accordingly.
One exception: GET /products takes api_key only, no domain. It
still resolves the site, but it lists products by the site owner (so
all of the owner's products are visible to any of their sites).
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
| GET | /update/{slug} |
Fetch manifest plus a fresh download URL. |
| GET | /check/{slug} |
Fetch manifest without the download URL. |
| GET | /products |
List all products available to this site's owner. |
| GET | /ping |
Validate API key + domain. Returns site metadata. |
| POST | /register |
One-shot site registration (called on settings save). |
| POST | /report |
Periodic version report (throttled, hourly). |
Definitions live in backend/template.yaml. The customer-facing client
plugin only ever hits these six.
GET /update/{slug} — the manifest endpoint
The endpoint the Plugin Update Checker library polls. Returns the full
manifest including a short-lived presigned download_url that WordPress
uses to fetch the zip.
Request
GET /update/{slug}?api_key={key}&domain={host}
Path parameter {slug} is the product's slug as registered in the
dashboard (e.g. vp-starter-theme, velvetpress-plugin).
Response — 200 OK
{
"name": "VP Starter Theme",
"version": "1.6.1",
"requires": "6.0",
"requires_php": "7.4",
"tested": "6.7",
"details_url": "https://velvetpress.dev/themes/vp-starter-theme",
"download_url": "https://velvetpress-updates-prod.s3.amazonaws.com/vp-starter-theme/vp-starter-theme-1.6.1.zip?X-Amz-..."
}
Field reference:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Human-readable name shown in wp-admin. Defaults to the slug if the build didn't supply one. |
version |
string | yes | The release version. WordPress compares this against the installed version to offer updates. |
requires |
string | yes | Minimum WordPress version. Defaults to 6.0. |
requires_php |
string | yes | Minimum PHP version. Defaults to 7.4. |
tested |
string | yes | Latest WordPress version this release was tested against. Defaults to 6.7. |
details_url |
string | yes | "View details" link in wp-admin. Defaults to https://velvetpress.dev/plugins/{slug} for plugins or https://velvetpress.dev/themes/{slug} for themes (branches on product type). |
download_url |
string | yes* | Presigned S3 URL pointing at {slug}/{slug}-{version}.zip. Expires in 300 seconds. Not stored — generated per request. |
* download_url is included on /update/{slug} responses only; it's
omitted from /check/{slug}.
The persisted on-disk record (without download_url) is what the build
worker wrote to s3://<bucket>/{slug}/theme.json. The Lambda reads it,
generates the presigned URL, and merges them.
curl
curl "https://api.velvetpress.dev/update/vp-starter-theme?api_key=$VP_API_KEY&domain=example.com"
A successful call prints the JSON above. To download the release in one shot:
curl -L -o release.zip "$(
curl -s "https://api.velvetpress.dev/update/vp-starter-theme?api_key=$VP_API_KEY&domain=example.com" \
| jq -r .download_url
)"
GET /check/{slug} — manifest only
Same response as /update/{slug} minus download_url. Use this when you
only need to know "is there a newer version?" — the download URL is a few
hundred bytes of extra payload and a fresh S3 signature on every poll, so
skipping it is cheaper when you don't intend to install.
WordPress's update flow uses /update/{slug} exclusively. /check/{slug}
is available for tooling and dashboards.
curl "https://api.velvetpress.dev/check/vp-starter-theme?api_key=$VP_API_KEY&domain=example.com"
GET /products — list available products
Lists every product owned by this site's owner — themes and plugins both —
with the latest version and a fresh download_url per product. The client
plugin's Available Products section uses this to populate the install
list.
Request
GET /products?api_key={key}
No domain is required; ownership is resolved from the API key.
Response — 200 OK
{
"products": [
{
"slug": "vp-starter-theme",
"type": "theme",
"version": "1.6.1",
"download_url": "https://...s3...?X-Amz-...",
"requires": "6.0",
"requires_php": "7.4"
}
]
}
Products with no released version yet appear in the list with version
and download_url set to null — useful when a site admin wants to see
what's coming, but not installable until the developer pushes a release.
GET /ping — connection test
Validates that the API key and domain are accepted. The client plugin calls this from its settings page to render the ✓ Connected / ✗ Error / ✗ Offline badge.
GET /ping?api_key={key}&domain={host}
{
"success": true,
"message": "Connection successful",
"site_name": "Example Site",
"environment": "prod"
}
A 403 here is the most useful signal in the system: either the API key
is wrong, or the site's request domain isn't in the allowed-domains list
for that key.
POST /register — one-shot site registration
Called once by the client plugin after the site admin saves their API
key (see plugins/velvetpress/includes/register.php). Re-saving the
settings page re-fires the call — it's idempotent.
POST /register
Content-Type: application/json
{
"api_key": "...",
"domain": "example.com",
"theme_slug": "vp-starter-theme",
"theme_version": "1.6.1",
"site_name": "Example Site",
"wp_version": "6.7"
}
api_key, domain, and theme_slug are required. The rest are optional
metadata that surfaces in the developer's dashboard.
{ "success": true, "message": "Site registered" }
POST /report — periodic version report
Called by the client plugin on every page load, throttled to once per
hour via a WordPress transient (see
plugins/velvetpress/includes/report.php). Also fired immediately after
any theme or plugin update completes.
POST /report
Content-Type: application/json
{
"api_key": "...",
"domain": "example.com",
"theme_slug": "vp-starter-theme",
"theme_version": "1.6.1"
}
{ "success": true }
api_key and domain are required. The rest are optional — report
without theme_version just updates last_seen for the site.
Error responses
All error responses are JSON with a single error field:
{ "error": "Domain example.com not authorized for this key" }
One exception: /ping returns { "success": false, "error": "..." } on
403, because its success response also carries a success field.
| Status | When |
|---|---|
400 |
Missing required fields (e.g. no api_key query param, or POST body missing fields). |
401 |
Dashboard-authenticated endpoint hit without a valid x-api-key header. Customer endpoints don't return 401. |
403 |
API key not found, or key valid but domain not in the allowed list. |
404 |
Product slug exists in the URL but theme.json isn't in S3 yet (no release published). |
500 |
DynamoDB or S3 call failed. Body includes the underlying AWS error string. |
There is no rate-limit response code today. The Lambda is sized to absorb the per-site polling rate without throttling.
Polling cadence
The client plugin uses Plugin Update Checker (PUC)
for both the active theme (includes/update-checker.php) and the
VelvetPress plugin's self-update (includes/self-update.php). PUC schedules
its own puc_check_now-{slug} cron event (default period 12 hours) per
registered product, and also hooks the pre_set_site_transient_update_themes
/ ..._update_plugins filter that wp_version_check triggers — so the
observable cadence is the typical WordPress twice-daily check, plus PUC's
own 12h tick.
Two ways to force an immediate poll:
- From
wp-admin: Dashboard → Updates → Check again. This callswp_version_check()synchronously, which fires every PUC checker registered on the site. - From the command line:
wp cron event run wp_version_check.
Releases are eventually consistent because of this cadence. A push that builds in 30 seconds may still take up to 12 hours to reach a customer site that just polled. There's no push-style notification — the customer side is poll-only.
The presigned download_url in each manifest response is good for 300
seconds. A site that polled an hour ago and is about to click "Update
Now" first re-fetches the manifest to get a fresh URL; PUC handles this
automatically.
Cache behavior
The Lambda doesn't set Cache-Control, and API Gateway's default response
caching is off — every poll hits the function. That's intentional: a
release should be visible to all customer sites within one polling
interval, no cache-bust step required.
The presigned download_url is unique per request (S3 signature includes
a timestamp), so even an aggressive intermediate CDN couldn't reuse a
stale one.
Troubleshooting
My new release isn't showing up in wp-admin.
Work through these in order:
- Confirm the manifest is published. Hit
/update/{slug}directly with curl (see above). If theversionfield matches your new release, the backend is fine — the rest is on the WordPress side. - Confirm the manifest version is higher than what's installed. PUC
compares with
version_compare; the same version means no update is offered. Bump the header (style.cssfor themes, the main plugin file for plugins) and push again. - Confirm the customer site has polled since the release. Default cadence is twice daily. Click Dashboard → Updates → Check again to force a poll.
- Confirm the API key and domain. Hit
/pingwith the site's API key and host. A403here means the key or domain is wrong. The site admin checks Settings → VelvetPress for the API Key; the developer fixes the allowed-domains list from the dashboard.
Manual manifest fetch (the one command to keep in your back pocket):
curl -s "https://api.velvetpress.dev/update/{slug}?api_key=$VP_API_KEY&domain={host}" | jq
If that returns a manifest with the right version and requires /
tested values, the protocol is doing its job — any further "update not
showing" issue is in PUC, in WordPress's update cache (clear it with
wp transient delete --all), or in the customer's network.
What this page doesn't cover
- The build-worker side of the pipeline (
/themes/{slug}/upload-url,/themes/{slug}/release) — those are deploy-key authenticated endpoints called from the build worker, not from customer sites. They live in the operator-level docs. - The dashboard API (
/themes,/sites,/dashboard) — those are protected byx-api-key+x-user-idand only ever called from the Next.js dashboard server. Out of scope for a customer-protocol reference. - The GitHub webhook receiver (
POST /webhooks/github) — server-to- server, signed by GitHub'sX-Hub-Signature-256, not relevant to customer sites. See Publishing a plugin or theme for the narrative.