What it is
On the Scale plan, WPClientHub has a versioned REST API for your own tools: a client dashboard, an on-call script, a reporting pipeline, or our Zapier app. It reads what the dashboard shows — sites, pending updates, update batches, backups, uptime, security findings, clients, help desk tickets, client reports and the Activity Log — and it can start the same safe actions the app’s buttons do: a sync, an update check, a backup, or a safe update batch. Signed webhooks tell your tools when something happens.
- Base URL:
https://app.wpclienthub.com/api/v1 - Format: JSON in and out, timestamps in UTC (ISO 8601), every answer wrapped in
data. - Spec: OpenAPI 3.1 — import it into Postman, Insomnia or a code generator. It is checked against the live routes on every build, so it can’t drift.
- What v1 won’t do: delete, restore or disconnect anything. Writes only start work, through the same rules as the app — a backup can’t start while one is running, updates take a restore point first and respect your approval setting.
The API isn’t part of the 14-day Agency trial. It is separate from notification routes, which can post alerts to Slack or a URL on every plan.
API keys
- Go to Settings → API keys and press Create key (owners and admins).
- Name it after what will use it — “Zapier”, “Client dashboard” — so you can tell keys apart later.
- Pick its access: Read-only, Zapier (exactly what the Zapier app needs), Full access, or scope by scope. Optionally pick an expiry date.
- Copy the key. It is shown once; WPClientHub keeps only a fingerprint of it (SHA-256). Afterwards you see its prefix and last four characters (
wch_live_8KfQ…a1B2), when it was last used and from which IP address.
Keys belong to the organization, not to the person who made them: one keeps working when its creator leaves. Revoke a key in one click; anything using it stops at once, and webhook subscriptions it created (Zapier triggers) are switched off with it. Revoked keys stay listed for 90 days. Every member can see the list; only owners and admins can create or revoke.
Keys made on staging start wch_test_ and only work there.
Authentication
Send the key as a bearer token on every request:
curl https://app.wpclienthub.com/api/v1/me \
-H "Authorization: Bearer wch_live_…"
GET /me needs no scope — it answers which organization the key belongs to, the key’s scopes and limits. A missing or wrong key is 401 (unauthenticated, invalid_api_key, api_key_revoked or api_key_expired); a key whose organization is no longer on Scale is 403 plan_required. Your browser session does not authenticate the API.
Scopes
A key can do exactly what its scopes allow — whoever created it. An endpoint lists the scopes that unlock it; one is enough. Without it the answer is 403 insufficient_scope, with the scopes it needs in details.
| Scope | Allows |
|---|---|
sites:read | List and show sites: health score, plugin version, pending updates, last backup, uptime status. |
sites:write | Ask a site to sync, check for updates, or take a backup. Nothing is deleted or disconnected. |
updates:read | Pending updates per site; update batches and their status. |
updates:write | Start a safe update batch — restore point first, visual check after, the site’s approval setting respected. |
backups:read | Restore points and their status. |
backups:write | Start a backup. |
uptime:read | Uptime status per site, and incidents. |
security:read | Security findings — vulnerable core, plugins and themes. |
clients:read | Clients and their sites. |
tickets:read | Help desk tickets and their public conversation (never internal notes). |
tickets:write | Open a ticket for a client; reply on one. |
reports:read | Client reports, with a short-lived PDF link. |
activity:read | The Activity Log. |
seo:read | Every page’s SEO fields and the redirects of a site, as WPClientHub last read them (SEO management, Studio and above on the organization’s plan). |
seo:write | Change a page’s SEO fields on the site (a revision is kept, undoable in the app) and add redirects. Nothing is deleted. |
webhooks:manage | Create, change and delete webhook endpoints — what Zapier subscribes through. |
Rate limits
Each key may make 120 requests a minute. Every answer says where you are:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1760000060
Over the limit, the answer is 429 rate_limited with Retry-After (seconds). Keys don’t share a budget, so a busy integration can’t starve another. Need more? Tell us what you’re building.
Pagination
Every list is cursor-paginated, newest first unless the endpoint says otherwise. Pass limit (1–100, default 25), then send meta.pagination.next_cursor back as cursor until has_more is false. Cursors don’t skip or repeat rows when new ones arrive between pages.
{
"data": [ … ],
"meta": { "pagination": { "limit": 25, "has_more": true, "next_cursor": "eyJpZCI6MTI0LCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9" } }
}
Errors
Every error has one shape. code is stable — branch on it; message is a sentence for a person; details is an object or null.
{
"error": {
"code": "backup_in_progress",
"message": "A backup is already in progress for this site.",
"details": null
}
}
| Status | Codes |
|---|---|
| 401 | unauthenticated, invalid_api_key, api_key_revoked, api_key_expired |
| 403 | insufficient_scope, plan_required, help_desk_unavailable, organization_suspended, link_expired (a report link) |
| 404 | not_found — also for another organization’s id: it looks exactly like one that never existed |
| 405 | method_not_allowed |
| 409 | site_not_connected, backup_in_progress, restore_in_progress, update_batch_in_progress, no_pending_updates |
| 422 | validation_failed (problems by field in details), invalid_limit, invalid_cursor, invalid_status and other filter codes, update_not_pending, site_client_mismatch |
| 429 | rate_limited |
| 500 | server_error — logged on our side; safe to retry a read |
What a write does — and doesn’t
- Sync / check for updates queue the same state pull as the app’s Resync button; check-updates first asks WordPress.org for new versions. Answer
202. - Back up now starts a manual backup — refused while a backup or a restore is running.
- Start safe updates goes through AutoPilot’s pipeline: before-screenshots, a restore point, the updates, after-screenshots, the visual diff, and the site policy’s automatic rollback if it has one. If the site’s update policy asks for approval (or you pass
require_approval), the batch waits in the app’s approval queue; the API can’t approve it. - Tickets and replies are written as the person who created the key (the owner, once that person has left), so the client sees a reply from your team and is notified as usual.
- Every action is in the Activity Log as “API key “Zapier”” with the request’s IP address.
Endpoints
Every endpoint, generated from the OpenAPI spec. Paths are relative to https://app.wpclienthub.com/api/v1. Ids: a site is its UUID; everything else is a number, except a webhook endpoint (a UUID).
| Endpoint | Scope | What it does |
|---|---|---|
GET /me | any key | Who this key belongs to |
GET /sites | sites:read | List sites |
GET /sites/{site} | sites:read | Get a site |
POST /sites/{site}/sync | sites:write | Refresh a site's data |
POST /sites/{site}/check-updates | sites:write | Check a site for updates |
POST /sites/{site}/update-batches | updates:write | Start safe updates |
GET /updates/pending | updates:read | Pending updates |
GET /update-batches | updates:read | List update batches |
GET /update-batches/{batch} | updates:read | Get an update batch |
POST /sites/{site}/backups | backups:write or sites:write | Back up a site now |
GET /backups | backups:read | List backups |
GET /backups/{backup} | backups:read | Get a backup |
GET /uptime | uptime:read | Uptime status |
GET /uptime/incidents | uptime:read | List incidents |
GET /security/findings | security:read | List security findings |
GET /clients | clients:read | List clients |
GET /clients/{client} | clients:read | Get a client |
GET /tickets | tickets:read | List tickets |
POST /tickets | tickets:write | Open a ticket |
GET /tickets/{ticket} | tickets:read | Get a ticket |
POST /tickets/{ticket}/replies | tickets:write | Reply on a ticket |
GET /reports | reports:read | List client reports |
GET /reports/{report} | reports:read | Get a client report |
GET /reports/{report}/pdf | signed link | Download a report PDF (signed link) |
GET /activity | activity:read | Activity Log |
GET /sites/{site}/seo/pages | seo:read | List a site’s pages with their SEO fields |
PATCH /sites/{site}/seo/pages/{page} | seo:write | Change a page’s SEO fields |
GET /sites/{site}/seo/redirects | seo:read | List a site’s redirects |
POST /sites/{site}/seo/redirects | seo:write | Add a redirect |
GET /event-types | any key | Webhook event types |
GET /webhooks | webhooks:manage | List webhook endpoints |
POST /webhooks | webhooks:manage | Create a webhook endpoint |
GET /webhooks/{webhook} | webhooks:manage | Get a webhook endpoint |
PATCH /webhooks/{webhook} | webhooks:manage | Change a webhook endpoint |
DELETE /webhooks/{webhook} | webhooks:manage | Delete a webhook endpoint |
POST /webhooks/{webhook}/test | webhooks:manage | Send a test event |
Account
GET /me — Who this key belongs to
The organization, the key itself (prefix, scopes, limits) and every scope that exists. Needs no scope — use it to test a key.
curl https://app.wpclienthub.com/api/v1/me \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"organization": {
"id": "0b5e1c2d-3f4a-4b6c-8d9e-0f1a2b3c4d5e",
"name": "Northwind Studio",
"plan": "scale",
"timezone": "Europe/London"
},
"api_key": {
"id": 7,
"name": "Zapier",
"prefix": "wch_live_8KfQ",
"masked": "wch_live_8KfQ…a1B2",
"scopes": [
"sites:read",
"sites:write",
"webhooks:manage"
],
"rate_limit_per_minute": 120,
"expires_at": null,
"last_used_at": "2026-10-07T09:12:44Z",
"created_at": "2026-10-01T10:00:00Z"
},
"available_scopes": [
"sites:read",
"…"
]
}
}
Sites
GET /sites — List sites
Every site in the portfolio, oldest first.
Scope: sites:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.status— Only sites with this status. One of:pending,connected,disconnected,suspended.client_id— Only this client's sites.q— Name or address contains this text.url— The site at this address. Scheme, a leading "www." and a trailing slash are ignored — `example.com` finds `https://www.example.com/`.
curl https://app.wpclienthub.com/api/v1/sites \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"status": "connected",
"client": {
"id": 12,
"name": "Acme Foods"
},
"labels": [
"woocommerce",
"care-plan"
],
"health_score": 86,
"wordpress_version": "6.8.2",
"php_version": "8.3.12",
"connector": {
"version": "0.13.0",
"distribution": "wporg"
},
"pending_updates": {
"total": 3,
"core": true,
"plugins": 2,
"themes": 0
},
"last_backup": {
"id": 5120,
"type": "scheduled",
"completed_at": "2026-10-07T02:06:51Z",
"size_bytes": 418381022
},
"uptime": {
"monitored": true,
"status": "up",
"down_since": null,
"open_incident_id": null
},
"last_synced_at": "2026-10-07T09:00:12Z",
"created_at": "2026-03-14T11:20:00Z",
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /sites/{site} — Get a site
Health score, plugin version, pending updates, last backup and uptime status.
Scope: sites:read
curl https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"status": "connected",
"client": {
"id": 12,
"name": "Acme Foods"
},
"labels": [
"woocommerce",
"care-plan"
],
"health_score": 86,
"wordpress_version": "6.8.2",
"php_version": "8.3.12",
"connector": {
"version": "0.13.0",
"distribution": "wporg"
},
"pending_updates": {
"total": 3,
"core": true,
"plugins": 2,
"themes": 0
},
"last_backup": {
"id": 5120,
"type": "scheduled",
"completed_at": "2026-10-07T02:06:51Z",
"size_bytes": 418381022
},
"uptime": {
"monitored": true,
"status": "up",
"down_since": null,
"open_incident_id": null
},
"last_synced_at": "2026-10-07T09:00:12Z",
"created_at": "2026-03-14T11:20:00Z",
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
}
}
POST /sites/{site}/sync — Refresh a site's data
Queues a fresh state pull from the site (versions, plugins, users, health). Doesn't ask WordPress.org for new versions — use check-updates for that.
Scope: sites:write
curl -X POST https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/sync \
-H "Authorization: Bearer $WCH_API_KEY"
Response 202
{
"data": {
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"action": "sync",
"status": "queued"
}
}
POST /sites/{site}/check-updates — Check a site for updates
Has WordPress re-check WordPress.org for core, plugin and theme updates, then refreshes the site's data — the app's Resync button.
Scope: sites:write
curl -X POST https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/check-updates \
-H "Authorization: Bearer $WCH_API_KEY"
Response 202
{
"data": {
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"action": "check_updates",
"status": "queued"
}
}
Updates
POST /sites/{site}/update-batches — Start safe updates
Starts an update batch through the safe-update pipeline — a restore point first, the updates, then before/after screenshots compared, with the site's automatic rollback if it has one. When the site's update policy asks for approval (or require_approval is true), the batch waits in the app's approval queue instead (review_state: awaiting_approval); the API can't approve it.
Without items, every pending update is included.
Scope: updates:write
curl -X POST https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/update-batches \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"items": [{"kind": "plugin", "slug": "woocommerce/woocommerce.php"}]}'
Response 202
{
"data": {
"id": 2207,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"mode": "autopilot",
"status": "pending",
"review_state": "released",
"diff_percent": null,
"items": [
{
"kind": "plugin",
"slug": "woocommerce/woocommerce.php",
"from_version": "9.3.1",
"to_version": "9.3.3",
"status": "pending",
"error": null
}
],
"rollback": null,
"created_at": "2026-10-07T03:00:00Z",
"updated_at": "2026-10-07T03:07:12Z",
"links": {
"app": "https://app.wpclienthub.com/sites/42?tab=updates"
}
}
}
GET /updates/pending — Pending updates
Connected sites with at least one pending update, and what is pending — as of each site's last sync. A page can hold fewer than limit entries while has_more is true; keep following next_cursor.
Scope: updates:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).
curl https://app.wpclienthub.com/api/v1/updates/pending \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"client": {
"id": 12,
"name": "Acme Foods"
},
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
},
"checked_at": "2026-10-07T09:00:12Z",
"items": [
{
"kind": "core",
"slug": null,
"name": "WordPress",
"from_version": "6.8.2",
"to_version": "6.8.3"
},
{
"kind": "plugin",
"slug": "woocommerce/woocommerce.php",
"name": "WooCommerce",
"from_version": "9.3.1",
"to_version": "9.3.3"
}
]
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /update-batches — List update batches
Newest first.
Scope: updates:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— Only batches with this status. One of:pending,running,completed,failed.mode— manual (started item by item in the app) or autopilot (the safe-update pipeline — scheduled, or started through this API). One of:manual,autopilot.
curl https://app.wpclienthub.com/api/v1/update-batches \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 2207,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"mode": "autopilot",
"status": "completed",
"review_state": "clean",
"diff_percent": 0.42,
"items": [
{
"kind": "plugin",
"slug": "woocommerce/woocommerce.php",
"from_version": "9.3.1",
"to_version": "9.3.3",
"status": "succeeded",
"error": null
},
{
"kind": "core",
"slug": null,
"from_version": "6.8.2",
"to_version": "6.8.3",
"status": "succeeded",
"error": null
}
],
"rollback": null,
"created_at": "2026-10-07T03:00:00Z",
"updated_at": "2026-10-07T03:07:12Z",
"links": {
"app": "https://app.wpclienthub.com/sites/42?tab=updates"
}
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /update-batches/{batch} — Get an update batch
Per-item results, the visual check and any rollback.
Scope: updates:read
curl https://app.wpclienthub.com/api/v1/update-batches/2207 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": 2207,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"mode": "autopilot",
"status": "completed",
"review_state": "clean",
"diff_percent": 0.42,
"items": [
{
"kind": "plugin",
"slug": "woocommerce/woocommerce.php",
"from_version": "9.3.1",
"to_version": "9.3.3",
"status": "succeeded",
"error": null
},
{
"kind": "core",
"slug": null,
"from_version": "6.8.2",
"to_version": "6.8.3",
"status": "succeeded",
"error": null
}
],
"rollback": null,
"created_at": "2026-10-07T03:00:00Z",
"updated_at": "2026-10-07T03:07:12Z",
"links": {
"app": "https://app.wpclienthub.com/sites/42?tab=updates"
}
}
}
Backups
POST /sites/{site}/backups — Back up a site now
Starts a manual backup. Poll GET /backups/{id} or subscribe to backup.completed / backup.failed. 409 backup_in_progress or restore_in_progress when one is already running.
Scope: backups:write or sites:write
curl -X POST https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/backups \
-H "Authorization: Bearer $WCH_API_KEY"
Response 202
{
"data": {
"id": 5120,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"type": "manual",
"status": "pending",
"location": "local",
"offsite": {
"copied": false,
"verified": false
},
"restorable": false,
"size_bytes": null,
"files_count": null,
"error": null,
"created_at": "2026-10-07T02:00:04Z",
"updated_at": "2026-10-07T02:06:51Z"
}
}
GET /backups — List backups
Restore points, newest first.
Scope: backups:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— Only backups with this status. One of:pending,running,completed,failed.type— Only this kind of backup. One of:manual,scheduled,pre_update,pre_restore,pre_cleanup.
curl https://app.wpclienthub.com/api/v1/backups \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 5120,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"type": "scheduled",
"status": "completed",
"location": "both",
"offsite": {
"copied": true,
"verified": true
},
"restorable": true,
"size_bytes": 418381022,
"files_count": 12408,
"error": null,
"created_at": "2026-10-07T02:00:04Z",
"updated_at": "2026-10-07T02:06:51Z"
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /backups/{backup} — Get a backup
One restore point.
Scope: backups:read
curl https://app.wpclienthub.com/api/v1/backups/5120 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": 5120,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"type": "scheduled",
"status": "completed",
"location": "both",
"offsite": {
"copied": true,
"verified": true
},
"restorable": true,
"size_bytes": 418381022,
"files_count": 12408,
"error": null,
"created_at": "2026-10-07T02:00:04Z",
"updated_at": "2026-10-07T02:06:51Z"
}
}
Uptime
GET /uptime — Uptime status
Per site: whether it is monitored, up or down, the open incident and the last 30 days' uptime.
Scope: uptime:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).
curl https://app.wpclienthub.com/api/v1/uptime \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"client": {
"id": 12,
"name": "Acme Foods"
},
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
},
"monitored": true,
"status": "up",
"open_incident": null,
"uptime_30d_pct": 99.97
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /uptime/incidents — List incidents
Newest first.
Scope: uptime:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— open or resolved. One of:open,resolved.check_type— Only incidents from this kind of check. One of:http,ssl,domain.
curl https://app.wpclienthub.com/api/v1/uptime/incidents \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 8812,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"check_type": "http",
"status": "resolved",
"cause": "HTTP 503",
"region": "eu-fsn",
"started_at": "2026-10-07T08:41:00Z",
"resolved_at": "2026-10-07T08:53:30Z",
"duration_seconds": 750
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
Security
GET /security/findings — List security findings
Known vulnerabilities in what your sites run, newest first. Open ones unless status says otherwise.
Scope: security:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— Default open; `all` for every finding. One of:open,resolved,ignored,all.severity— Only this severity. One of:critical,high,medium,low,unknown.
curl https://app.wpclienthub.com/api/v1/security/findings \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 3317,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"component": {
"kind": "plugin",
"slug": "contact-form-7",
"installed_version": "5.9.2"
},
"title": "Contact Form 7 < 5.9.5 - Unauthenticated Open Redirect",
"severity": "medium",
"cvss": 6.1,
"cve_id": "CVE-2024-4704",
"fixed_in": "5.9.5",
"status": "open",
"source_url": "https://patchstack.com/database/vulnerability/contact-form-7",
"first_detected_at": "2026-10-07T09:14:03Z",
"resolved_at": null
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
Clients
GET /clients — List clients
Oldest first.
Scope: clients:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.q— Name contains this text.
curl https://app.wpclienthub.com/api/v1/clients \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 12,
"name": "Acme Foods",
"email": "ops@acme.example",
"phone": null,
"sites_count": 3,
"created_at": "2026-03-14T11:00:00Z"
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /clients/{client} — Get a client
With the client's sites.
Scope: clients:read
curl https://app.wpclienthub.com/api/v1/clients/12 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": 12,
"name": "Acme Foods",
"email": "ops@acme.example",
"phone": null,
"sites_count": 1,
"created_at": "2026-03-14T11:00:00Z",
"sites": [
{
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"client": {
"id": 12,
"name": "Acme Foods"
},
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
}
]
}
}
Tickets
GET /tickets — List tickets
Newest first. 403 help_desk_unavailable when the organization doesn't have the help desk.
Scope: tickets:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— One status, or `open` for everything not resolved or closed. One of:new,diagnosing,diagnosed,awaiting_estimate,estimated,approved,in_progress,delivered,resolved,closed,needs_human,waiting_on_client,open.client_id— Only this client's tickets.
curl https://app.wpclienthub.com/api/v1/tickets \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 731,
"number": 58,
"reference": "#58",
"title": "Contact form not sending",
"description": "Since this morning the contact form on /contact shows a spinner and never sends.",
"status": "new",
"priority": "high",
"category": "form_broken",
"source": "client",
"page_url": "https://acme-bakery.example/contact",
"client": {
"id": 12,
"name": "Acme Foods"
},
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example"
},
"assignee": null,
"created_at": "2026-10-07T09:58:12Z",
"updated_at": "2026-10-07T09:58:12Z",
"links": {
"helpdesk": "https://helpdesk.wpclienthub.com/requests/731"
}
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
POST /tickets — Open a ticket
Opens a ticket for a client, as your team would in the help desk — written by the person who created the key (or the owner, once they have left). An automatic check runs unless diagnose is false.
Scope: tickets:write
curl -X POST https://app.wpclienthub.com/api/v1/tickets \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"client_id": 12, "site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f", "title": "Contact form not sending", "description": "Reported by phone: the form shows a spinner and never sends.", "priority": "high"}'
Response 201
{
"data": {
"id": 731,
"number": 58,
"reference": "#58",
"title": "Contact form not sending",
"description": "Since this morning the contact form on /contact shows a spinner and never sends.",
"status": "new",
"priority": "high",
"category": "form_broken",
"source": "agency",
"page_url": "https://acme-bakery.example/contact",
"client": {
"id": 12,
"name": "Acme Foods"
},
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example"
},
"assignee": null,
"created_at": "2026-10-07T09:58:12Z",
"updated_at": "2026-10-07T09:58:12Z",
"links": {
"helpdesk": "https://helpdesk.wpclienthub.com/requests/731"
},
"conversation": []
}
}
GET /tickets/{ticket} — Get a ticket
With its public conversation. Internal notes never appear in the API.
Scope: tickets:read
curl https://app.wpclienthub.com/api/v1/tickets/731 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": 731,
"number": 58,
"reference": "#58",
"title": "Contact form not sending",
"description": "Since this morning the contact form on /contact shows a spinner and never sends.",
"status": "new",
"priority": "high",
"category": "form_broken",
"source": "client",
"page_url": "https://acme-bakery.example/contact",
"client": {
"id": 12,
"name": "Acme Foods"
},
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example"
},
"assignee": null,
"created_at": "2026-10-07T09:58:12Z",
"updated_at": "2026-10-07T09:58:12Z",
"links": {
"helpdesk": "https://helpdesk.wpclienthub.com/requests/731"
},
"conversation": [
{
"id": 9051,
"author": {
"type": "client",
"name": "Dana Ruiz"
},
"body": "Thanks — the form sends again. Could you also check the shop page on mobile?",
"created_at": "2026-10-07T10:02:41Z"
}
]
}
}
POST /tickets/{ticket}/replies — Reply on a ticket
Adds a message to the ticket's public conversation — the client sees it and is notified as for any reply from your team.
Scope: tickets:write
curl -X POST https://app.wpclienthub.com/api/v1/tickets/731/replies \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"body": "Fixed — a caching plugin update had blocked the form. Could you try once more?"}'
Response 201
{
"data": {
"id": 9052,
"author": {
"type": "agency",
"name": "Priya Shah"
},
"body": "Fixed — a caching plugin update had blocked the form. Could you try once more?",
"created_at": "2026-10-07T10:20:05Z"
}
}
Reports
GET /reports — List client reports
Newest first. A completed report carries pdf.url, a signed link that works for 15 minutes without the key.
Scope: reports:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).status— Only reports with this status. One of:pending,running,completed,failed.
curl https://app.wpclienthub.com/api/v1/reports \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 318,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"site_name": "Acme Bakery",
"period_start": "2026-09-01",
"period_end": "2026-09-30",
"status": "completed",
"error": null,
"sent_to": [
"dana@acme.example"
],
"pdf": {
"url": "https://app.wpclienthub.com/api/v1/reports/318/pdf?expires=1760001000&signature=3c9f…",
"expires_at": "2026-10-07T09:30:00Z"
},
"created_at": "2026-10-01T06:00:00Z"
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
GET /reports/{report} — Get a client report
Fetch it again for a fresh pdf.url.
Scope: reports:read
curl https://app.wpclienthub.com/api/v1/reports/318 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": {
"id": 318,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"site_name": "Acme Bakery",
"period_start": "2026-09-01",
"period_end": "2026-09-30",
"status": "completed",
"error": null,
"sent_to": [],
"pdf": {
"url": "https://app.wpclienthub.com/api/v1/reports/318/pdf?expires=1760001000&signature=3c9f…",
"expires_at": "2026-10-07T09:30:00Z"
},
"created_at": "2026-10-01T06:00:00Z"
}
}
GET /reports/{report}/pdf — Download a report PDF (signed link)
The link in a report's pdf.url. Its signature is the credential — no API key is sent. It expires after 15 minutes (403 link_expired).
Query parameters
expires—signature—
curl -o report.pdf "https://app.wpclienthub.com/api/v1/reports/318/pdf?expires=1760001000&signature=3c9f…"
Activity
GET /activity — Activity Log
Newest first. Names and sentences only; IP and email addresses stay in the app's export.
Scope: activity:read
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.site_id— Only this site (its UUID).category— Only this category. One of:content,plugins,themes,users,settings,security,core,media,system,commerce,support.source— wordpress (from the site) or hub (from WPClientHub). One of:wordpress,hub.action— Only this action, e.g. backup.completed.since— Only events at or after this time (ISO 8601).
curl https://app.wpclienthub.com/api/v1/activity \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": 99120,
"occurred_at": "2026-10-07T02:06:51Z",
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery"
},
"source": "hub",
"category": "system",
"action": "backup.completed",
"summary": "Backup completed (399 MB)",
"actor": {
"type": "system",
"name": null
},
"object": null
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
SEO management
Available when the organization’s plan includes SEO management (Studio and above; every Scale organization does). Pages are listed as the app last read them from the site — open the site’s SEO → Pages section once so the list exists. See SEO management.
GET /sites/{site}/seo/pages — List a site’s pages with their SEO fields
Every published page and post with its fields (title, description, canonical, noindex, nofollow, og_title, og_description, og_image, twitter_card) as read from the SEO plugin’s own fields when Yoast SEO, Rank Math, All in One SEO or SEOPress runs the site, plus effective_title / effective_description — what the page actually gets. Oldest first.
Scope: seo:read
Query parameters
limit,cursor— paging.type— only this post type (page,post,product…).noindex—truefor only the pages set to noindex.
curl https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/seo/pages?type=page \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"post_id": 42,
"type": "page",
"post_title": "Boiler repair",
"url": "https://acme-plumbing.example/boiler-repair/",
"fields": {
"title": "Boiler repair in Leeds — same-day engineers",
"description": "Gas Safe engineers for every make of boiler, usually the same day.",
"canonical": "",
"noindex": false,
"nofollow": false,
"og_title": "",
"og_description": "",
"og_image": "",
"twitter_card": ""
},
"effective_title": "Boiler repair in Leeds — same-day engineers",
"effective_description": "Gas Safe engineers for every make of boiler, usually the same day.",
"noindex": false,
"key_page": "In the “Primary” menu",
"modified_at": "2026-10-02T14:11:08+00:00",
"synced_at": "2026-10-08T09:00:12+00:00",
"links": { "app": "https://app.wpclienthub.com/sites/42?tab=seo&seo=pages" }
}
],
"meta": { "pagination": { "limit": 25, "has_more": false, "next_cursor": null } }
}
PATCH /sites/{site}/seo/pages/{page} — Change a page’s SEO fields
Writes the fields you send to the site at once — into the SEO plugin’s own fields when one runs the site — and keeps a revision (undoable in the app). Send only the fields to change; an empty string clears one. {page} is the page’s post_id. 409 page_changed when the page was changed on the site since it was last read (read it again first); 422 unsupported when the site’s SEO plugin is one WPClientHub does not edit; 403 plan_required below Studio.
Scope: seo:write
curl -X PATCH https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/seo/pages/42 \
-H "Authorization: Bearer $WCH_API_KEY" -H "Content-Type: application/json" \
-d '{"fields":{"title":"Emergency plumber in Leeds — 24/7 call-outs","description":"Burst pipe or blocked drain? Our Leeds plumbers arrive within the hour, day or night."}}'
Response 200 — the page as it is now (the same shape as the list).
GET /sites/{site}/seo/redirects — List a site’s redirects
The redirects WPClientHub manages on the site, oldest first, with the hit count the site reports.
Scope: seo:read
Response 200
{
"data": [
{
"id": 318,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"source": "/services/boiler-repairs",
"match": "exact",
"target": "/boiler-repair",
"code": 301,
"hits": 212,
"last_hit_at": "2026-10-08T08:41:03+00:00",
"note": "Old service page, merged in the October rebuild",
"created_at": "2026-10-01T10:02:00+00:00"
}
],
"meta": { "pagination": { "limit": 25, "has_more": false, "next_cursor": null } }
}
POST /sites/{site}/seo/redirects — Add a redirect
Adds one redirect and sends the site its whole set. source is a path on the site (with or without a trailing slash; a query string makes it match only that query) or, with match: regex, a regular expression; target is a path or a full URL (not needed for 410); code is 301 (default), 302, 307, 308 or 410. 422 duplicate_source when the source already has a redirect, redirect_loop when it would lead back to itself, invalid_redirect otherwise; 403 plan_required below Studio.
Scope: seo:write
curl -X POST https://app.wpclienthub.com/api/v1/sites/7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f/seo/redirects \
-H "Authorization: Bearer $WCH_API_KEY" -H "Content-Type: application/json" \
-d '{"source":"/services/boiler-repairs","target":"/boiler-repair","code":301,"note":"Old service page"}'
Response 201 — the redirect (the same shape as the list).
Webhooks
GET /event-types — Webhook event types
Every event a webhook endpoint can subscribe to, with an example of its data. Needs no scope.
curl https://app.wpclienthub.com/api/v1/event-types \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"type": "site.down",
"label": "Site down",
"description": "A site stopped answering its uptime check and was still down at the confirmation recheck, about 3 minutes later.",
"example": {
"site": "…",
"incident": "…"
}
},
"…"
]
}
GET /webhooks — List webhook endpoints
Endpoints made in the app and through the API.
Scope: webhooks:manage
Query parameters
limit— Page size, 1–100.cursor— The `next_cursor` of the previous page.
curl https://app.wpclienthub.com/api/v1/webhooks \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200
{
"data": [
{
"id": "9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31",
"url": "https://hooks.zapier.com/hooks/standard/123456/abcdef/",
"description": "Zapier: New Site Down",
"events": [
"site.down"
],
"enabled": true,
"disabled_reason": null,
"source": "api",
"last_delivery_at": "2026-10-07T08:44:21Z",
"last_success_at": "2026-10-07T08:44:21Z",
"last_response_status": 200,
"created_at": "2026-10-02T15:10:00Z"
}
],
"meta": {
"pagination": {
"limit": 25,
"has_more": false,
"next_cursor": null
}
}
}
POST /webhooks — Create a webhook endpoint
Subscribes a URL to events — what Zapier's REST hooks call. The answer carries the signing secret, once. An endpoint made with a key is switched off if that key is revoked.
Scope: webhooks:manage
curl -X POST https://app.wpclienthub.com/api/v1/webhooks \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.zapier.com/hooks/standard/123456/abcdef/", "events": ["site.down"], "description": "Zap: page on-call"}'
Response 201
{
"data": {
"id": "9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31",
"url": "https://hooks.zapier.com/hooks/standard/123456/abcdef/",
"description": "Zap: page on-call",
"events": [
"site.down"
],
"enabled": true,
"disabled_reason": null,
"source": "api",
"last_delivery_at": null,
"last_success_at": null,
"last_response_status": null,
"created_at": "2026-10-07T09:20:00Z",
"secret": "whsec_4bT0r…"
}
}
GET /webhooks/{webhook} — Get a webhook endpoint
One endpoint.
Scope: webhooks:manage
curl https://app.wpclienthub.com/api/v1/webhooks/9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 200 — the same object as above.
PATCH /webhooks/{webhook} — Change a webhook endpoint
Change its URL, events or description, or switch it off and on (switching on starts a fresh failure count).
Scope: webhooks:manage
curl -X PATCH https://app.wpclienthub.com/api/v1/webhooks/9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31 \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events": ["site.down", "site.up"]}'
Response 200 — the same object as above.
DELETE /webhooks/{webhook} — Delete a webhook endpoint
Zapier's unsubscribe. Its delivery log goes with it.
Scope: webhooks:manage
curl -X DELETE https://app.wpclienthub.com/api/v1/webhooks/9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31 \
-H "Authorization: Bearer $WCH_API_KEY"
Response 204 No Content
POST /webhooks/{webhook}/test — Send a test event
Sends one example of an event ("test": true in the body), signed like the real thing, right now — the answer says how the receiver responded. Never retried.
Scope: webhooks:manage
curl -X POST https://app.wpclienthub.com/api/v1/webhooks/9d1f8a62-5e3b-4c1a-8f0e-2b7d6c5a4e31/test \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "backup.failed"}'
Response 200
{
"data": {
"id": "c41e…",
"event_id": "evt_8b2f…",
"event_type": "backup.failed",
"status": "succeeded",
"attempts": 1,
"response_status": 200,
"error": null,
"duration_ms": 84,
"next_attempt_at": null,
"last_attempt_at": "2026-10-07T09:21:03Z",
"test": true,
"created_at": "2026-10-07T09:21:03Z"
}
}
Webhooks
Add an endpoint in Settings → Webhooks (or with POST /webhooks), pick events, and WPClientHub POSTs a signed JSON event to it when one happens. Each endpoint shows its delivery log — every attempt, the receiver’s status code and the start of its answer — with Send test event and Redeliver.
Events
| Event | When it fires | data has |
|---|---|---|
site.down | A site stopped answering its uptime check and was still down at the confirmation recheck, about 3 minutes later. | site, incident |
site.up | A site that was reported down answers its uptime check again. | site, incident |
backup.completed | A backup finished — manual, scheduled, or the restore point taken before updates. | site, backup |
backup.failed | A backup did not finish. | site, backup |
update_batch.completed | An update batch finished. The visual check’s result is included when the safe-update pipeline ran. | site, batch |
update_batch.failed | Every update in a batch failed, or the site could not be reached. | site, batch |
vulnerability.found | A new security finding on a site, or one that came back after being fixed. | site, finding |
ticket.created | A help desk ticket was opened — by a client in the portal, by your team, or through the API. | ticket |
ticket.replied | A new message on a ticket’s public conversation, from the client or your team. Internal notes never fire it. | ticket, reply |
domain.expiring | A site’s domain is 30, 14, 7, 3 or 1 day from expiring, or has expired. | site, domain |
The objects inside data have exactly the shapes the API returns for them (a site summary, an incident, a backup, an update batch, a finding, a ticket). GET /event-types lists every event with an example.
What a delivery looks like
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: WPClientHub-Webhooks/1.0 (+https://wpclienthub.com/docs/api.html#webhooks)
X-WCH-Event: site.down
X-WCH-Event-Id: evt_3f0c1a2b4d5e4f60718293a4b5c6d7e8
X-WCH-Delivery: 2c7e9a10-4b8d-4e57-9a1c-6f3b2d8e0a95
X-WCH-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_3f0c1a2b4d5e4f60718293a4b5c6d7e8",
"type": "site.down",
"api_version": "v1",
"created_at": "2026-10-07T08:44:10Z",
"test": false,
"organization": {
"id": "0b5e1c2d-3f4a-4b6c-8d9e-0f1a2b3c4d5e",
"name": "Northwind Studio"
},
"data": {
"site": {
"id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"name": "Acme Bakery",
"url": "https://acme-bakery.example",
"client": {
"id": 12,
"name": "Acme Foods"
},
"links": {
"app": "https://app.wpclienthub.com/sites/42"
}
},
"incident": {
"id": 8812,
"site_id": "7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e0f",
"check_type": "http",
"status": "open",
"cause": "HTTP 503",
"region": "eu-fsn",
"started_at": "2026-10-07T08:41:00Z",
"resolved_at": null,
"duration_seconds": 190
}
}
}
test is true for Send test event, which sends example data (the “Acme Bakery” site), not yours.
Verifying the signature
X-WCH-Signature carries t, the Unix time we signed at, and v1, the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with the endpoint’s signing secret (whsec_…, shown once when you add the endpoint). Recompute it, compare in constant time, and reject a t more than 5 minutes from your clock. For 24 hours after you rotate the secret the header carries two v1 values — one per secret; either match is valid.
PHP
<?php
// The raw body — before any JSON decoding.
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WCH_SIGNATURE'] ?? '';
$secret = getenv('WCH_WEBHOOK_SECRET'); // whsec_…
$t = 0;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$name, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($name === 't') { $t = (int) $value; }
if ($name === 'v1') { $signatures[] = $value; }
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
$valid = false;
foreach ($signatures as $signature) {
$valid = $valid || hash_equals($expected, $signature);
}
if (! $valid || abs(time() - $t) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// De-duplicate on $event['id'], handle $event['type'],
// and answer 2xx within 10 seconds.
http_response_code(204);
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// express.raw: the signature covers the exact bytes we sent.
app.post('/wch-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const parts = (req.get('X-WCH-Signature') ?? '').split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([name]) => name === 't')?.[1] ?? 0);
const signatures = parts.filter(([name]) => name === 'v1').map(([, value]) => value);
const expected = crypto
.createHmac('sha256', process.env.WCH_WEBHOOK_SECRET) // whsec_…
.update(`${t}.${req.body}`)
.digest('hex');
const valid = signatures.some((sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
if (!valid || Math.abs(Date.now() / 1000 - t) > 300) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// De-duplicate on event.id, handle event.type, answer within 10 s.
res.sendStatus(204);
});
Both snippets were tested against real signed deliveries, including one sent during a secret rotation. Notification-route webhooks (Settings → Email alerts) carry the same X-WCH-Signature header too, next to their original X-WPClientHub-Signature, so one verifier covers both.
Retries, and when an endpoint is switched off
- Answer with any 2xx within 10 seconds. Anything else — another status, a timeout, a refused connection — is retried: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 8 hours (±10%), 8 attempts over about a day.
- Redirects aren’t followed; use the final URL. The URL must be
httpson a public address — checked when you save it and again before every delivery. - De-duplicate on the event
id: it is the same on every retry and redelivery. Events can arrive out of order. - When a delivery has used every retry and the endpoint hasn’t had a single success since its failures began, the endpoint is switched off, its queued deliveries are cancelled, and the organization’s owners and admins get an email. Fix the receiver, then Switch back on — the failure count starts again.
- A receiver that answers
410 Goneis switched off at once (that is how Zapier says a Zap is gone). - Deliveries and events are kept for 30 days.
Subscribing from code (REST hooks)
Tools that subscribe on their own — Zapier does — create an endpoint with POST /webhooks (scope webhooks:manage) and delete it with DELETE /webhooks/{id}. Those endpoints appear in Settings → Webhooks marked via API, and are switched off if the key that made them is revoked.
curl -X POST https://app.wpclienthub.com/api/v1/webhooks \
-H "Authorization: Bearer $WCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/wch", "events": ["backup.failed", "site.down"]}'
Plans and downgrades
The API, webhooks and the Zapier app are part of Scale only; the 14-day Agency trial doesn’t include them. If your organization leaves Scale, nothing is deleted: keys stay listed and answer 403 plan_required (and can still be revoked), webhook endpoints are kept but nothing is sent and events from that time aren’t queued. Back on Scale, both work again as they were.
Limits & defaults
| What | Value |
|---|---|
| Plan | Scale (not in the Agency trial) |
| Rate limit | 120 requests a minute per key |
| Page size | 25 by default, up to 100 |
| Active keys | 25 per organization |
| Key expiry | Never, or a date you pick (up to 3 years) |
| Report PDF link | 15 minutes; fetch the report again for a new one |
| Webhook endpoints | 50 per organization |
| Webhook timeout | 10 seconds per attempt |
| Webhook retries | 8 attempts over about 24 hours |
| Signature tolerance | 5 minutes |
| Delivery log | 30 days; the first 2 KB of each answer |
| Who manages keys and endpoints | Owners and admins; every member can see them |