# Agency Pulse Backup — External Server Contract

> **Status:** Planning / Pre-implementation. No code written yet.
> **Last updated:** 2026-03-30
> **Companion document:** `BACKUP_WP_PLUGIN_PLAN.md` — defines the WP plugin implementation.

This document defines the complete API contract between the WP plugin and the external server. It describes what the server must accept from WP, what the server must send to WP, and the authentication and delivery mechanisms for both directions. It does not prescribe how the server stores, manages, or presents backup data internally — that is the server's concern.

---

## Authentication

The two directions use different authentication mechanisms, both already implemented by the Pro Connector (`agency-pulse-pro`) and in production use by the portal mission scheduler.

### Outbound: WP → Server (JWT Bearer)

WP attaches the Pro Connector JWT as a Bearer token in the `Authorization` header on every backup push request. The server validates the JWT to confirm the request originates from a licensed WP instance. The JWT is managed and refreshed automatically by the Pro Connector — the backup plugin calls `AgencyPulseWP_Token_Manager::get_valid_token()` and attaches the result. No additional auth logic is required.

### Inbound: Server → WP (Ed25519 Signed Requests)

The server signs inbound operation requests using **Ed25519 asymmetric cryptography**. WP verifies the signature using the Ed25519 public key stored in the Pro Connector's connector state (`agencypulsewp_connector_state['access_token']`). This public key is established during Pro Connector setup and never changes unless the site re-connects.

**The backup plugin does not implement signature verification itself.** It delegates to the Pro Connector's existing `AgencyPulseWP_Verifier` class, the same class used by the portal mission scheduler's relay endpoint.

**Signed request structure:**

```
POST {wp_inbound_endpoint}
Content-Type: application/json
X-Timestamp: {unix_timestamp}
X-Signature: {base64_encoded_ed25519_signature}
```

**What is signed:** `{timestamp}.{raw_request_body}` — the timestamp header value concatenated with a `.` and the raw JSON body.

**Verification steps (handled by `AgencyPulseWP_Verifier`):**
1. Extract `X-Timestamp` and `X-Signature` headers — reject with 400 if missing.
2. Fetch public key from `agencypulsewp_connector_state['access_token']` (32 bytes, base64-decoded).
3. Base64-decode the signature (must be 64 bytes) — reject with 403 if invalid length.
4. Reconstruct message as `{X-Timestamp}.{raw_body}`.
5. Verify Ed25519 signature via `sodium_crypto_sign_verify_detached()` (PHP 7.2+ native) with phpseclib3 fallback — reject with 403 if verification fails.
6. Confirm timestamp is within ±300 seconds of server time — reject with 403 if outside window (replay protection).

The server must sign every inbound request with this scheme. WP will reject any inbound operation that fails verification.

---

## Direction 1 — WP Pushes to Server

WP sends backup payloads to the server whenever a tracked entity changes. The server receives and stores these payloads. WP does not need to know how the server stores them.

### Endpoint: Create or Update a Backup Record

```
POST /api/plugin/backup/{entity_type}
Authorization: Bearer {jwt}
Content-Type: application/json
```

**Path parameter:**

| Value | Entity |
|---|---|
| `invoice` | Invoice post and all associated data |
| `client` | Portal client record |
| `product` | Product catalog entry |
| `order` | Shopping cart order record |
| `global_config` | Versioned snapshot of global configuration options |

**Request body — Invoice:**

```json
{
  "entity_type": "invoice",
  "plugins_active": ["agency-pulse-portal", "agency-pulse-multi-currency"],
  "core": {
    "post_id": 123,
    "post_title": "Invoice for Acme Co",
    "post_status": "publish",
    "post_date": "2026-03-01 10:00:00",
    "post_modified": "2026-03-15 14:22:00",
    "meta": { "_invoice_number": "INV-0042", "_invoice_status": "paid" },
    "line_items": [
      { "description": "Web Design", "quantity": 1, "unit_price": "2000.00", "total": "2000.00" }
    ],
    "pdf_versions": [
      { "generated_at": 1743000000, "hash": "sha256..." }
    ]
  },
  "portal": {
    "client_email": "client@example.com",
    "client_snapshot": { "_client_name": "Acme Co", "_client_address": "123 Main St" },
    "invoice_access_token": "abc123def456"
  },
  "multi_currency": {
    "invoice_currency": "EUR",
    "base_currency": "USD",
    "exchange_rate_locked": "1.0823",
    "currency_config_snapshot": { "EUR": { "symbol": "€", "decimals": 2, "position": "before" } }
  },
  "enhancements": {
    "company_name": "My Agency",
    "company_index": 0,
    "company_snapshot": { "name": "My Agency", "address": "456 Agency Ave" },
    "external_file_map": { "456": "https://s3.example.com/files/invoice-123/brief.pdf" },
    "hide_files_until_paid": false
  },
  "products": {
    "order_id": 789,
    "order_meta": { "_order_status": "paid", "_transaction_id": "txn_abc123" },
    "product_snapshots": [
      { "name": "Logo Package", "price": "500.00", "type": "digital", "tax_ids": [] }
    ]
  },
  "secure_sign": {
    "signed_pdf_path": "s3://bucket/signed/invoice-123.pdf",
    "signed_pdf_hash": "sha256...",
    "signed_at": 1743100000,
    "signed_ip": "1.2.3.4"
  },
  "backup_meta": {
    "remote_id": "server-assigned-record-id",
    "triggered_by": "status_change",
    "backed_up_at": 1743200000,
    "payload_hash": "sha256..."
  }
}
```

`backup_meta.remote_id` is `null` on the first push for a new entity. Once the server returns a `remote_id` in its success response, WP stores it and includes it in all subsequent pushes for that entity. The server must use this to distinguish creates from updates without inspecting payload content.

Addon sections (`portal`, `multi_currency`, `enhancements`, `products`, `secure_sign`) are **omitted entirely** when the corresponding plugin is not active on the sending WP instance.

**Request body — Client:**

```json
{
  "entity_type": "client",
  "client_email": "client@example.com",
  "modified_at": "2026-03-15 14:22:00",
  "meta": {
    "_client_name": "Acme Co",
    "_client_phone": "555-1234",
    "_client_address": "123 Main St",
    "_client_city": "Springfield",
    "_client_state": "IL",
    "_client_zip": "62701",
    "_client_country": "US",
    "_client_tax_id": "VAT123"
  },
  "templates": [
    { "uuid": "abc-uuid", "template_data": { "..." : "..." } }
  ],
  "backup_meta": {
    "remote_id": null,
    "triggered_by": "client_saved",
    "backed_up_at": 1743200000,
    "payload_hash": "sha256..."
  }
}
```

**Request body — Product:**

```json
{
  "entity_type": "product",
  "post_id": 456,
  "name": "Logo Package",
  "meta": {
    "_product_price": "500.00",
    "_product_type": "digital",
    "_product_tax_ids": [],
    "_product_company_index": 0
  },
  "backup_meta": {
    "remote_id": null,
    "triggered_by": "product_saved",
    "backed_up_at": 1743200000,
    "payload_hash": "sha256..."
  }
}
```

**Request body — Global Config:**

```json
{
  "entity_type": "global_config",
  "configs": {
    "apmc_currencies": { "USD": { "symbol": "$", "decimals": 2 }, "EUR": { "..." : "..." } },
    "apmc_default_currency": "USD",
    "agency_pulse_companies": [ { "name": "My Agency", "address": "..." } ],
    "apie_invoice_numbering_config": { "prefix": "INV", "padding": 4 }
  },
  "backup_meta": {
    "triggered_by": "option_updated",
    "backed_up_at": 1743200000
  }
}
```

**Success response (all entity types):**

```json
{
  "success": true,
  "remote_id": "server-assigned-record-id",
  "revision": 3
}
```

- `remote_id` — the server's stable identifier for this entity's backup record. WP stores this as `_apab_backup_remote_id` and uses it on subsequent updates to the same record.
- `revision` — the server's revision number for this backup snapshot.

**Error response:**

```json
{
  "success": false,
  "error": "unauthenticated",
  "message": "JWT validation failed"
}
```

Expected HTTP status codes: `200 OK` on success, `401` for auth failure, `422` for payload validation failure, `500` for server error.

---

### Endpoint: Mark Entity as Archived / Inactive

Sent when a local entity is trashed or deleted and should be flagged on the external server as no longer present in WP.

```
PATCH /api/plugin/backup/{entity_type}/{remote_id}
Authorization: Bearer {jwt}
Content-Type: application/json
```

```json
{
  "archived": true,
  "archived_at": 1743300000
}
```

**Success response:**

```json
{
  "success": true
}
```

---

### Endpoint: Confirm Operation Result (Callback)

When an inbound operation payload includes a `callback_url`, WP POSTs the result to that URL after completing the operation. This is an outbound WP → Server call, so it uses the Pro Connector JWT Bearer token for authentication.

```
POST {callback_url}
Content-Type: application/json
Authorization: Bearer {jwt}
```

```json
{
  "success": true,
  "operation": "restore",
  "entity_type": "invoice",
  "remote_id": "server-record-id",
  "local_id": 456,
  "conflict": false,
  "conflict_reason": null,
  "errors": []
}
```

The server uses this callback to track restore completeness and update the archived flag on its records.

---

## Direction 2 — Server Sends Operations to WP

The server initiates operations on WP by sending signed payloads. WP validates, executes, and returns a result synchronously (Pattern A) or via callback (Pattern B).

### WP Inbound Endpoint

The WP plugin exposes a WP REST API endpoint registered via `rest_api_init`. The full URL follows the standard WP REST pattern and is returned to the server during plugin registration (see Registration below).

```
POST https://{site}/wp-json/apab/v1/operation
Content-Type: application/json
X-Timestamp: {unix_timestamp}
X-Signature: {base64_encoded_ed25519_signature}
```

`permission_callback` is `__return_true` — WordPress access control is not used. All security is enforced inside the handler via `AgencyPulseWP_Verifier`, identical to the Pro Connector's `agency-pulse-pro/v1/mission-callback` pattern.

No `Authorization` header is used — authentication is via the `X-Timestamp` and `X-Signature` headers (see Authentication section above).

### Operation Payload Structure

Every operation payload sent from the server to WP is delivered as a signed POST request (see Authentication above). The Ed25519 signature in `X-Signature` is the authentication mechanism — there is no JWT or other token in the body.

```json
{
  "operation": "restore",
  "entity_type": "invoice",
  "payload": { "...full backup record for this entity..." },
  "remote_id": "server-record-id",
  "expiry": 1743999999,
  "callback_url": "https://server.example.com/api/plugin/backup/result"
}
```

| Field | Required | Description |
|---|---|---|
| `operation` | Yes | See operations table below |
| `entity_type` | Yes | `invoice \| client \| product \| global_config` |
| `payload` | Restore operations only | Full backup record as previously received from WP |
| `remote_id` | Yes | Server's record ID; returned in WP's response |
| `expiry` | Yes | Unix timestamp; WP rejects payloads past this time |
| `callback_url` | No | URL for WP to POST the result when complete |

### Supported Operations

| `operation` | `entity_type` | What WP does |
|---|---|---|
| `restore` | `invoice` | Recreates the invoice post with all meta and line items; resolves local addon entity IDs |
| `archive` | `invoice` | Moves post to trash (default) or hard deletes if `archive_mode: "delete"` is set in payload; sets `_apab_archived` meta |
| `restore_client` | `client` | Creates or updates `app_portal_client` post with deduplication by email |
| `restore_product` | `product` | Creates or updates `agency_pulse_product` post with deduplication by name + company |
| `restore_global_config` | `global_config` | Overwrites specified global options |
| `restore_batch` | any | Paginated batch of any entity type; WP processes items and returns per-item results |

> **Orders:** There is no `restore_order` operation. Order records are included in the invoice backup payload under the `products` section and restored inline as part of the `restore` invoice operation. The `/api/plugin/backup/order` push endpoint exists to keep the server's order records current, but the server never sends a standalone order restore command to WP.

### `restore_batch` Payload

```json
{
  "operation": "restore_batch",
  "entity_type": "invoice",
  "items": [
    { "remote_id": "rec-1", "payload": { "..." : "..." } },
    { "remote_id": "rec-2", "payload": { "..." : "..." } }
  ],
  "page": 1,
  "total_pages": 12,
  "expiry": 1743999999,
  "callback_url": "https://server.example.com/api/plugin/backup/batch-result"
}
```

### WP Response to Operations

**Synchronous response (Pattern A — server-to-server):**

```json
{
  "success": true,
  "operation": "restore",
  "entity_type": "invoice",
  "remote_id": "server-record-id",
  "local_id": 456,
  "conflict": false,
  "conflict_reason": null,
  "errors": []
}
```

**`restore_batch` response:**

```json
{
  "success": true,
  "operation": "restore_batch",
  "entity_type": "invoice",
  "page": 1,
  "results": [
    { "remote_id": "rec-1", "local_id": 456, "conflict": false, "errors": [] },
    { "remote_id": "rec-2", "local_id": null, "conflict": true, "conflict_reason": "invoice_number_exists", "errors": [] }
  ]
}
```

**On validation failure (invalid signature, expired payload, unknown operation):**

```json
{
  "success": false,
  "errors": ["payload_expired"]
}
```

HTTP status: `200` for all operation outcomes (success, conflict, deduplication skip). `400` for missing signature headers or malformed payload. `403` for invalid signature or expired timestamp.

---

## Delivery Patterns

### Pattern A — Server-to-Server (Primary)

The server POSTs the operation payload directly to the WP inbound endpoint. WP responds synchronously. Used in production environments where the WP site is publicly reachable.

- **Pro**: fast, no user action required, suitable for bulk operations
- **Con**: requires WP site to be publicly reachable from the server

### Pattern B — Signed URL (Fallback)

The server generates a URL that encodes the operation payload and embeds the Ed25519 signature as query parameters. The admin clicks the link in the management console; their browser delivers the request to WP. WP runs the same `AgencyPulseWP_Verifier` validation as Pattern A and executes the operation.

- **Pro**: works in local/dev environments where WP is not publicly reachable from the server
- **Con**: requires admin browser action; not suitable for automated bulk operations

The server selects the appropriate pattern. WP handles both patterns on the same endpoint with the same validation logic. No WP configuration is required to switch patterns.

The signed URL must include the timestamp and signature so WP's verifier can authenticate the request even when it arrives via browser. The `expiry` field in the payload provides an additional time bound. The server should set short expiry windows (e.g. 5 minutes) for signed URLs to limit the exposure window of a shareable link.

---

## Plugin Registration

THIS IS NOT NECESSARY AS THE DOMAIN IS ALREADY KNOWN ON AUTHENTICATION - IF IT CHANGES WE NEED REAUTHENTICATION - THERE IS A DOMAIN HASH AS WELL FOR VALIDATION
---------------- DO NOT COMPLETE THIS ---------------
On activation, and on each `plugins_loaded` cycle when the Pro Connector is available, the WP plugin registers itself with the external server. This allows the server to know the WP inbound endpoint URL and current plugin version.

```
POST /api/plugin/backup/register
Authorization: Bearer {jwt}
Content-Type: application/json
```

```json
{
  "wp_inbound_endpoint": "https://client-site.com/wp-json/apab/v1/operation",
  "plugin_version": "1.0.0",
  "active_addons": ["agency-pulse-portal", "agency-pulse-multi-currency"],
  "site_url": "https://client-site.com"
}
```

**Success response:**

```json
{
  "success": true,
  "server_version": "1.0.0"
}
```

The server uses `wp_inbound_endpoint` to know where to send operations for this site. If the endpoint URL changes (e.g. site migration), the next registration call updates it.

---------------- DO NOT COMPLETE THIS ---------------
---

## Required Server Endpoints — Summary

| Method | Path | Direction | Purpose |
|---|---|---|---|
| `POST` | `/api/plugin/backup/register` | WP → Server | Plugin registration and endpoint discovery |
| `POST` | `/api/plugin/backup/invoice` | WP → Server | Create or update invoice backup record |
| `POST` | `/api/plugin/backup/client` | WP → Server | Create or update client backup record |
| `POST` | `/api/plugin/backup/product` | WP → Server | Create or update product backup record |
| `POST` | `/api/plugin/backup/order` | WP → Server | Create or update order backup record |
| `POST` | `/api/plugin/backup/global_config` | WP → Server | Create or update versioned global config snapshot |
| `PATCH` | `/api/plugin/backup/{type}/{remote_id}` | WP → Server | Mark entity as archived / inactive |
| `POST` | `{wp_inbound_endpoint}` | Server → WP | Send operations to WP (restore, archive, batch) |
| `POST` | `{callback_url}` | WP → Server | Deliver operation result after completion |

---

## Decisions Reflected in This Contract

- **Local PDF binaries are not included in backup payloads.** The `pdf_versions` array records metadata (timestamp, hash, S3 path where applicable) but never a binary payload. Local unsigned PDFs can be regenerated from the HTML template snapshot on restore. Signed PDFs are already on the server's S3 storage and are referenced by path only.

- **Invoice restore conflict behaviour.** When an invoice with the same invoice number already exists locally, WP acts according to its `apab_restore_conflict_mode` setting (`skip` / `overwrite` / `duplicate`, default `skip`). In all cases WP returns `conflict: true` with `conflict_reason: "invoice_number_exists"` and the existing `local_id`. The server must surface this outcome in the management console for admin review regardless of which mode was applied.

- **Archive mode.** The `archive` operation payload may include an optional `archive_mode` field (`"trash"` or `"delete"`). If omitted, WP applies its site-level default (`apab_default_archive_mode` option, defaulting to `"trash"`). The server can force a hard delete by sending `"archive_mode": "delete"` explicitly.

---

## Constraints and Guarantees WP Provides

The server can rely on the following guarantees from the WP plugin:

1. **Payload hash** — every backup request includes `backup_meta.payload_hash` (SHA-256 of the payload body). The server can use this to detect duplicate pushes and skip storage if the hash matches the most recent revision.

2. **`remote_id` on updates** — after the first successful backup, WP stores the `remote_id` returned by the server and includes it in all subsequent pushes for the same entity. The server can use this to route creates vs updates without examining payload content.

3. **`plugins_active` completeness** — addon sections are omitted when the plugin is not active, never present with empty/null values. The server can treat presence of a section as confirmation that the plugin was active at backup time.

4. **Atomic invoice payload** — an invoice backup payload is always a complete snapshot, not a diff. The server can store each received payload as a self-contained revision without needing to merge with previous state.

5. **Expiry enforcement** — WP rejects all inbound operation payloads past their `expiry` timestamp and returns `payload_expired`. The server must handle this response and may re-issue the operation with a fresh expiry.

6. **Idempotent restore** — WP's restore handlers are idempotent for the same `remote_id`. Sending the same restore operation twice returns the same `local_id` without creating a duplicate.
