# Webhook Forwarding — `WebhookForwarder`

## What it does

A provider gives ONE webhook URL (Meta Cloud API, Retell, most others). When a second
site must see the same events — the dev site while Meta points at production, the old
domain mid-migration, a developer's tunnel — the receiving box relays a
**byte-identical copy** of each verified webhook to admin-configured targets, and
**ledgers every delivery** (success / failure / attempts / duration) where the admin
can see it.

Consumers: **WhatsApp Cloud channels** (the **Forwarding tab** on
`/manage/messages/channels/{uuid}`, Cloud API channels only) and — since 2026-09-07 — the
**Zoom integration** (the **Forwarding tab** on `/manage/integrations/zoom`, targets hanging
off the single `ZoomServerCredential` row and serving BOTH Zoom endpoints, `/webhooks/zoom`
and `/webhooks/zoom-rtms`). Targets are polymorphic (`forwardable`), so any webhook-owning
model can attach them the same way.

## How it works

- `Src\Common\Webhooks\WebhookForwarder::relay($forwardable, $request, $signatureHeaders, $event)`
  is called from the webhook controller AFTER signature verification: one query for
  active targets, one PENDING ledger row + one queued `App\Jobs\Webhooks\ForwardWebhook`
  per target. Fail-soft — a relay problem never affects the 200.
- The job POSTs the RAW body with the original signature headers untouched (re-encoding
  JSON breaks the downstream HMAC) plus `X-Peta-Forwarded: 1`; `relay()` refuses any
  request that ARRIVED with that header, so two sites pointing at each other dead-end
  instead of ping-ponging. Retries 3× (backoff 30/90s, 10s timeout); the ledger row is
  settled SUCCESS/FAILED and the target's `last_ok_at`/`last_failed_at` stamped.
- The GET verify handshake is never relayed — each site does its own when pointed at.
- Target URLs are https-only and SSRF-guarded (no private/loopback/reserved hosts) in
  `ForwardTargets\StoreRequest`, and the scheme is re-checked at send time.
- Ledger rows carry size + outcome, NEVER the payload (customers' messages); pruned
  after 30 days (`Prunable` + the 02:50 `model:prune` schedule).
- **Forwarding is not dual-running**: if both sites process the same inbound with AI
  auto-reply live on the same number, the customer hears from two servers. Keep ONE
  environment's AI live per number. Peta downstreams de-duplicate naturally
  (`provider_message_id` unique per channel).
- **An owner with MORE THAN ONE endpoint passes `$endpoint`** (a path such as
  `/webhooks/zoom-rtms`): only the targets whose URL path matches get that event. A target
  is the other site's SAME endpoint, so a Zoom recording event must never be posted to the
  other site's RTMS door — the two controllers verify with different secrets and one would
  401. `WebhookForwarder::servesEndpoint($url, $endpoint)` is the shared comparison (path
  only, trailing slash ignored); Zoom's `ForwardTargets\StoreRequest` uses it to refuse a
  URL that serves neither endpoint. Owners with one endpoint (WhatsApp) pass nothing.
- **A forwarded copy is a spectator, never a second actor.** The Zoom RTMS controller reads
  the `X-Peta-Forwarded` header and records the meeting WITHOUT launching a media sidecar —
  a second listener on one stream would double the Zoom credits, the STT bill and every
  transcript line. The Live table on the receiving site shows the meeting as "Not started
  here". The same principle as the AI-reply rule above, enforced in code because the cost is
  automatic rather than a setting someone could forget.

## Reference usage

```php
use Src\Common\Webhooks\WebhookForwarder;

// In a webhook controller, after the signature check passes and the owning
// model is resolved — queued, fail-soft, never in the response path:
WebhookForwarder::relay($channel, $request, ['X-Hub-Signature-256'], 'cloud');
```

Adding targets to a new model needs no schema change — call
`WebhookForwardRepository::create($model, ['webhook_forward_target' => ['url' => …, 'note' => …]])`,
send the page `ForwardTargetsPresenter::targets($model)` + `::logs($model)` as props, and mount
the shared card:

```vue
<ForwardTargetsCard base-url="/manage/…/forward-targets" :targets :logs :can-manage placeholder="https://…">
    The owner-specific intro — what the provider's one URL is, what a target must point at.
</ForwardTargetsCard>
```

`resources/js/Components/Webhooks/ForwardTargetsCard.vue` owns add / pause / **Send test** /
remove, the health line and the 20-row ledger; the WhatsApp `ForwardingTab.vue` and the Zoom
Forwarding tab are both thin wrappers around it. The owner's controller supplies the four
routes (`store` / `update` / `destroy` / `test`) — copy `ZoomSettingsController`'s or
`ChannelsController`'s; **Send test must sign the synthetic event the way the provider does**
(Zoom: `v0=HMAC(v0:{ts}:{body})` with the secret of the app owning that endpoint), so the
other site proves it holds the same secret, not merely that the URL answers.

The Retell relay (`RETELL_WEBHOOK_FORWARD_URL` + `ForwardRetellWebhook`) predates this
service and still runs env-configured; migrate it onto a target row later — but never
remove the env var before the replacement target exists, or the AE call bridge on the
dev box goes deaf.

## Related files

- `src/Common/Webhooks/WebhookForwarder.php` — the relay entry point (+ `servesEndpoint()`)
- `src/Common/Webhooks/WebhookForwardTarget.php` / `WebhookForwardLog.php` — models
- `src/Common/Webhooks/Repositories/WebhookForwardRepository.php` — all writes
- `src/Common/Webhooks/Support/ForwardTargetsPresenter.php` — the page-shaped targets + ledger every card renders
- `app/Jobs/Webhooks/ForwardWebhook.php` — delivery + ledger settle
- `app/Http/Requests/Manage/Whatsapp/ForwardTargets/` — URL/SSRF validation (the one place for it; Zoom's requests extend these)
- `app/Http/Requests/Manage/Integrations/Zoom/ForwardTargets/` — + the "must be a Zoom endpoint" rule
- `app/Http/Controllers/Manage/Whatsapp/ChannelsController.php` — the channel tab's CRUD + Send test
- `app/Http/Controllers/Manage/Integrations/ZoomSettingsController.php` — the Zoom tab's CRUD + per-endpoint signed Send test
- `app/Http/Controllers/Webhooks/WhatsAppWebhookController.php@handleCloud` — first consumer
- `app/Http/Controllers/Webhooks/ZoomWebhookController.php` / `ZoomRtmsWebhookController.php` — the Zoom consumers (per-endpoint relay; the RTMS one never launches on a copy)
- `resources/js/Components/Webhooks/ForwardTargetsCard.vue` — the shared UI
- `resources/js/Pages/Manage/Messages/Channels/Partials/Tabs/ForwardingTab.vue` — the WhatsApp wrapper
- `resources/js/Pages/Manage/Integrations/Zoom/Index.vue` — the Zoom Forwarding tab
