# Callback consolidation — request

**Goal:** the plugin must expose exactly one webhook endpoint for translation completion, with one signature scheme, so the backend can implement a single callback contract.

**Repo root:** `/home/user/Projects/Press.zone/wordpress/wp-content` — paths below are relative to `plugins/international-press-zone/`.

## Context — the live defect

Two classes register the identical REST route `international-press-zone/v1` `/callback`:

- `includes/API/TranslateJobsController.php:178-186` — `handleCallback`, guarded by `verifyWebhookSignature`
- `includes/Translation/JobReceiver.php:90-94` — `handle_callback`, guarded by `verify_webhook_permission`

WordPress silently keeps whichever registers last, so one handler is dead code that appears functional. The two guards disagree on every detail — header name, whether a timestamp is required, what the HMAC covers, and which option holds the secret — so no single backend payload can satisfy both. This blocks the backend's callback implementation entirely.

The two implementations have complementary strengths, and the consolidation must keep both sets:

| | `TranslateJobsController` | `JobReceiver` |
|---|---|---|
| HMAC covers | `timestamp . '.' . body` — replay-bound | body only — replayable inside the window |
| Timestamp | required, ±300 s | absent |
| Idempotency | `deliveryId` checked against `webhook_event_id` | absent |
| Job identity | `wp_<digits>` cross-checked against stored `api_job_id` | looser |
| Terminal-state guard | present | absent |
| Per-IP rate limit | absent | 100/hour, applied before HMAC |

## Contract — the single surviving endpoint

Route `international-press-zone/v1` `/callback`, `POST`, registered exactly once, by `TranslateJobsController`.

Signature header `X-Webhook-Signature`, with `X-TPZ-Signature` still accepted as a fallback. Timestamp header `X-TPZ-Timestamp`, Unix **milliseconds**, rejected when absent, non-numeric, or more than 300 000 ms from now in either direction. Signed value is `hash_hmac('sha256', $timestamp . '.' . $raw_body, $secret)` compared with `hash_equals`. Secret from `Settings::get_existing_callback_secret()`.

Required body fields and their semantics are exactly those `TranslateJobsController::handleCallback` already enforces: `event`, `deliveryId`, `clientJobId` matching `/^wp_(\d+)$/`, and an `apiJobId` that must equal the stored `api_job_id`. Accepted events stay `translation.completed`, `translation.failed`, `bulk_translation.completed`.

## Files

- Modify `includes/API/TranslateJobsController.php` — add the per-IP rate limit to `verifyWebhookSignature`, applied **before** any signature computation, using the same shape `JobReceiver::verify_webhook_permission` uses today: `REMOTE_ADDR` only (never `X-Forwarded-For`), transient key `presszone_international_webhook_rate_<md5(ip)>`, 100 attempts per `HOUR_IN_SECONDS`, empty IP rejected.
- Modify `includes/Translation/JobReceiver.php` — remove the duplicate `/callback` registration. Preserve every other responsibility this class has; if removing the registration leaves the class with no remaining purpose, remove the class and its instantiation in `includes/Core/Plugin.php` instead of leaving an empty shell.
- Modify `includes/Core/Plugin.php` — only if the previous point requires it.
- Create `tests/php/CallbackConsolidationTest.php` — see Acceptance.

## Behavior

Exactly one handler must be reachable at the route after the change. Rejection order is: missing/invalid IP, then rate limit, then signature and timestamp, then body validation — a request that fails the rate limit must never reach signature computation. Replaying a previously delivered `deliveryId` still returns the existing success response rather than reprocessing. Every existing passing behavior of `handleCallback` is preserved unchanged.

Any option, setting, or admin string that exists solely to feed the removed `JobReceiver` registration becomes orphaned by this change and must be removed with it. Do not remove pre-existing dead code unrelated to this change.

## Out of scope

The backend. The `system-translate/bulk-callback` route, which is a separate endpoint with a per-job secret and stays as it is. Any change to job processing, translation logic, or admin UI. Any change to the signature scheme beyond what is pinned above.

## Acceptance

- `tests/php/CallbackConsolidationTest.php` asserts that `international-press-zone/v1/callback` resolves to exactly one registered handler, that a request with a valid signature but a stale timestamp is rejected, and that a request over the rate limit is rejected without the secret being consulted.
- Run the gate remotely, never locally: `/ipz-e2e` — report its real output.
- `grep -rn "register_rest_route" includes/ | grep "callback'" ` shows the route registered once.
