# Provider Webhook Secrets

Some services you depend on also **call you back** with signed webhooks — PolicySign today;
Relay and lob-send have the same shape. To verify those webhooks your receiver needs the
provider's signing secret. Declare where you receive them and the builder provisions the secret
on every deploy. No copying `whsec_…` values into `tawa config`.

## The Short Version

```yaml
# Consumer — catalog-info.yaml (catalog 0.4.0+)
spec:
  dependencies:
    - service: policysign
      webhooks:
        path: /api/webhooks/policysign   # where you receive PolicySign's webhooks
```

On deploy the pod gets `POLICYSIGN_WEBHOOK_SECRET`, the secret of your PolicySign webhook
endpoint for that URL. Verify with it as usual (Standard Webhooks).

No `webhooks.path` → nothing happens. A plain `- service: policysign` behaves exactly as before.

## What the builder does

After dependency resolution, for each dependency with `webhooks.path`:

1. **Reads the provider's contract** from Koko (`metadata.webhookProvider`, published by the
   provider's own deploy). No contract → a warning, nothing provisioned.
2. **Computes your receiver URL** for this environment: your **verified custom domain** for the
   environment if you have one, else the platform hostname (`{service}.tawa.pro`,
   `{service}.sandbox.tawa.pro`, …) — plus `webhooks.path`.
3. **Calls the provider** at `https://{provider platform host}{provisionEndpoint}` with the
   builder's Bio-ID `client_credentials` token and
   `{ orgSlug: spec.owner, url, consumerService, environment }`.
4. The provider **finds or creates** the endpoint in your org and returns its secret.
5. The builder **injects the secret as a managed secret** under the contract's `secretEnv`
   (it lands in `{service}-managed-secrets`, never on the helm command line).

| Environment | Receiver URL (no custom domain) | Endpoint |
|---|---|---|
| prod | `https://my-app.tawa.pro/api/webhooks/policysign` | its own |
| sandbox | `https://my-app.sandbox.tawa.pro/api/webhooks/policysign` | its own |
| uat | `https://my-app.uat.tawa.pro/api/webhooks/policysign` | its own |

Each environment has a different URL, therefore a different endpoint and a different secret.

## Adopt, never duplicate, never rotate

The provider must be safe to call on every deploy, forever:

- **Adopts an existing endpoint.** PolicySign matches an active endpoint in your org on the
  **exact URL**, else the oldest one on the **same origin** — the same rule it uses to pick the
  secret that signs a delivery. An endpoint you registered by hand in the PolicySign console is
  adopted, and its current secret returned.
- **Never rotates.** Rolling a secret is a deliberate action in the provider's console. The next
  consumer deploy picks the new value up — so **redeploy the receiver inside the roll's grace
  window**.
- **Never creates duplicates** on redeploy, and never reads another org's endpoints.

## A value you set by hand wins

If `secretEnv` is already set on your service — `tawa config set --secret` **or** plain
`tawa config set` — the builder **does not overwrite it**:

| Your value vs the provider's | Build log |
|---|---|
| same | `POLICYSIGN_WEBHOOK_SECRET: your tawa config value is kept; it matches …` |
| different | `[warn] … DIFFERS from the secret policysign signs … with` + how to switch |

The comparison happens in memory; neither value is ever logged. To move to the automatic secret:

```bash
tawa config unset POLICYSIGN_WEBHOOK_SECRET
tawa deploy --prod
```

## Fail open

Provisioning never blocks a deploy. Each of these is a `[warn]` line and no injection (a manually
set value is still used):

- the provider publishes no contract, or an invalid one
- the builder cannot mint its Bio-ID service token
- the provider is unreachable, times out (10s), or returns an error
- `secretEnv` is a reserved platform variable or was already provisioned this deploy

Warnings link here.

## Where the secret does NOT go

Never to the build log, Koko, the build record, or the deploy snapshot — the manifest lists the
key with `isSecret: true` and no value.

## Being a provider

Publish the contract in **your** catalog:

```yaml
spec:
  webhookProvider:
    provisionEndpoint: /api/platform/webhook-endpoints   # absolute path on your public host
    secretEnv: POLICYSIGN_WEBHOOK_SECRET                 # must end _SECRET, _KEY, _TOKEN or _PASSWORD
```

Deploy once; the builder syncs it to Koko (`metadata.webhookProvider`). Remove it and redeploy to
withdraw it. An invalid contract is dropped with a build warning. `secretEnv` may not be a
reserved platform variable (`BIO_CLIENT_SECRET`, `MONGODB_URI`, …).

Your endpoint must:

| Requirement | Why |
|---|---|
| `POST`, JSON body `{ orgSlug, url, consumerService, environment }` | the builder's call |
| respond `{ success: true, data: { endpointId, secret, match } }` | `match` is shown in the build log |
| authenticate the **builder**: verify the Bio-ID token by JWKS, require `token_type: client_credentials` **and** `client_id` on an allowlist (`builder-prod`) | an org check is not enough — every `insureco` service's token carries `orgSlug: insureco` |
| be idempotent: adopt, never rotate, never duplicate | it runs on every consumer deploy |
| scope everything to `orgSlug` | never return another org's secret |
| validate `url` against your SSRF guard | the URL becomes a delivery target |
| audit with a hint only, and send `Cache-Control: no-store` | it returns a credential |

The builder reaches you on your **public platform hostname** — it runs off-cluster, so there is no
Janus in front and `/internal/*` conventions give no protection. Authenticate in the handler.

PolicySign's implementation: `app/api/platform/webhook-endpoints/route.ts`,
`lib/services/webhookProvisioning.ts`, `lib/platformAuth.ts`.

## Common mistakes

- **Setting `webhooks.path` without the provider having deployed its contract** — warns "publishes
  no webhook provider contract". Deploy the provider first.
- **Leaving an old manual value in place after a roll** — the manual value wins, so the
  `DIFFERS` warning appears and verification fails. Unset it.
- **A path that isn't your receiver** — the provider registers that exact URL as a delivery target.
- **Confusing the consumer's `POLICYSIGN_WEBHOOK_SECRET` with PolicySign's own config of the same
  name** — PolicySign's is the legacy platform-wide `X-PolicySign-Signature` key and is unrelated.

## Related

The same idea for a secret that lives in the Locker rather than at a provider:
`tawa config set --secret KEY --from-locker lk://insureco/<vault>/<item>`, where the builder fetches the value itself.
See [config](config.md).
