Black Friday: 50% off. Your first year on any annual plan, until 1 December. See the dealFirst year on annual plans, until 1 December
WPClientHub V2 is live. Fewer plugins on your client sites: caching, forms and image optimization now come built into WPClientHub One. See what’s newCaching, forms and images, built in. See what’s new
Sign in Start free

← Docs  /  Integrations

Public API & webhooks

Updated 2026-10-07

The WPClientHub REST API on Scale: scoped keys, rate limits, pagination, every endpoint with an example, and signed webhooks with verification code.

Browse documentation

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

  1. Go to Settings → API keys and press Create key (owners and admins).
  2. Name it after what will use it — “Zapier”, “Client dashboard” — so you can tell keys apart later.
  3. Pick its access: Read-only, Zapier (exactly what the Zapier app needs), Full access, or scope by scope. Optionally pick an expiry date.
  4. 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.

ScopeAllows
sites:readList and show sites: health score, plugin version, pending updates, last backup, uptime status.
sites:writeAsk a site to sync, check for updates, or take a backup. Nothing is deleted or disconnected.
updates:readPending updates per site; update batches and their status.
updates:writeStart a safe update batch — restore point first, visual check after, the site’s approval setting respected.
backups:readRestore points and their status.
backups:writeStart a backup.
uptime:readUptime status per site, and incidents.
security:readSecurity findings — vulnerable core, plugins and themes.
clients:readClients and their sites.
tickets:readHelp desk tickets and their public conversation (never internal notes).
tickets:writeOpen a ticket for a client; reply on one.
reports:readClient reports, with a short-lived PDF link.
activity:readThe Activity Log.
seo:readEvery 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:writeChange a page’s SEO fields on the site (a revision is kept, undoable in the app) and add redirects. Nothing is deleted.
webhooks:manageCreate, 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
  }
}
StatusCodes
401unauthenticated, invalid_api_key, api_key_revoked, api_key_expired
403insufficient_scope, plan_required, help_desk_unavailable, organization_suspended, link_expired (a report link)
404not_found — also for another organization’s id: it looks exactly like one that never existed
405method_not_allowed
409site_not_connected, backup_in_progress, restore_in_progress, update_batch_in_progress, no_pending_updates
422validation_failed (problems by field in details), invalid_limit, invalid_cursor, invalid_status and other filter codes, update_not_pending, site_client_mismatch
429rate_limited
500server_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).

EndpointScopeWhat it does
GET /meany keyWho this key belongs to
GET /sitessites:readList sites
GET /sites/{site}sites:readGet a site
POST /sites/{site}/syncsites:writeRefresh a site's data
POST /sites/{site}/check-updatessites:writeCheck a site for updates
POST /sites/{site}/update-batchesupdates:writeStart safe updates
GET /updates/pendingupdates:readPending updates
GET /update-batchesupdates:readList update batches
GET /update-batches/{batch}updates:readGet an update batch
POST /sites/{site}/backupsbackups:write or sites:writeBack up a site now
GET /backupsbackups:readList backups
GET /backups/{backup}backups:readGet a backup
GET /uptimeuptime:readUptime status
GET /uptime/incidentsuptime:readList incidents
GET /security/findingssecurity:readList security findings
GET /clientsclients:readList clients
GET /clients/{client}clients:readGet a client
GET /ticketstickets:readList tickets
POST /ticketstickets:writeOpen a ticket
GET /tickets/{ticket}tickets:readGet a ticket
POST /tickets/{ticket}/repliestickets:writeReply on a ticket
GET /reportsreports:readList client reports
GET /reports/{report}reports:readGet a client report
GET /reports/{report}/pdfsigned linkDownload a report PDF (signed link)
GET /activityactivity:readActivity Log
GET /sites/{site}/seo/pagesseo:readList a site’s pages with their SEO fields
PATCH /sites/{site}/seo/pages/{page}seo:writeChange a page’s SEO fields
GET /sites/{site}/seo/redirectsseo:readList a site’s redirects
POST /sites/{site}/seo/redirectsseo:writeAdd a redirect
GET /event-typesany keyWebhook event types
GET /webhookswebhooks:manageList webhook endpoints
POST /webhookswebhooks:manageCreate a webhook endpoint
GET /webhooks/{webhook}webhooks:manageGet a webhook endpoint
PATCH /webhooks/{webhook}webhooks:manageChange a webhook endpoint
DELETE /webhooks/{webhook}webhooks:manageDelete a webhook endpoint
POST /webhooks/{webhook}/testwebhooks:manageSend 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 — true for 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

EventWhen it firesdata has
site.downA site stopped answering its uptime check and was still down at the confirmation recheck, about 3 minutes later.site, incident
site.upA site that was reported down answers its uptime check again.site, incident
backup.completedA backup finished — manual, scheduled, or the restore point taken before updates.site, backup
backup.failedA backup did not finish.site, backup
update_batch.completedAn update batch finished. The visual check’s result is included when the safe-update pipeline ran.site, batch
update_batch.failedEvery update in a batch failed, or the site could not be reached.site, batch
vulnerability.foundA new security finding on a site, or one that came back after being fixed.site, finding
ticket.createdA help desk ticket was opened — by a client in the portal, by your team, or through the API.ticket
ticket.repliedA new message on a ticket’s public conversation, from the client or your team. Internal notes never fire it.ticket, reply
domain.expiringA 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 https on 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 Gone is 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

WhatValue
PlanScale (not in the Agency trial)
Rate limit120 requests a minute per key
Page size25 by default, up to 100
Active keys25 per organization
Key expiryNever, or a date you pick (up to 3 years)
Report PDF link15 minutes; fetch the report again for a new one
Webhook endpoints50 per organization
Webhook timeout10 seconds per attempt
Webhook retries8 attempts over about 24 hours
Signature tolerance5 minutes
Delivery log30 days; the first 2 KB of each answer
Who manages keys and endpointsOwners and admins; every member can see them

Troubleshooting

Every call answers 401 invalid_api_key
The key is not the one that was shown when it was created, or it was cut short when copied. Keys are 49 characters: wch_live_ and 40 more. If it is lost, revoke it in Settings → API keys and create another — we can’t show it again.
A call answers 403 insufficient_scope
The key lacks the scope that endpoint needs; details.required names it. Scopes can’t be added to an existing key — create a new key with the scopes you need and revoke the old one.
A call answers 403 plan_required
The organization is no longer on Scale. The key is kept and works again as soon as the plan is Scale.
Starting updates answers 409 update_batch_in_progress
A batch for that site is still running or waiting for approval in the app. Approve or finish it first — WPClientHub never stacks update runs on one site.
My webhook receiver says the signature doesn’t match
Sign the raw body exactly as received — before any JSON parsing or re-encoding — as "t.body". Check you use the endpoint’s current secret (after a rotation the old one keeps working for 24 hours), and that your server clock is within 5 minutes.
An endpoint was switched off
Every delivery to it failed for about a day, or it answered 410 Gone. Its delivery log shows the answers it gave. Fix the receiver, then press Switch back on in Settings → Webhooks and send a test event.