# Private delivery

Source: https://solidhooks.com/docs/private-delivery

> Deliver webhooks to services inside your private network with SolidHooks Connect, using an outbound-only agent and no public URL.

A destination does not have to be reachable from the internet. SolidHooks Connect lets you deliver to a service inside your own network or cluster by running a small agent there. The agent only makes outbound connections, so you open no inbound firewall rule and expose no public hostname.

For a walkthrough of the idea, see [Private network delivery](https://solidhooks.com/use-cases/private-network-delivery).

## How it works

A **connector** represents one of your networks. It belongs to the organisation, not to a project, so one connector serves every project. Inside that network you run one or more **replicas** of the agent. Several replicas give you high availability.

When a delivery is due, SolidHooks dials the connector by name and the agent makes the HTTP request from inside your network. A **private destination** points at an in-network URL such as `http://payments.default.svc.cluster.local:8080/hooks`, and everything downstream (connections, retries, the attempts log, billing per delivery) behaves as it does for a public `http` destination.

## Set it up

### Create a connector

```bash
curl -s "$API/v1/connectors" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "name": "prod-cluster", "description": "EU production" }'
```

The response includes `enrollmentKey`, a bootstrap token starting `cbt_`. It is shown only here and when you regenerate it, so store it immediately. It does not expire.

Creating a connector mints a credential, so it needs an API key scoped `connector: read & write`. Anything less gets `403 INSUFFICIENT_API_KEY_PERMISSIONS`.

### Run the agent in your network

The dashboard shows the exact `docker run` or `helm install` command with your token in it. For a single replica with Docker:

```bash
docker run -e ENROLLMENT_KEY=cbt_... \
  -v solidhooks-connector:/ziti \
  interstellarlabs/solidhooks-connector
```

Mount a writable volume at `/ziti`: the agent keeps its identity and heartbeat token there, and the server stores only a hash of the heartbeat token, so a lost one cannot be fetched again. For high availability, use the `solidhooks-connector` Helm chart with `--set replicaCount=N`.

Each replica calls the public `POST /v1/connectors/bootstrap` with `{ "enrollmentKey": "cbt_..." }` to enrol itself. It gets back a one-time enrolment token and a reveal-once heartbeat token starting `crh_`.

### Create a private destination

```bash
curl -s "$API/v1/destinations" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "In-cluster payments service",
    "type": "private",
    "connectorId": "cntr_...",
    "url": "http://payments.default.svc.cluster.local:8080/hooks",
    "method": "POST"
  }'
```

`url` is still validated as a URL, but it does not need to resolve publicly. It is the address the agent resolves inside your network.

> **Provisioning takes a few seconds**
>
> A connector has a `provisionedAt` timestamp that is `null` until its infrastructure is ready. While it is `null`, creating a private destination against it returns `400` and deleting it returns `409 CONNECTOR_PROVISIONING`. Both are retryable. This is separate from `isDisabled`, which is a switch you control.

## Heartbeats and revocation

Each replica calls the public `POST /v1/connectors/replicas/heartbeat` with `{ "heartbeatToken": "crh_..." }`. The stock agent does this every 60 seconds, tunable with `HEARTBEAT_INTERVAL_SEC`. A healthy beat returns `{ "ok": true }`.

A replica is revoked, and its slot freed, only when both of these are true:

* it has not sent a heartbeat for 24 hours, and
* it does not appear connected.

Requiring both means a replica that is serving traffic but cannot reach the SolidHooks API is never revoked.

What a heartbeat returns for a bad token:

| Token                                                                     | Response                                                                                                                                                                                                       |
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Belongs to a revoked replica                                              | `403 CONNECTOR_REPLICA_REVOKED`, with a machine-readable `reason`: `connector_deleted`, `replica_revoked` or `identity_absent`. The stock agent logs the reason and stops its container. It does not re-enrol. |
| Unknown, or its revocation record has aged out (records are kept 30 days) | An opaque `401` with a generic message, so the endpoint cannot be used to probe which credentials are live.                                                                                                    |

If an agent stops after a `403`, remove that container and its volume, then start a fresh one with the same `ENROLLMENT_KEY`. It enrols as a new replica. Do not delete the connector to fix one replica: that revokes the healthy ones too, and is refused while any destination still uses it.

## Reading health

`GET /v1/connectors/{id}` reports a `status` aggregated across replicas, plus `connectedReplicas` and `totalReplicas`.

| Status         | Meaning                                                                                                                                                                                            |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`      | No replica has enrolled yet.                                                                                                                                                                       |
| `connected`    | At least one replica is up.                                                                                                                                                                        |
| `disconnected` | Replicas are enrolled, all reported, and none is reachable.                                                                                                                                        |
| `unknown`      | Nothing is connected and at least one replica went unreported. SolidHooks could not reach the overlay controller, which says nothing about your agent. Retry rather than treating it as an outage. |

`GET /v1/connectors/{id}/replicas` returns one row per enrolled replica with its own `status` (the same four values) and last-seen time. Use it to tell "3 of 3 up" from "1 of 3 up" when the aggregate says `connected`.

## Plan limits

| Entitlement               | What it caps                                                                | Error                                                         |
| ------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `maxConnectors`           | Connectors per organisation, checked at `POST /v1/connectors`               | `403 CONNECTOR_LIMIT_REACHED` with `data: { limit, current }` |
| `maxReplicasPerConnector` | Replicas enrolled per connector, checked at `POST /v1/connectors/bootstrap` | `403 REPLICA_LIMIT_REACHED` with `data: { limit, current }`   |

| Plan       | `maxConnectors`   | `maxReplicasPerConnector` |
| ---------- | ----------------- | ------------------------- |
| Hobby      | 0 (not available) | not set (not available)   |
| Launch     | 1                 | 2                         |
| Scale      | 5                 | 5                         |
| Enterprise | unlimited         | unlimited                 |

The limits apply only when you create. If your plan shrinks below what you already have, existing connectors and replicas keep working and only new ones are blocked.

## Other connector endpoints

| Endpoint                                         | Purpose                                                                                                                                                      |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /v1/connectors`                             | List connectors with aggregate status.                                                                                                                       |
| `GET /v1/connectors/options`                     | Lightweight `{ id, name }` list, for a destination form's picker.                                                                                            |
| `POST /v1/connectors/{id}/regenerate-enrollment` | Mint a new bootstrap token and invalidate the previous one. Enrolled replicas keep running. Needs `connector: read & write`.                                 |
| `POST /v1/connectors/{id}/toggle-disabled`       | A disabled connector rejects new private destinations. Enrolled replicas keep beating, because disabling is not revocation. Needs `connector: read & write`. |
| `DELETE /v1/connectors/{id}`                     | Delete a connector. Blocked with `400` while any destination references it. Needs `connector: read & write`.                                                 |

> **Private delivery is optional on a deployment**
>
> If the deployment has not been configured for private delivery, connector mutations return `400 BAD_REQUEST`. Treat it as a capability that may not be available.

## Next steps

* [Destinations](https://solidhooks.com/docs/destinations) — the `private` destination type
* [Retries](https://solidhooks.com/docs/retries) — retries work the same for private destinations
* [Plans](https://solidhooks.com/docs/plans) — connector entitlements per plan
* [Private network delivery](https://solidhooks.com/use-cases/private-network-delivery) — the use-case guide