> ## Documentation Index
> Fetch the complete documentation index at: https://voyant.travel/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a private connector

> Host an org-owned supplier adapter behind the Connect external adapter protocol.

A private connector is an org-owned connector provider backed by an adapter that the customer hosts. Voyant stores the provider manifest, grants, registrations, encrypted signing secret, and normalized connection state; the supplier adapter itself runs outside Voyant infrastructure.

This model is for suppliers that are specific to one organization, such as an obscure bed bank, DMC, cruise consolidator, or airline GSA. The contractor building the integration owns the adapter service and exposes the protocol below.

For protocol demos without building inventory, point `metadata.externalAdapter.baseUrl` at the public [Voyant sandbox connector](/docs/connect/sandbox) Worker and use HMAC as below — the sandbox implements the same operation paths.

## Register the provider

### CLI (recommended)

```bash theme={null}
# Validate locally (optional --probe hits well-known manifest)
voyant connector validate ./connector.json --probe

# Register against your org (requires connect:providers:write)
voyant connector register ./connector.json --org <slug> --json
```

Store the one-time `signingSecret` from the register response in the adapter's secret manager.

### HTTP API

Register the provider with `POST /connect/v1/connector-providers`. The request body is the connector manifest. Private connectors must include `metadata.externalAdapter`; `hostedWorker` and `externalAdapter` are mutually exclusive.

Registration validates the manifest, enforces public HTTPS URL policy on `metadata.externalAdapter.baseUrl`, and **probes the adapter endpoint** (well-known manifest preferred; otherwise `/health` must respond as reachable, including 401/403 when signature is required).

After creation, `PUT /connect/v1/connector-providers/{key}/manifest` re-registers the manifest for the same provider — use it to update capabilities, credential forms, or the adapter target as the integration evolves. Only the owning organization can touch an owned key, and platform-managed metadata (`managedByOrganizationId`, `website`, `applicationForm`, `iconObjectKey`) is ignored if a manifest tries to set it. Adapters may also self-describe by serving their manifest at `/.well-known/voyant-connect/manifest`, which is how hosted connectors publish theirs; for private connectors it is optional but useful for `voyant`-side validation tooling.

```json theme={null}
{
  "key": "local-supplier",
  "displayName": "Local Supplier",
  "description": "Private integration for Local Supplier inventory",
  "authModel": "bring_your_own_credentials",
  "accessModel": "credential_scoped",
  "categoryCoverage": ["hotel"],
  "supplyModel": "dynamic",
  "capabilities": ["catalog_read", "availability_read", "booking_create"],
  "supportsMarkets": true,
  "defaultMarkets": ["RO"],
  "supportedDirections": ["inbound"],
  "credentialForm": {
    "type": "fields",
    "fields": [
      {
        "key": "apiKey",
        "label": "API key",
        "type": "password",
        "required": true
      },
      {
        "key": "baseUrl",
        "label": "Supplier base URL",
        "type": "url",
        "required": true
      }
    ]
  },
  "metadata": {
    "externalAdapter": {
      "type": "external_adapter",
      "baseUrl": "https://adapter.example.com/connect",
      "protocolVersion": "2026-05-28"
    }
  }
}
```

The response includes `data.provider` and `data.signingSecret`. The signing secret is revealed once. Store it in the adapter service secret manager; later reads do not return it.

## URL constraints

`metadata.externalAdapter.baseUrl` must be a public HTTPS URL. The control plane rejects URLs with credentials, query strings, or fragments, and rejects hosts that are local, internal, loopback, private, link-local, carrier-grade NAT, documentation-only, benchmark, multicast, reserved, or otherwise non-public after DNS resolution.

Voyant repeats this policy at connect time in its connector egress relay. The relay resolves the complete DNS answer set, rejects the request if any answer is non-public, selects one accepted address, and pins that address into the TLS connection. It keeps the original hostname for certificate validation and SNI. DNS failure and mixed public/private answers fail closed. The relay does not follow redirects.

The configuration-time check remains defense in depth. It is not the check that authorizes the connection, so changing DNS after registration cannot redirect a connector request to a private service.

Valid:

```text theme={null}
https://adapter.example.com/connect
```

Invalid:

```text theme={null}
http://adapter.example.com
https://user:pass@adapter.example.com
https://adapter.example.com/connect?tenant=acme
https://localhost/connect
```

## Request contract

Connect dispatches each operation with `POST` to:

```text theme={null}
${baseUrl}<operationPath>
```

If `baseUrl` is `https://adapter.example.com/connect`, `searchStays` is sent to `https://adapter.example.com/connect/stays/search`.

The request body is JSON:

```ts theme={null}
type ConnectorWorkerRequest<TInput = unknown> = {
  protocolVersion: "2026-05-28";
  operation: string;
  context: {
    connectionId: string;
    operatorId?: string | null;
    connection: Record<string, unknown>;
    credentials: Record<string, string>;
    marketContext?: {
      market: string | null;
      language: string | null;
      currency: string | null;
    };
    now?: string;
    requestId?: string;
    correlationId?: string;
    idempotencyKey?: string;
    environment?: "sandbox" | "production";
  };
  input: TInput;
};
```

Treat `operatorId`, `marketContext`, and `now` as absent-able: catalog-wide operations dispatch without an operator, and only per-call operations carry the tracing fields. Code the adapter against this exact shape rather than assuming every field is present.

The adapter must return one of these JSON envelopes:

```ts theme={null}
type ConnectorWorkerResponse<TData = unknown> =
  | { ok: true; data: TData }
  | {
      ok: false;
      error: {
        code?: string;
        message: string;
        details?: unknown;
      };
    };
```

## Headers and signing

Every external adapter request includes:

| Header                         | Value                                                                      |
| ------------------------------ | -------------------------------------------------------------------------- |
| `Content-Type`                 | `application/json`                                                         |
| `X-Voyant-Connect-Protocol`    | The manifest's `metadata.externalAdapter.protocolVersion`, or `2026-05-28` |
| `X-Voyant-Connect-Operation`   | The operation name, such as `searchStays`                                  |
| `X-Voyant-Connect-Connection`  | The Connect connection id                                                  |
| `X-Voyant-Connect-Provider`    | The provider key                                                           |
| `X-Voyant-Connector-Timestamp` | Unix timestamp in seconds                                                  |
| `X-Voyant-Connector-Signature` | `sha256=<hex hmac>`                                                        |

The egress relay authentication header is platform-internal and is never sent to the adapter. The adapter-facing method, URL path, body, headers, timeout envelope, and response handling are unchanged by relaying.

The signature is HMAC-SHA256 over this canonical string:

```text theme={null}
${timestamp}POST${url.pathname}${bodyText}
```

`url.pathname` includes any path prefix from `baseUrl` plus the operation path. It does not include scheme, host, query, or fragment. `bodyText` is the exact raw request body bytes decoded as text.

```ts theme={null}
async function verifyVoyantConnectorRequest(
  request: Request,
  signingSecret: string,
): Promise<{ ok: true; bodyText: string } | { ok: false; status: number }> {
  const timestamp = request.headers.get("X-Voyant-Connector-Timestamp");
  const signature = request.headers.get("X-Voyant-Connector-Signature");
  if (!timestamp || !signature?.startsWith("sha256=")) {
    return { ok: false, status: 401 };
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 300) {
    return { ok: false, status: 401 };
  }

  const bodyText = await request.text();
  const path = new URL(request.url).pathname;
  if (request.method !== "POST") {
    return { ok: false, status: 405 };
  }

  const canonical = `${timestamp}POST${path}${bodyText}`;
  const expected = await hmacSha256Hex(signingSecret, canonical);

  if (!timingSafeEqualHex(signature.slice("sha256=".length), expected)) {
    return { ok: false, status: 401 };
  }

  return { ok: true, bodyText };
}

async function hmacSha256Hex(secret: string, message: string): Promise<string> {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const signature = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(message),
  );
  return Array.from(new Uint8Array(signature))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

function timingSafeEqualHex(a: string, b: string): boolean {
  if (!/^[0-9a-f]+$/i.test(a) || !/^[0-9a-f]+$/i.test(b)) return false;
  if (a.length !== b.length) return false;

  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}
```

## Operation paths

The platform dispatches only the operations it needs for a connection and the provider capabilities in use.

| Operation              | Path                     |
| ---------------------- | ------------------------ |
| `validateCredentials`  | `/validate-credentials`  |
| `syncCatalog`          | `/sync/catalog`          |
| `syncPricing`          | `/sync/pricing`          |
| `searchStays`          | `/stays/search`          |
| `quoteStay`            | `/stays/quote`           |
| `lockStay`             | `/stays/lock`            |
| `confirmStay`          | `/stays/confirm`         |
| `cancelStay`           | `/stays/cancel`          |
| `getStayBooking`       | `/stays/bookings/get`    |
| `searchCruises`        | `/cruises/search`        |
| `quoteCruise`          | `/cruises/quote`         |
| `lockCruise`           | `/cruises/lock`          |
| `confirmCruise`        | `/cruises/confirm`       |
| `cancelCruise`         | `/cruises/cancel`        |
| `getCruiseBooking`     | `/cruises/bookings/get`  |
| `searchPackages`       | `/packages/search`       |
| `quotePackage`         | `/packages/quote`        |
| `lockPackage`          | `/packages/lock`         |
| `confirmPackage`       | `/packages/confirm`      |
| `cancelPackage`        | `/packages/cancel`       |
| `getPackageBooking`    | `/packages/bookings/get` |
| `searchFlights`        | `/flights/search`        |
| `priceFlightOffer`     | `/flights/price`         |
| `bookFlight`           | `/flights/book`          |
| `getFlightOrder`       | `/flights/orders/get`    |
| `cancelFlightOrder`    | `/flights/orders/cancel` |
| `ticketFlightOrder`    | `/flights/orders/ticket` |
| `listFlightOrders`     | `/flights/orders/list`   |
| `getFlightSeatMap`     | `/flights/seatmap`       |
| `selectFlightSeats`    | `/flights/seats/select`  |
| `getFlightAncillaries` | `/flights/ancillaries`   |
| `checkInFlight`        | `/flights/checkin`       |
| `modifyFlightOrder`    | `/flights/orders/modify` |
| `refundFlightOrder`    | `/flights/orders/refund` |
| `voidFlightOrder`      | `/flights/orders/void`   |
| `addFlightSsr`         | `/flights/ssr`           |
| `health`               | `/health`                |

## Rotate the signing secret

Use `POST /connect/v1/connector-providers/:key/signing-secret/rotate` to rotate the per-provider external adapter signing secret. The response includes the new `data.signingSecret` once, alongside the provider row.

Deploy the adapter with the new secret before using it exclusively. The current dispatch code signs with the single encrypted secret stored on the provider row.

## Grants and sharing

Creating a private provider automatically creates an active owner self-grant. The owning organization can share the provider with another organization using:

```text theme={null}
POST /connect/v1/connector-providers/:key/grants
GET /connect/v1/connector-providers/:key/grants
PATCH /connect/v1/connector-providers/:key/grants/:granteeOrgId
DELETE /connect/v1/connector-providers/:key/grants/:granteeOrgId
```

Grant request and response payloads use camelCase. A grant can be active, suspended, or revoked, and may include `expiresAt`.

## Lifecycle

Disable a private provider with:

```text theme={null}
PATCH /connect/v1/connector-providers/:key
```

```json theme={null}
{ "status": "disabled" }
```

Disable is a kill switch. The provider remains visible to the owner, but access checks deny usage while disabled.

Delete a private provider with:

```text theme={null}
DELETE /connect/v1/connector-providers/:key
```

If active connections reference the provider, the API returns `409` with the active connection count. To tear down active references, revoke grants, remove provider registrations and stored provider secret material, and hard-delete the provider row, call:

```text theme={null}
DELETE /connect/v1/connector-providers/:key?force=true
```

Hard deletion frees the provider key for re-registration.

## Reference implementation

The TUI connector is the reference implementation for the hosted connector protocol. It is registered as an internal provider with `metadata.hostedWorker.type = "hosted_worker_target"` and protocol version `2026-05-28`, and the Connect API dispatches TUI stays and packages operations through the same operation envelope and response envelope described here.
