# Runtime HTTP API.

Every worker you define becomes one Cloudflare Worker, and every Worker serves the same fixed route map on its own origin: dispatch routes for each capability kind, two HubSpot OAuth legs, and a small set of platform endpoints under /_hsx. HubSpot calls the signed routes, the CLI and your own HTTP clients call the open ones, and the control plane calls the grant-protected ones. This page is that route map, endpoint by endpoint: method, auth model, request and response shapes, and which SDK primitive answers on each.

## The 30-second answer

A deployed HS-X Worker serves capability dispatch at `POST /capabilities/<id>/invoke`, HubSpot workflow actions at `POST /workflow-actions/<id>/invoke` (plus a `/batched/invoke` variant), card backends at `POST /_hsx/cards/<id>`, webhook triggers at `POST /webhooks/hubspot/<id>`, sync runs at `POST /sync/<id>/run`, the install OAuth pair at `GET /oauth-start` and `GET /oauth-callback`, and platform endpoints under `/_hsx`: health, manifest, flag evaluation, flag authoring, and the signed tenant-data read.

The surface splits into three auth models. **Webhook triggers and workflow-action invokes are default-secure: whenever a HubSpot client secret is configured, a request without a valid HubSpot v3 signature is rejected with a 401.** The tenant-data and flag-authoring endpoints take short-lived HMAC grants bound to the Worker's own identity. Everything else is an open JSON endpoint on the Worker's origin.

**If you only read one thing**

Every dispatch route is JSON-in, JSON-out, and POST-only; the four GET routes are health, manifest, and the two OAuth legs. Anything else returns `404 {"ok": false, "error": "not_found"}`.

## Every route, one table

| Route | Method | Auth | Answers for |
| --- | --- | --- | --- |
| `/_hsx/health` | GET | none | Liveness and identity; the promote waiter's ping target |
| `/_hsx/manifest` | GET | none | This Worker's capability inventory |
| `/capabilities/<id>/invoke` | POST | none | Any declared capability; the universal dispatch door |
| `/workflow-actions/<id>/invoke` | POST | HubSpot v3 signature when a secret is configured | `worker.action()` / `worker.tool()` |
| `/workflow-actions/<id>/batched/invoke` | POST | HubSpot v3 signature when a secret is configured | Batched actions (needs a completion queue) |
| `/_hsx/cards/<id>` | POST | none | `worker.cardBackend()` |
| `/webhooks/hubspot/<id>` | POST | HubSpot v3 signature when a secret is configured | `worker.trigger()` |
| `/sync/<id>/run` | POST | none | `worker.sync()` |
| `/oauth-start` | GET | none | Self-initiated install: redirect to HubSpot authorize |
| `/oauth-callback` | GET | OAuth code exchange; signed `state` verified when present | Install completion and token storage |
| `/_hsx/flags/evaluate` | POST | none | Flag reads for pure-UI cards |
| `/_hsx/flags/define`, `/_hsx/flags/list` | POST | Signed grant with `flags:write` | Flag authoring (`hs-x flags`) |
| `/_hsx/sync/read` | POST | Signed grant with read scopes | Control-plane reads of tenant data |

Three structural facts shape everything below. Each `defineWorker(...)` in your project deploys as its own Cloudflare Worker, so each origin serves this map for its own capabilities only; a capability id that lives on another worker is a 404 here. Routes that depend on optional wiring answer `409` with a `*_not_configured` error rather than pretending: `sync_not_configured`, `batched_actions_not_configured`, `flags_not_configured`, `flags_authoring_not_configured`, `sync_read_not_configured`. And every dispatch records a checkpoint on a linked deploy, which is what feeds `hs-x logs` and the dashboard; [the observability reference](/docs/observability) covers that pipeline.

## Health and manifest

`GET /_hsx/health` answers with the Worker's name and the runtime version baked into it:

```sh
$ curl -s https://<worker>.workers.dev/_hsx/health
{"ok":true,"worker":"deals","runtimeVersion":"0.2.5"}
```

Health carries more weight than a liveness probe. Attestation heartbeats ride live requests: any incoming request, this one included, triggers an attestation send through `ctx.waitUntil`, throttled to one per 15 minutes per isolate. That is why `hs-x deploy --promote-when-healthy` pings `/_hsx/health` while it polls drift, and why a Worker that receives no traffic never attests; [the deploy lifecycle reference](/docs/deploy-lifecycle) covers the gate this feeds.

`GET /_hsx/manifest` returns the single worker's manifest: its name plus one entry per capability with `kind`, `id`, and the kind-specific fields (`label`, `objectType`, typed `input`/`output` field maps for tools; `eventType` and `dedup` for triggers; `schedule`, `manageSchema`, and the source for syncs; `objectTypes` for card backends). Note the shape difference from the local dev server, whose manifest wraps all of a project's workers in a `workers` array; here you are talking to exactly one.

## Capability invokes and workflow actions

All three invoke routes accept the HS-X dispatch payload, every key optional:

```json
{
  "input": { "threshold": 25000 },
  "enrolledObject": { "id": "d1", "objectType": "deals", "properties": { "amount": "50000" } },
  "install": { "id": "hubspot-app:123:portal:46993937", "portalId": "46993937", "state": "active", "config": {} }
}
```

A body that is not valid JSON, or that fails the dispatch-payload schema, is a `400 invalid_payload`. A request without a `content-type` containing `application/json` is treated as an empty payload. The `install` block is what selects the portal whose stored OAuth token `ctx.hubspot` resolves.

`POST /capabilities/<id>/invoke` dispatches any capability the worker declares, whatever its kind. This is the route `hs-x dev invoke <id> --remote` posts to, and the production counterpart of the dev server's `/_hsx/invoke/<id>`. It is default-secure under the same policy as the workflow-action and webhook routes: once a signature secret is resolvable (an explicit runtime option or the `HSX_HUBSPOT_CLIENT_SECRET` binding), the route requires a valid HubSpot v3 signature and answers `401` otherwise. The `install` identity in the payload is caller-asserted, so the signature is what keeps a deployed Worker's stored install credentials from being exercised by arbitrary callers; `hs-x dev invoke` signs automatically with the secret from `.dev.vars`, and pre-OAuth bootstrap deploys, which have no secret yet, accept unsigned calls. Success is `{"ok": true, "capabilityId": "<id>", "result": <handler result or null>}`. A rate-limit backpressure result comes back the same way, as `ok: true` with the structured result; [the rate-limits reference](/docs/rate-limits) explains those shapes. An unhandled handler exception is recorded as an error checkpoint and rethrown, which the Workers platform surfaces as a 500.

**A tool invoke that omits a required input field is rejected with `400 missing_required_input` before the handler runs.** Fields in a tool's `input` map that declare `required: true` (or HubSpot's legacy `isRequired`) and carry no `default` must be present and non-null; the response names the gaps:

```json
{
  "ok": false,
  "error": "missing_required_input",
  "capabilityId": "tag-high-value-deals",
  "missing": ["threshold"],
  "message": "Missing required input field(s): threshold."
}
```

This check runs on all three invoke routes and applies only to tools; other capability kinds have no input field declarations to enforce.

`POST /workflow-actions/<id>/invoke` is the URL `hs-x deploy` writes into the workflow action's HubSpot metadata, so this is the route HubSpot's workflow engine actually executes. It answers only for `tool` capabilities (`404 unknown_workflow_action` otherwise) and accepts HubSpot's native execution body as well as the dispatch payload: input is read from `inputFields`, `fields`, or `input`; the record from `object` or `enrolledObject`; the portal id from `portalId`, `portalID`, or the `origin` block. The response speaks HubSpot's execution contract, mapped from the handler's `ActionResult`: `ok` answers `200` with `outputFields` built from the result's `output` (or `data`) record; `fail-continue` and `block` answer `200` with `hs_execution_state` set to `FAIL_CONTINUE` or `BLOCK` (the handler's message rides along as the `hsx_message` output field for executionRules); `fail-stop` answers `400`, which HubSpot records as a failure without retrying; and `retry-later` answers `429` with a `Retry-After` header when the handler gave `retryAfterSeconds`, else `503` — both of which HubSpot requeues with exponential backoff for up to three days. [The workflow actions guide](/docs/guides/workflow-actions) covers the handler side.

`POST /workflow-actions/<id>/batched/invoke` is the deferred-completion variant: the payload must include `input.callbackId` (`400 missing_callback_id` otherwise), the handler result is enqueued for completion rather than returned, and the response is a `202` with `{"ok": true, "capabilityId", "queued": true, "completionId", "shardId", "depth"}`. A full queue shard also answers `202`, with a `retry-later` backpressure result and `queue.accepted: false`. The route requires a completion queue in the runtime options; the entrypoint `hs-x deploy` generates does not wire one today, so on a standard deploy this route answers `409 batched_actions_not_configured`.

Both workflow-action routes share the trigger routes' signature policy, which is the next section.

## Webhook triggers and the v3 signature

`POST /webhooks/hubspot/<id>` dispatches a `trigger` capability (`404 unknown_trigger` for any other kind). The verification step runs before anything else touches the body.

The secret is resolved in a fixed order: an explicit `webhook.hubSpotAppSecret` in the runtime options wins, otherwise the `HSX_HUBSPOT_CLIENT_SECRET` binding that `hs-x deploy` pushes once the app's OAuth client secret is known. The same resolution backs the workflow-action routes. When no secret exists, which is the state of a pre-OAuth bootstrap deploy, unsigned requests are accepted because there is nothing to verify against; `hs-x deploy` warns loudly about that state.

When a secret is configured, the request must carry HubSpot's v3 signature headers, and four distinct 401s tell you exactly what failed:

| `error` | What failed |
| --- | --- |
| `missing_hubspot_signature` | No `x-hubspot-signature-v3` or no `x-hubspot-request-timestamp` header |
| `invalid_hubspot_signature_timestamp` | The timestamp is not a millisecond epoch value |
| `stale_hubspot_signature` | The timestamp is outside the skew window (5 minutes by default) |
| `invalid_hubspot_signature` | The HMAC-SHA256 over method + URL + body + timestamp did not match |

The comparison is timing-safe, and the URL is decoded the way HubSpot encodes it before signing, so signatures verify byte-for-byte against what HubSpot sent.

Past the signature, the runtime derives a delivery id (from the `x-hsx-delivery-id`, `x-hubspot-request-id`, or `x-hubspot-correlation-id` header, falling back to the event id, a composite of subscription, object, and occurrence time, or a body hash) and drops repeats within a 24-hour window: `{"ok": true, "capabilityId", "deduped": true, "deliveryId"}`. On a deploy with the tenant database bound — any linked deploy with a data plane — the dedup store is durable: delivery ids are claimed atomically in tenant D1, so `dedup: 'strict'` holds across isolates, not just within one. Without the binding no dedup store is wired at all, and every signed delivery dispatches — design handlers on unlinked deploys to tolerate redelivery. When a trigger queue is configured, bursts are enqueued instead of executed inline, answering `202` with a `jobId`; the generated entrypoint wires no queue today, so a standard deploy executes triggers inline and answers `{"ok": true, "capabilityId", "deliveryId", "result"}`.

One half of this contract is still yours: HS-X owns the endpoint, but the webhook subscription in your app's configuration is declared by you, pointed at `/webhooks/hubspot/<trigger-id>`. [The triggers guide](/docs/guides/triggers) walks through the pairing.

## Card backends and flag evaluation

`POST /_hsx/cards/<id>` dispatches a `card-backend` capability with the standard dispatch payload; it is the dispatch path the generated `refs` module exports for card frontends to call. The envelope matches the generic invoke: `{"ok": true, "capabilityId", "result"}`, with backpressure surfaced as a successful structured result. There is no signature model on this route.

`POST /_hsx/flags/evaluate` exists for cards that have no backend at all: the card iframe resolves feature flags against its own tenant Worker, reading the KV flag snapshots and running the same pure evaluator the runtime uses for `ctx.flags`. Neither the control plane nor HubSpot is in this read path.

```json
// request
{ "flagKeys": ["new-pricing-table"], "targetingContext": { "portalId": "46993937" } }

// response
{ "ok": true, "flags": { "new-pricing-table": { "value": true, "variation": "on", "reason": "targeting_match" } } }
```

The request carries no per-key defaults; a missing or unreadable flag resolves to `value: null` with a fail-safe reason, and the caller overlays its own defaults. The Worker's baked-in identity (account, project, environment, app id) seeds the targeting context and the caller's `targetingContext` overlays it. The endpoint is unauthenticated and the targeting identity is caller-asserted; a cross-install session guard is deferred until a card-backend session model exists to verify against. On a deploy without the flags KV binding (any unlinked deploy) the route answers `409 flags_not_configured`. [The feature flags guide](/docs/guides/feature-flags) covers authoring and rollout rules.

## Sync runs, and who calls them

`POST /sync/<id>/run` executes one run of a `sync` capability: the runtime loads the stored cursor for the `(capabilityId, portalId)` pair, hands the handler a cursor context, persists the new cursor only if the handler set one, and answers with both facts:

```json
{ "ok": true, "capabilityId": "import-orders", "cursorUpdated": true, "cursor": "2026-06-11T03:12:00Z", "result": null }
```

The portal id comes from the dispatch payload's `install` block and defaults to `local-portal`, so cursors are per-portal by construction.

**There is no sync scheduler: nothing in HS-X calls `/sync/<id>/run` on a timer.** A sync's `schedule` is declared in the manifest, but nothing consumes it yet: no scheduled handler is generated, no cron is attached to the Worker, and no platform component fires runs. The caller is you: CI, a cron you own, or the CLI during development. The route also requires a cursor store in the runtime options, which the generated entrypoint does not wire today, so on a standard deploy it answers `409 sync_not_configured`; hand-wired runtimes supply their own store. [The syncs guide](/docs/guides/syncs) covers cursor semantics end to end.

## The install OAuth pair

These are the two routes HubSpot portals interact with when installing your app, and the only ones that answer with redirects and HTML pages rather than JSON.

`GET /oauth-start` mints a signed, five-minute `state`, then 302-redirects to HubSpot's authorize page with the Worker's own `/oauth-callback` as the redirect URI and the scopes from the `HSX_HUBSPOT_SCOPES` binding. It requires `HSX_HUBSPOT_CLIENT_ID` and configured state storage; a missing binding renders an "Install failed" page naming exactly which one.

`GET /oauth-callback` finishes the install. Two entry paths are legitimate, and the state rules differ on purpose. A self-initiated install arrives with the state `/oauth-start` minted, and a present-but-invalid state is rejected with a 400. A HubSpot-initiated install, from the app's Distribution tab or a marketplace listing, carries no state parameter at all, and proceeds on the authorization code alone; rejecting those would block every marketplace install, and the confidential code exchange is the proof of consent.

The exchange itself runs against HubSpot's date-based OAuth API (`/oauth/2026-03/token`), reads the portal id and scopes from the token response, and falls back to introspection only when they are absent. The runtime then writes two records to the tenant's own KV: the install owner record and the encrypted token blob that `ctx.hubspot` later resolves. On success the user is 302-redirected to the portal's connected-apps overview page. Failures render HTML pages with the failing step named: a 502 for an unreachable or failed exchange or introspection, a 500 when tokens were issued but could not be stored. The callback requires `HSX_APP_ID`, `HSX_HUBSPOT_CLIENT_ID`, `HSX_HUBSPOT_CLIENT_SECRET`, plus the token-storage bindings (`HSX_TOKEN_KEY`, `INSTALL_KV`) that `hs-x deploy` provisions.

## The signed-grant surfaces

Two route groups take a different credential entirely: a short-lived HMAC grant in the `Authorization: Bearer` header, signed with the per-install `HSX_SYNC_GRANT_KEY` secret provisioned at deploy. The grant is `base64url(JSON claims).base64url(HMAC-SHA256)`; the claims carry the install identity (`accountId`, `projectId`, `environment`, `hubSpotAppId`), an enumerated scope list, issue and expiry timestamps, and a nonce. **A grant is only honored when its identity claims equal the Worker's own baked-in identity, and it expires five minutes after issue by default.** The failure ladder is precise: `401 unauthenticated` with no bearer token, `401 invalid_grant` for a bad signature or expired grant, `403 grant_binding_mismatch` for the wrong install, `403 out_of_scope` for a missing scope token.

`POST /_hsx/sync/read` is the tenant-data read-out: the path by which the control plane's sync worker reads installer users and platform-event occurrences out of the developer's own D1 to deliver them to a HubSpot CRM destination. The body is a discriminated selector over two resources and four modes:

| `resource` | `mode` | Required scope |
| --- | --- | --- |
| `installed_portal_users` | `by_portal` (a `portalId`) | `installed_portal_users:read` |
| `installed_portal_users` | `by_role` (optional `roles`, `since`) | `installed_portal_users:read` |
| `hsx_platform_event_occurrences` | `by_project` (an `environment`) | `hsx_platform_event_occurrences:read` |
| `hsx_platform_event_occurrences` | `by_install` (an `installId`) | `hsx_platform_event_occurrences:read` |

All modes take an optional `limit` and answer `{"ok": true, "resource", "rows"}`. Windowed reads clamp `since` so a caller can only narrow, never widen, the replay window; the floor defaults to 90 days back, or the grant's own `windowDays` when it carries one. A grant that enumerates `roles` also caps which roles a `by_role` read may request. The endpoint and its signing client are live; the CRM sync app that would call it routinely is gated behind app-object approval, so nothing in the platform reads through it today.

`POST /_hsx/flags/define` and `POST /_hsx/flags/list` are the flag-authoring writes, behind the same grant machinery with the `flags:write` scope. This is what `hs-x flags create`, `hs-x flags set-state`, and `hs-x flags list` speak. Define is a full-definition upsert, last write wins: the body must be a complete flag definition whose identity equals the Worker's scope (`403 identity_mismatch` otherwise), the D1 config row is overwritten, and the project's flags are re-projected into the KV snapshots that `/_hsx/flags/evaluate` and `ctx.flags` read. The response is `{"ok": true, "flag", "syncedSnapshots"}`. There is no delete; archival is the kill switch, a PUT of the same definition with `state: "archived"`, which the evaluator treats as serve-the-default. Both routes answer `409 flags_authoring_not_configured` on deploys without the tenant D1 and flags KV bindings.

## How this differs from the local dev server

The local dev server (`hs-x dev`) and a deployed Worker dispatch through the same production router code, so a capability behaves identically in both. The HTTP wrapper around that router is different in shape:

| | Dev server | Deployed Worker |
| --- | --- | --- |
| Invoke route | `POST /_hsx/invoke/<id>` | `POST /capabilities/<id>/invoke` |
| Scope of one origin | The whole project, every worker | One worker |
| Manifest shape | `{ "workers": [...] }` | `{ "name", "capabilities" }` |
| Health body | `{ "ok": true, "cliVersion" }` | `{ "ok": true, "worker", "runtimeVersion" }` |
| Handler logs | Returned inline as `logs: [...]` | Workers Logs, read via `hs-x logs` and the dashboard |
| Install identity | Fixture defaults (`portalId: "0"`) | Real installs; `ctx.hubspot` resolves stored portal tokens |
| HubSpot-facing routes | None | Workflow actions, webhooks, cards, OAuth |

The dev server serves exactly three endpoints, and none of the signed or HubSpot-facing surface; webhook signatures, OAuth installs, grants, and flag endpoints exist only on the deployed Worker. [The local dev HTTP reference](/docs/dev-http) documents the dev side of this table.

## Related reference

- [Local dev HTTP API](/docs/dev-http) — the three-endpoint dev-server counterpart of this surface.
- [Deploy lifecycle](/docs/deploy-lifecycle) — how attestation heartbeats riding these routes gate promotion.
- [Observability](/docs/observability) — the checkpoint and Workers Logs pipeline every dispatch feeds.
- [HubSpot API rate limits](/docs/rate-limits) — the backpressure result shapes the invoke envelopes can carry.
- [Triggers guide](/docs/guides/triggers) — pairing a HubSpot webhook subscription with `/webhooks/hubspot/<id>`.
- [Workflow actions guide](/docs/guides/workflow-actions) — the handler side of the action invoke routes.
- [Feature flags guide](/docs/guides/feature-flags) — authoring the flags that `/_hsx/flags/evaluate` serves.

---

*Last updated: June 11, 2026. Behavior reflects the runtime shipped with CLI `v0.2.5`, including the v3-signature fallback on webhook triggers and the `missing_required_input` contract on invoke routes. Refreshed when the route map or an auth model changes.*

