# WordPress Two-Way Integration Setup

This setup turns the WordPress integration into an active connection instead of a one-way sync. CareHQ can now track connected WordPress sites, receive signed health pings, and queue signed outbound webhook events for the plugin.

## CareHQ Changes Added

- `POST /api/health/ping` registers or updates a WordPress site installation.
- `wordpress_sites` stores plugin version, PHP version, WordPress version, last sync time, last ping time, webhook URL, health status, and a per-site signing secret.
- `wordpress_request_nonces` stores signed request nonces for replay protection.
- `wordpress_webhook_events` stores outbound webhook events for later delivery/retry.
- `$lib/server/wordpress/*` contains shared API-key auth, HMAC signing, site health, and outbound webhook helpers.
- `deliverWordPressWebhookEvent` in `webhooks.ts` rejects localhost and private-range webhook URLs before calling `fetch()`, returning a clear error instead of Cloudflare 1003.

## Environment Variables

```bash
WORDPRESS_MIN_PLUGIN_VERSION=1.1.0
WORDPRESS_LATEST_PLUGIN_VERSION=1.2.0
WORDPRESS_REQUIRE_SIGNATURES=false
```

- `WORDPRESS_MIN_PLUGIN_VERSION` — Sites running below this version get `healthStatus: 'outdated'` and receive a `plugin.update_available` webhook with `required: true`.
- `WORDPRESS_LATEST_PLUGIN_VERSION` — Newest available release. Sites above the minimum but below this receive `updateAvailable: true` in the ping response and a webhook with `required: false`.
- Start with `WORDPRESS_REQUIRE_SIGNATURES=false` so existing plugins can register once using only the API key. After installed plugins store the returned `signingSecret`, flip it to `true`.

## Health Ping Contract

Endpoint: `POST /api/health/ping`

Headers for first registration:

```http
x-api-key: YOUR_API_KEY
content-type: application/json
```

Headers after the plugin has a signing secret:

```http
x-api-key: YOUR_API_KEY
content-type: application/json
x-carehq-signature: sha256=HEX_HMAC
x-carehq-timestamp: 1778112000000
x-carehq-nonce: UUID_OR_RANDOM_STRING
```

Body:

```json
{
	"siteUrl": "https://example-care-home.co.uk",
	"webhookUrl": "https://example-care-home.co.uk/wp-json/carehq/v1/webhook",
	"pluginVersion": "1.1.0",
	"phpVersion": "8.2.12",
	"wordpressVersion": "6.5.4",
	"lastSyncTime": "2026-05-07T10:30:00.000Z",
	"metadata": {
		"multisite": false
	}
}
```

First successful registration returns `data.signingSecret`. Store it with the plugin settings and do not show it in routine admin UI.

The response also includes a `pluginVersion` object on every ping:

```json
{
	"success": true,
	"data": {
		"siteId": "...",
		"siteUrl": "https://example-care-home.co.uk",
		"healthStatus": "healthy",
		"lastPingAt": "2026-05-07T10:30:00.000Z",
		"serverTime": "2026-05-07T10:30:01.000Z",
		"requireSignedRequests": false,
		"pluginVersion": {
			"current": "1.1.0",
			"latest": "1.2.0",
			"minimum": "1.1.0",
			"updateAvailable": true,
			"updateRequired": false
		}
	}
}
```

Use `pluginVersion.updateAvailable` and `pluginVersion.updateRequired` to drive in-plugin notices on every ping, not just webhook events.

## Signature Algorithm

CareHQ signs and verifies this string:

```text
{timestamp}.{nonce}.{raw_json_body}
```

Use HMAC-SHA256 with the per-site signing secret. Send the lowercase hex digest as:

```text
sha256=hex_digest_here
```

Timestamps are Unix milliseconds. CareHQ accepts signatures within a five-minute clock window and rejects reused nonces.

## Signed Home Syncs

After registration, include `siteUrl` in `/api/homes/sync` payloads and sign the exact JSON body with the same HMAC headers. `siteUrl` is backward-compatible while `WORDPRESS_REQUIRE_SIGNATURES=false`, but it is required once signed sync enforcement is enabled.

```json
{
	"siteUrl": "https://example-care-home.co.uk",
	"homes": [
		{
			"homeId": "SCH-001",
			"name": "Sunrise Care Home",
			"provider": "CareHQ"
		}
	]
}
```

## WordPress Plugin Instructions

### 1. Store New Settings

Add plugin options for:

- CareHQ API base URL
- CareHQ API key
- CareHQ signing secret
- webhook endpoint enabled/disabled
- last successful sync time
- last successful health ping time

The signing secret should be saved with `autoload` disabled. Treat it like a password.

### 2. Send a Registration Ping on Connect

When an admin saves the API key, call `/api/health/ping` without signature headers. If the response contains `data.signingSecret`, persist it.

```php
function carehq_health_ping_body() {
    return wp_json_encode(array(
        'siteUrl' => home_url(),
        'webhookUrl' => rest_url('carehq/v1/webhook'),
        'pluginVersion' => CAREHQ_PLUGIN_VERSION,
        'phpVersion' => PHP_VERSION,
        'wordpressVersion' => get_bloginfo('version'),
        'lastSyncTime' => get_option('carehq_last_sync_time'),
        'metadata' => array(
            'multisite' => is_multisite(),
        ),
    ));
}
```

### 3. Sign Future Requests

Use the exact raw JSON body string when creating the HMAC. Do not pretty-print or rebuild JSON after signing. Use these headers for health pings, home syncs, and later CareHQ API writes.

```php
function carehq_signed_headers($body) {
    $secret = get_option('carehq_signing_secret');
    $timestamp = (string) round(microtime(true) * 1000);
    $nonce = wp_generate_uuid4();
    $payload = $timestamp . '.' . $nonce . '.' . $body;
    $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);

    return array(
        'Content-Type' => 'application/json',
        'x-api-key' => get_option('carehq_api_key'),
        'x-carehq-signature' => $signature,
        'x-carehq-timestamp' => $timestamp,
        'x-carehq-nonce' => $nonce,
    );
}
```

### 4. Schedule Health Pings

Register a WP-Cron event that runs at least daily. Hourly is better for operational dashboards.

```php
add_action('carehq_health_ping', 'carehq_send_health_ping');

function carehq_send_health_ping() {
    $body = carehq_health_ping_body();
    $secret = get_option('carehq_signing_secret');
    $headers = $secret ? carehq_signed_headers($body) : array(
        'Content-Type' => 'application/json',
        'x-api-key' => get_option('carehq_api_key'),
    );

    $response = wp_remote_post(carehq_api_url('/api/health/ping'), array(
        'headers' => $headers,
        'body' => $body,
        'timeout' => 15,
    ));

    if (!is_wp_error($response)) {
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if (!empty($data['data']['signingSecret'])) {
            update_option('carehq_signing_secret', $data['data']['signingSecret'], false);
        }
        update_option('carehq_last_health_ping_time', gmdate('c'), false);
    }
}
```

### 5. Add the Webhook Receiver

Create `POST /wp-json/carehq/v1/webhook`. Verify CareHQ's signature using the stored signing secret before acting on the event.

Expected event body:

```json
{
	"id": "event_uuid",
	"type": "subscription.downgraded",
	"createdAt": "2026-05-07T12:00:00.000Z",
	"data": {
		"plan": "starter",
		"seats": 3
	}
}
```

Initial event types:

- `home.over_trial_limit` — show a banner and gate adding more homes.
- `subscription.downgraded` — refresh local entitlement state and gate paid features.
- `admin_user.added` — refresh local admin/team display if the plugin shows connected users.
- `plugin.update_available` — a newer or required plugin version exists; show an admin notice.

### 6. Verify Incoming Webhooks in WordPress

```php
function carehq_verify_webhook_request(WP_REST_Request $request) {
    $secret = get_option('carehq_signing_secret');
    if (!$secret) {
        return new WP_Error('carehq_missing_secret', 'CareHQ signing secret is missing.', array('status' => 401));
    }

    $body = $request->get_body();
    $timestamp = $request->get_header('x-carehq-timestamp');
    $nonce = $request->get_header('x-carehq-nonce');
    $signature = $request->get_header('x-carehq-signature');

    if (!$timestamp || !$nonce || !$signature) {
        return new WP_Error('carehq_missing_signature', 'Missing CareHQ signature headers.', array('status' => 401));
    }

    if (abs((round(microtime(true) * 1000)) - (int) $timestamp) > 5 * 60 * 1000) {
        return new WP_Error('carehq_expired_signature', 'CareHQ signature has expired.', array('status' => 401));
    }

    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $nonce . '.' . $body, $secret);
    if (!hash_equals($expected, $signature)) {
        return new WP_Error('carehq_invalid_signature', 'Invalid CareHQ signature.', array('status' => 401));
    }

    if (get_transient('carehq_nonce_' . $nonce)) {
        return new WP_Error('carehq_replay', 'CareHQ webhook nonce has already been used.', array('status' => 409));
    }

    set_transient('carehq_nonce_' . $nonce, 1, 5 * MINUTE_IN_SECONDS);
    return true;
}
```

### 7. Register the Webhook Route

```php
add_action('rest_api_init', function () {
    register_rest_route('carehq/v1', '/webhook', array(
        'methods' => 'POST',
        'permission_callback' => 'carehq_verify_webhook_request',
        'callback' => 'carehq_handle_webhook',
    ));
});

function carehq_handle_webhook(WP_REST_Request $request) {
    $event = json_decode($request->get_body(), true);

    switch ($event['type'] ?? '') {
        case 'home.over_trial_limit':
            update_option('carehq_entitlement_banner', $event['data'], false);
            break;
        case 'subscription.downgraded':
            update_option('carehq_subscription_state', $event['data'], false);
            break;
        case 'admin_user.added':
            update_option('carehq_admin_user_event', $event['data'], false);
            break;
        case 'plugin.update_available':
            update_option('carehq_plugin_update', $event['data'], false);
            break;
    }

    return rest_ensure_response(array('success' => true));
}
```

### 8. Handle Plugin Version State from the Ping Response

After every health ping, read the `pluginVersion` object from the response and persist it. This gives you update state immediately without waiting for a webhook.

```php
function carehq_send_health_ping() {
    $body = carehq_health_ping_body();
    $secret = get_option('carehq_signing_secret');
    $headers = $secret ? carehq_signed_headers($body) : array(
        'Content-Type' => 'application/json',
        'x-api-key' => get_option('carehq_api_key'),
    );

    $response = wp_remote_post(carehq_api_url('/api/health/ping'), array(
        'headers' => $headers,
        'body'    => $body,
        'timeout' => 15,
    ));

    if (is_wp_error($response)) {
        return;
    }

    $data = json_decode(wp_remote_retrieve_body($response), true);

    // Persist signing secret on first registration
    if (!empty($data['data']['signingSecret'])) {
        update_option('carehq_signing_secret', $data['data']['signingSecret'], false);
    }

    update_option('carehq_last_health_ping_time', gmdate('c'), false);

    // Persist version info for admin notice rendering
    if (!empty($data['data']['pluginVersion'])) {
        update_option('carehq_plugin_version_state', $data['data']['pluginVersion'], false);
    }
}
```

The stored option shape matches the response:

```php
// get_option('carehq_plugin_version_state') returns:
array(
    'current'        => '1.1.0',
    'latest'         => '1.2.0',
    'minimum'        => '1.1.0',
    'updateAvailable' => true,
    'updateRequired'  => false,
)
```

The webhook event `plugin.update_available` has the same fields under `$event['data']`, so you can merge both sources into the same option:

```php
// In carehq_handle_webhook, replace the basic update_option with:
case 'plugin.update_available':
    // Webhook payload has the same shape as pluginVersion in the ping response
    update_option('carehq_plugin_version_state', $event['data'], false);
    break;
```

### 9. Show Admin Notices for Outdated / Available Updates

Hook into `admin_notices` and read the stored option. Show a different notice for required vs optional updates.

```php
add_action('admin_notices', 'carehq_plugin_update_notice');

function carehq_plugin_update_notice() {
    // Only show to users who can manage plugins
    if (!current_user_can('manage_options')) {
        return;
    }

    $state = get_option('carehq_plugin_version_state');

    if (empty($state) || empty($state['updateAvailable'])) {
        return;
    }

    $current = esc_html($state['current'] ?? '');
    $latest  = esc_html($state['latest']  ?? '');
    $minimum = esc_html($state['minimum'] ?? '');
    $required = !empty($state['updateRequired']);

    if ($required) {
        // Red error notice — plugin is below the minimum required version
        printf(
            '<div class="notice notice-error"><p>'
            . '<strong>CareHQ Plugin — Update Required:</strong> '
            . 'You are running version <code>%s</code> but the minimum required version is <code>%s</code>. '
            . 'Some features may not work until you update. '
            . '<a href="%s">Update now &rarr;</a>'
            . '</p></div>',
            $current,
            $minimum,
            esc_url(admin_url('plugins.php'))
        );
    } else {
        // Yellow warning notice — update available but not required
        printf(
            '<div class="notice notice-warning is-dismissible"><p>'
            . '<strong>CareHQ Plugin — Update Available:</strong> '
            . 'Version <code>%s</code> is available (you have <code>%s</code>). '
            . '<a href="%s">Update now &rarr;</a>'
            . '</p></div>',
            $latest,
            $current,
            esc_url(admin_url('plugins.php'))
        );
    }
}
```

### 10. Add a Version Status Indicator to the Plugin Settings Page

Display current version, health status, and a styled inline notice on the plugin settings/connection page.

```php
function carehq_render_version_status() {
    $state = get_option('carehq_plugin_version_state');

    echo '<tr valign="top">';
    echo '<th scope="row">Plugin version</th>';
    echo '<td>';
    echo '<code>' . esc_html(CAREHQ_PLUGIN_VERSION) . '</code>';

    if (!empty($state)) {
        if (!empty($state['updateRequired'])) {
            echo ' <span style="color:#b32d2e;font-weight:600;">'
                . '&#x26A0; Update required (minimum: ' . esc_html($state['minimum']) . ')'
                . '</span>';
        } elseif (!empty($state['updateAvailable'])) {
            echo ' <span style="color:#996800;">'
                . '&#x2191; Update available: ' . esc_html($state['latest'])
                . '</span>';
        } else {
            echo ' <span style="color:#1d6a39;">&#x2713; Up to date</span>';
        }
    }

    echo '</td>';
    echo '</tr>';
}
```

Call `carehq_render_version_status()` inside your settings table.

### 11. Clear Stale Update State on Successful Manual Update

When the plugin updates itself (via WordPress plugin updater), clear the stored version state so it is refreshed on the next health ping.

```php
add_action('upgrader_process_complete', 'carehq_clear_version_state_after_update', 10, 2);

function carehq_clear_version_state_after_update($upgrader, $hook_extra) {
    if (
        isset($hook_extra['action'], $hook_extra['type'], $hook_extra['plugins']) &&
        $hook_extra['action'] === 'update' &&
        $hook_extra['type'] === 'plugin' &&
        in_array(plugin_basename(CAREHQ_PLUGIN_FILE), $hook_extra['plugins'], true)
    ) {
        delete_option('carehq_plugin_version_state');
        // Trigger an immediate ping so state is refreshed with the new version
        carehq_send_health_ping();
    }
}
```

### 12. Handle the `connection.test` Event

The CareHQ admin panel has a **Test** button for each connected site. Clicking it immediately queues and delivers a `connection.test` webhook to that site's webhook URL. This lets an admin confirm two-way connectivity without waiting for a real event.

The event body looks like any other webhook:

```json
{
	"id": "event_uuid",
	"type": "connection.test",
	"createdAt": "2026-05-07T12:00:00.000Z",
	"data": {
		"testedAt": "2026-05-07T12:00:00.000Z",
		"testedBy": "admin@example.com"
	}
}
```

Add a case for it in `carehq_handle_webhook()`:

```php
case 'connection.test':
    // Respond 200 OK — no state change needed, CareHQ just confirms delivery
    break;
```

That is all that is required on the plugin side. The event handler already verifies the HMAC signature, so a `200` response proves:

1. The webhook URL is reachable from CareHQ.
2. The shared signing secret is correctly stored on both sides.
3. The plugin can parse and respond to signed webhook payloads.

**Optional — show a transient admin notice on test receipt:**

```php
case 'connection.test':
    set_transient('carehq_connection_test_received', $event['data']['testedAt'], 60);
    break;
```

Then in your settings page:

```php
$tested = get_transient('carehq_connection_test_received');
if ($tested) {
    echo '<div class="notice notice-success is-dismissible"><p>'
        . '<strong>CareHQ:</strong> Connection test received at ' . esc_html($tested) . '.'
        . '</p></div>';
}
```

#### Troubleshooting `HTTP 403` on `connection.test`

If the admin panel reports `HTTP 403` after pressing **Test**, the request reached the WordPress REST API but was rejected. Common causes, in order of likelihood:

1. **Plugin route not registered yet** — the `register_rest_route('carehq/v1', '/webhook', …)` call has not run on this site. WordPress returns `403 rest_no_route` (not 404) when permalinks are flat. Save permalinks (`Settings → Permalinks → Save`) and confirm the route exists at `/wp-json/carehq/v1/webhook`.
2. **Signing secret mismatch** — the plugin's stored `carehq_signing_secret` does not match the one CareHQ holds. Re-register by re-saving the API key on the plugin settings page; the next `/api/health/ping` response will deliver a fresh secret.
3. **Security plugin blocking unauthenticated REST POSTs** — Wordfence, iThemes, etc. often block POST to `/wp-json/*` from unknown IPs. Allow-list the CareHQ origin or whitelist the `carehq/v1/webhook` route.
4. **`permission_callback` returns `WP_Error`** — verify `carehq_verify_webhook_request()` is actually returning `true` for valid signed requests. Temporarily log the failure reason from each `WP_Error` branch to identify which check fails.
5. **Reverse proxy stripping headers** — Cloudflare/Nginx in front of WordPress can drop custom `x-carehq-*` headers. Confirm the headers arrive at PHP via `error_log(print_r(getallheaders(), true))` inside the permission callback.

The CareHQ admin panel surfaces the WordPress response body in the failure toast, so the exact `code` field returned by `WP_Error` will help pinpoint which of the above applies.

#### Local development — Cloudflare 1003 "Direct IP Access Not Allowed"

CareHQ runs on Cloudflare Workers. Workers execute at the edge and **cannot reach `localhost` or any private/LAN IP address**. When a local WordPress install registers a webhook URL like `http://localhost:10023/wp-json/carehq/v1/webhook`, Cloudflare intercepts the outbound request before it ever leaves the edge and returns error **1003 Direct IP Access Not Allowed**.

`deliverWordPressWebhookEvent` in `webhooks.ts` detects localhost and private-range URLs before attempting `fetch()` and returns a descriptive error — so the failure toast will say _"Webhook URL is a private/local address — use a tunnel for local testing"_ instead of the cryptic 1003.

**Fix: expose your local site through a public tunnel.**

Using [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) (no account required):

```bash
cloudflared tunnel --url http://localhost:10023
```

Cloudflare prints a `*.trycloudflare.com` URL. Then:

1. Copy the tunnel URL (e.g. `https://abc-def-123.trycloudflare.com`).
2. In the WordPress plugin settings, update the site URL to the tunnel URL — or simply re-save your API key. The next health ping will re-register with the new public `webhookUrl`.
3. Press **Test** in the CareHQ admin panel — the webhook will now route through the public tunnel to your local machine.

> The tunnel URL changes each time you restart `cloudflared`. Re-save the API key after each restart to re-register.

## Next CareHQ Steps

1. Add an app dashboard that reads `wordpress_sites` and highlights `healthStatus = 'outdated'` or stale `lastPingAt` values.
2. Call `queueWordPressWebhookForUser()` from subscription downgrade, trial over-limit, and team admin flows.
3. Add a delivery worker or scheduled job that retries pending `wordpress_webhook_events` with backoff.
4. Once the plugin has rolled out signed pings, set `WORDPRESS_REQUIRE_SIGNATURES=true`.
5. Bump `WORDPRESS_MIN_PLUGIN_VERSION` and `WORDPRESS_LATEST_PLUGIN_VERSION` whenever a new plugin release ships; all connected sites will receive `plugin.update_available` on their next ping or via `POST /api/webhooks/broadcast-plugin-update`.
