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 calls wp_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:

  1. Confirm the manifest is published. Hit /update/{slug} directly with curl (see above). If the version field matches your new release, the backend is fine — the rest is on the WordPress side.
  2. 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.css for themes, the main plugin file for plugins) and push again.
  3. Confirm the customer site has polled since the release. Default cadence is twice daily. Click Dashboard → Updates → Check again to force a poll.
  4. Confirm the API key and domain. Hit /ping with the site's API key and host. A 403 here 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 by x-api-key + x-user-id and 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's X-Hub-Signature-256, not relevant to customer sites. See Publishing a plugin or theme for the narrative.