# Webhooks and SFTP

Available to configured partners. xPortfolios publishes targets; your team controls imports, account sizing and execution. Delivery infrastructure is verified using a labelled controlled receiver and isolated SFTP accounts. This does not establish real-partner onboarding or platform-import compatibility. Alpaca and the embeddable component remain planned.

## Webhooks

Provide your HTTPS endpoint on port 443 through assisted onboarding. URLs cannot contain credentials, a query or fragment. Private/metadata destinations, redirects and invalid certificates are refused. An optional agreed fixed public IP preserves hostname/certificate validation. Signing secrets are 32 random bytes transferred privately; never put them or your API key in a URL.

An event contains `event_id`, `kind` (`model.published` or `model.corrected`), `organization` (immutable UUID), `portfolio_id`, `slug`, `rebalance_id`, `supersedes_rebalance_id`, `data_release_id`, `decided_at`, `effective_at`, `published_at`, `publication_time_basis`, `payload_sha256`, `payload_hash_schema` and `links` with authenticated `targets` and four `files` URLs. Corrections have their own saved decision ID and reference their predecessor. Publication evidence is the first retained activation receipt, labelled `first_retained_activation`, including history predating Webhooks. Original decision/effective dates remain unchanged.

A newly configured webhook starts from the current decision: the latest saved decision for each currently entitled portfolio, including its latest correction, is queued for POST. A portfolio granted or regranted later establishes the same baseline atomically at first discovery: only its latest saved decision is queued, and older unsent events become `HistoricalBaseline`. Accepted/failed outcomes and attempt history are preserved. Older events are recorded as skipped with the distinct reason `HistoricalBaseline` and remain available through authenticated replay. They are not sent automatically. Later decisions and corrections are queued normally; daily valuation updates do not resend decisions.

### Verify exact bytes

1. Read the unmodified body bytes and `X-XP-Timestamp` (Unix seconds).
2. Reject a timestamp more than 300 seconds from your synchronized clock.
3. Compute HMAC-SHA256 with the shared secret over **ASCII timestamp + `.` + exact body bytes**.
4. Compare `sha256=` plus the lowercase hex digest with `X-XP-Signature` using constant-time comparison.
5. Persist `event_id` with your processing transaction and deduplicate it before applying a model. Parse only after signature verification.

```bash
python webhook-verifier.py --secret-file signing.secret --body-file request.bin --headers-file headers.json
# headers.json contains X-XP-Timestamp and X-XP-Signature as received.
# Download webhook-verifier.py next to sample-consumer.py for:
python sample-consumer.py --verify-webhook --secret-file signing.secret --body-file request.bin --headers-file headers.json
```

`payload_hash_schema=xp-model-content-v1`: fetch `links.targets` with your Bearer header. Hash only its `model` object as canonical ASCII JSON: sorted keys, comma/colon separators, `ensure_ascii=true`, no NaN, no trailing newline. It includes portfolio ID/slug/name/currency, saved decision metadata/targets/changes and prior basket inputs for exits. Daily valuations, historical price annotations, event envelope, request signature, transport state and generated file bytes are excluded. `webhook-verifier.py` includes `verify_model(event, response)`.

### Retry and recover

Requests have a ten-second total bound. HTTP 2xx records acceptance by your endpoint only. Network failures, lost responses, 408, 425, 429 and 5xx retry **1, 5, 30, 120 minutes** after the previous failed attempt, then fail after five attempts per delivery cycle. Other responses, including redirects, fail without retry. The event ID and body remain stable; each POST has a fresh signed timestamp. Restart recovery follows the same schedule.

This is **at-least-once delivery**, never exactly once. A lost reply can cause duplicate receipt even if you processed the first request. Endpoint acceptance does not establish downstream processing or execution.

Recover through `GET /api/v1/partner/webhooks/replay?limit=50&after=<event_id>`. Use your organization Bearer key, follow `next_cursor` while `has_more` is true, and keep deduplicating saved event IDs. Limits are 1–100 per page. On 409, omit `after` and restart from available history. Unknown/cross-organization cursors are unavailable. Current organization/configuration status and entitlements apply, including after revocation. GET replay retrieves records; it does not schedule another POST or acknowledge execution.

```bash
# Set XP_API_BASE=https://www.xportfolios.com and XP_PARTNER_KEY privately.
python sample-consumer.py --replay-webhooks webhook-state.json
```

Coordinate signing-key rotation through operations: install the new key at your receiver, switch the sender reference, then retire the old receiver key after the agreed in-flight window. Pending attempts use the current key; event IDs/body remain unchanged. Disablement stops future sends and replay. Re-enabling through configuration re-queues events skipped as `AccessDisabled` or `AccessChanged`, subject to current organization and portfolio access. These resume with the same event ID/body, the current endpoint/signing key and a fresh cycle of at most five attempts; previous attempt evidence is preserved. `HistoricalBaseline` skips and accepted/failed deliveries remain unchanged and available through replay. Reconfiguration also recovers `AccessChanged` skips after rotation; rotation alone does not re-queue them.

## SFTP: your team pulls files

1. Generate a dedicated Ed25519 client key locally. Supply only its public key during onboarding.
2. Receive your assigned username and the approved SFTP origin host (`63.187.102.64`, port 22). Verify its ED25519 host-key fingerprint through the agreed authenticated onboarding channel before saving it in your known-hosts file. The web hostname may point at a proxy; use the assigned SFTP origin.
3. Connect with your dedicated key and `StrictHostKeyChecking=yes`.
4. Download `/exports/index.json`, then each listed decision folder's `manifest.json` and files. Verify the manifest SHA-256 against the index, and each file's exact length/SHA-256 against the manifest.
5. Save downloads to temporary local filenames; activate them locally only after every check passes.

```bash
sftp -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=partner-known-hosts -i partner-ed25519 \
  ASSIGNED_USERNAME@63.187.102.64
# get /exports/index.json
```

Each organization has its own chroot and read-only internal-sftp account. Shell commands, forwarding, uploads, renames, deletes, symlinks and other filesystem mutations are denied. Paths contain portfolio UUID, saved decision UUID and manifest hash, so same-day corrections do not ambiguously overwrite earlier decisions. Complete packs appear atomically. Repeated jobs and daily valuations reuse the same files; the manifest identifies changed layouts separately.

Files reuse the API serializers: generic CSV/XLSX, IBKR Rebalance CSV and optional organization-specific custom CSV. Review exact instruments, zero-weight exits and correction IDs; IBKR TWS Rebalance import is paper-verified (22 September 2026); other platform imports remain unverified.

For permission changes, key replacement or disablement, operations first removes access and terminates existing sessions, including open file handles. Files already downloaded cannot be recalled. Restart with a newly agreed key/permissions and reread the index. If a download is interrupted or a hash fails, retry that complete pack after reconnecting; never import a partial set. File availability is distinct from download evidence, acknowledgement and execution.

## Portal receipts and joining email delivery

Viewing or refreshing the portal overview records receipt of every displayed decision for the organization. **Received** means delivery through the portal, API or a file download; it does not prove a download or review. **Acknowledged** is the team's explicit acknowledgement. **Executed** is a partner-reported action, not independently verified execution. None of these actions places trades.

Enrolment and newly granted portfolio access establish a baseline at the current decisions, with no email backlog or enrolment invitation. At the next due update, an unconfirmed email recipient receives one generic confirmation request without portfolio details or files. Its link expires after 24 hours; viewing the link does not confirm. Explicit confirmation establishes another current-decision baseline, and only future decisions are emailed. New partners obtain current targets immediately through their enabled portal Overview and Decision files, or configured API/SFTP access. Portal access and notification consent are separate.
