
# 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>`, external push-source deliveries at `POST /webhooks/<source>`, 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 across signed HubSpot calls, source HMACs, narrowly scoped HS-X grants, and intentionally public reads. **Webhook triggers, workflow actions, and generic capability invokes require a configured HubSpot client secret and a valid v3 signature.** Bad requests get 401; a missing server-side secret fails closed with 503 before dispatch. Tenant-data, flag-authoring, and install-lifecycle endpoints take short-lived HMAC grants bound to the Worker's own identity. Sync runs take their own one-time, capability-bound grant; an unsigned browser or public HTTP caller cannot start one.

**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. A non-POST request to a dispatch path answers `405 {"ok": false, "error": "method_not_allowed"}`, an unmatched capability id on a matched path answers 404 with a route-specific code (`unknown_capability`, `unknown_trigger`, `unknown_sync`, `unknown_push_source`, `unknown_card_backend`, `unknown_workflow_action`, `unknown_action`), and any other path returns `404 {"ok": false, "error": "not_found"}`. Those 404 and 405 answers come before signature verification, so capability ids are enumerable without a signature.

## 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 | HubSpot v3 signature, always (fails closed without a secret) | Any declared capability; the universal dispatch door |
| `/workflow-actions/<id>/invoke` | POST | HubSpot v3 signature, always (fails closed without a secret) | `worker.action()` / `worker.tool()` |
| `/workflow-actions/<id>/batched/invoke` | POST | HubSpot v3 signature, always (fails closed without a secret) | Batched actions (needs a completion queue) |
| `/_hsx/cards/<id>` | POST | HubSpot v3 signature, always (fails closed without a secret) | `worker.cardBackend()` |
| `/webhooks/hubspot/<id>` | POST | HubSpot v3 signature, always (fails closed without a secret) | `worker.trigger()` |
| `/webhooks/<source>` | POST | Raw-byte HS-X HMAC, always (fails closed without a bound secret) | A `sync` whose source is `defineSource.push(...)` |
| `/sync/<id>/run` | POST | One-time HS-X HMAC grant bound to tenant, app, and capability | `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 | Server-only Bearer grant with `flags:evaluate` | Trusted server flag reads |
| `/_hsx/flags/define`, `/_hsx/flags/list` | POST | One-time signed grant with exact `flags:write` scope | Flag authoring (`hs-x flags`) |
| `/_hsx/sync/read` | POST | Signed grant with read scopes | Control-plane reads of tenant data |
| `/_hsx/install-lifecycle/uninstall` | POST | HS-X grant, `install_lifecycle:uninstall` scope, bound to one portal and delivery | The control plane's uninstall handoff: marks the install uninstalled and records the platform event |

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: `batched_actions_not_configured`, `flags_not_configured`, `flags_authoring_not_configured`, `sync_read_not_configured`, `uninstall_lifecycle_not_configured`. Missing *secrets or verifiers* fail closed with `503` instead (`hubspot_signature_verification_unavailable`, `card_verifier_unconfigured`, `push_source_unconfigured`, `flags_evaluate_auth_not_configured`, `flags_authoring_auth_not_configured`, `sync_run_auth_not_configured`, `flags_rate_limiter_not_configured`), and a missing tenant migration or delivery seam is a `500`. 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.

A few answers cut across every dispatch route. A capability whose `billing` block gates the install answers `402 billing_gated` on the invoke, card, and sync routes (the workflow-action route speaks `BLOCK` instead, and a gated webhook delivery is acknowledged with `200 {"ok": true, "gated": true}`). After a rollback, a request that reaches a revision the route table no longer names answers `409 inactive_revision` with the current and active deploy ids. A `Content-Length` that is not an integer is `400 invalid_request_body`.

## Health and manifest

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

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

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&nbsp;minutes per isolate. Each heartbeat carries a short-lived HMAC grant over the exact body, bound to the account, project, environment, HubSpot app, deploy, and Cloudflare `version_metadata` revision. The control plane verifies and atomically consumes its nonce before changing drift state; missing secret or replay storage fails closed. 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 is a `400 invalid_payload` on every invoke route; `/capabilities/<id>/invoke`, the batched route, and card backends additionally reject a body that fails the dispatch-payload schema with the same code, while `/workflow-actions/<id>/invoke` accepts any JSON object and maps HubSpot's native fields itself. All HubSpot-signed routes and the push route require `application/json` or an `application/*+json` media type (parameters such as `charset=utf-8` are allowed) and reject a missing or unsupported media type with `415 unsupported_media_type`. They enforce a 256&nbsp;KiB body limit from both `Content-Length` and the actual request stream, returning `413 request_too_large` before signature verification or any privileged work. The exact accepted bytes are then used for the signature check. The grant-protected routes read their bodies separately: `/sync/<id>/run` caps at 64&nbsp;KiB with no media-type check, the flag routes at 64&nbsp;KiB, `/_hsx/sync/read` and the uninstall route at 16&nbsp;KiB. 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: the route requires a configured signature secret (an explicit runtime option or the `HSX_HUBSPOT_CLIENT_SECRET` binding) and a valid HubSpot v3 signature. Missing or invalid signatures answer `401`; a runtime missing the secret answers `503 hubspot_signature_verification_unavailable` before parsing the payload or calling a handler. The `install` identity in the payload is caller-asserted, so fail-closed signature verification 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`; its in-process local adapter has a separately named, explicit unsigned-test option for projects that have not obtained a client secret yet. Generated deployed workers never enable that option.

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 on `/_hsx/cards/<id>`; it applies to tools and card backends, the two kinds that declare an `input` field map. Triggers and syncs have no input 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`. It answers `404 unknown_action` for a non-tool id and, on a deploy without a completion queue, `409 batched_actions_not_configured`, both before signature verification. 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 the `409`.

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 runtime first reads one bounded, exact copy of the body, verifies the signature over that copy, and only then parses or dispatches it.

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 workflow-action and generic capability invokes. When no secret exists, HubSpot-authored routes return `503 hubspot_signature_verification_unavailable` without parsing or dispatching, and `hs-x deploy` warns that those routes remain unavailable until the secret is configured and the Worker is redeployed. Unsigned execution exists only behind `hubSpotRequestAuth.insecureAllowUnsignedRequestsForLocalDevelopment`, an explicit option for isolated local-development/test adapters; do not set it on a deployed Worker.

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&nbsp;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 rather than 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, and the path the generated `src/app/cards/_hsx-backend.ts` client wraps when a card declares `backend`. Whatever the card sends must sit under `input`: the body is decoded as the dispatch payload and other top-level keys are discarded, and any `install` block the card sends is overwritten with the server-verified install. The envelope matches the generic invoke: `{"ok": true, "capabilityId", "result"}`, with backpressure surfaced as a successful structured result. Every request must carry a valid HubSpot v3 request signature — `hubspot.fetch()` calls are signed by HubSpot automatically, and the verified portal identity comes from the HubSpot-appended query parameters, never from the request body. A deployed worker with no client secret configured answers `503 card_verifier_unconfigured` rather than dispatching; the only non-HubSpot path is the dev-proxy adapter the `hs-x dev` loop signs explicitly.

`POST /_hsx/flags/evaluate` is a server-only escape hatch for trusted callers that cannot use `ctx.flags` directly. It reads KV snapshots and runs the same pure evaluator. Every request must carry a one-use HMAC grant in `Authorization: Bearer`, scoped only to `flags:evaluate`, valid for no more than five minutes, and bound to the exact account, project, environment, app, portal, and canonical install. The body cannot carry a grant. Because `hubspot.fetch` cannot set this header, a card iframe cannot call the route.

```json
// Authorization: Bearer <one-use signed grant>
{ "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. Signed identity always wins over body context, and any cross-install identity in `targetingContext` is rejected before KV is read. The runtime also caps the body, key count, and key length; consumes the nonce durably in tenant D1; and returns error envelopes without flag data. Missing auth wiring fails closed with `503 flags_evaluate_auth_not_configured`; missing, invalid, expired, future, overlong, out-of-scope, cross-install, and replayed grants are rejected. On a deploy without the flags KV binding (any unlinked deploy) the route answers `409 flags_not_configured`.

Generated linked Workers also bind Cloudflare's Rate Limiting API to `evaluate`, `define`, and `list`. The key contains the deployment identity and exact route; each key allows 120 calls per 60 seconds in each Cloudflare location. Limiting runs before body reads, grant verification, replay claims, and tenant-store access. Exhaustion is `429 rate_limited` with `Retry-After: 60`; a missing, malformed, or throwing binding is `503 flags_rate_limiter_not_configured`. Cloudflare's counters are permissive and location-local, so this is abuse containment rather than exact usage accounting; a WAF rule remains valid defense in depth.

Flag-using UI cards must declare `flags: 'server'` and a card backend. The backend evaluates through `ctx.flags` and returns inert values in its signed response; the UI SDK provides only an in-memory snapshot reader. [The feature flags guide](/docs/guides/feature-flags) covers that boundary plus authoring and rollout rules.

## Sync runs: manual, scheduled, and push

`POST /sync/<id>/run` executes a leased run of a `sync` capability. It fails closed unless `Authorization: Bearer <grant>` verifies with `HSX_SYNC_GRANT_KEY` and binds the exact account, project, environment, HubSpot app id, and path capability to the single `sync_run:execute` scope. Grants expire within five minutes, tolerate only bounded clock skew, and carry a nonce claimed atomically in tenant D1 before the runtime reads the body, looks up installs, or dispatches work; replay is a `401`, missing verifier/replay wiring is a `503`, and bodies over 64&nbsp;KiB are a `413`. Generated Cloudflare scheduled handlers mint this grant automatically. Keep manual signers server-side; browsers and unsigned public callers are deliberately rejected.

Sync state — the last safe cursor, a single-winner lease with a fencing token, and chunk checkpoints for resumable pages — is durable per `(capabilityId, portalId)` pair: tenant D1 (runtime migration `0006`) on a standard deploy, a per-isolate memory store in local dev. The handler gets a cursor context whose `setCursor` durably completes a page (the cursor never moves backward) and whose `checkpoint`/`checkpoints` pair lets an interrupted run resume mid-page instead of restarting from zero.

Who runs depends on the body. With no `install` block, the runtime enumerates the app's installs from the token owner store, skips any that are not live, runs each live portal in isolation — one portal's failure never stops another — and answers `{"ok": true, "capabilityId", "installs", "runs": [...]}` with a per-portal summary. With an `install` block, the body only selects the install: identity is derived from the recorded install (`404 sync_install_not_found`, `409 sync_install_inactive`, `403 sync_install_portal_mismatch` otherwise), and a single-portal run answers:

```json
{ "ok": true, "capabilityId": "import-orders", "installId": "hubspot-app:77:portal:46993937", "portalId": "46993937", "cursorUpdated": true, "cursor": "2026-06-11T03:12:00Z", "result": null }
```

A dispatch that loses the lease race exits cleanly with `409 sync_lease_held` — exactly one run per capability and portal at a time. A tenant D1 that predates migration `0006` fails closed with `500 sync_store_migration_missing` rather than silently losing progress. A source-backed sync whose runtime has no HubSpot delivery seam — no `managedHubSpot`, which a standard linked deploy provides — fails closed with `500 sync_delivery_unconfigured` rather than accepting rows it cannot write. Workers without any install store (local dev, pre-OAuth deploys) trust the body's install identity, defaulting to `local-portal`.

For a **source-backed** sync the run does not stop at the handler: the runtime batch-upserts the source's rows into the declared destination object through HubSpot's batch endpoint (keyed on the sync's `idProperty`, so redelivery updates rather than duplicates), and any row that fails local schema validation is set aside in a poison-row DLQ (tenant migration `0007`) instead of failing the whole page. A pull source walks pages from the safe cursor; a push source consumes the rows its `receive` already produced.

`POST /webhooks/<source>` is that push door: a single path segment (so it never shadows the two-segment `/webhooks/hubspot/<id>` trigger route), matched to a `sync` whose source is a `defineSource.push(...)`. An unknown source name — or a name that resolves to a pull-only sync — is a `404 unknown_push_source`, so the surface never leaks which sources exist. Auth is a raw-byte HMAC-SHA256, base64-encoded, over `<x-hsx-timestamp>\n` followed by the exact bounded request bytes, sent in the `x-hsx-signature` header; `x-hsx-timestamp` is a millisecond epoch that must sit inside the skew window (5&nbsp;minutes by default). Replays are rejected by a per-isolate cache and, on a deploy with the tenant database bound, by an atomic `push:`-namespaced claim in tenant D1, so a replay that lands on another isolate is caught too. A missing, malformed, stale, mismatched, or replayed signature is a `401` (`missing_push_signature`, `invalid_push_timestamp`, `stale_push_signature`, `invalid_push_signature`, `replayed_push_signature`) with **zero privileged effects**: `receive` is never called and no run dispatches. The secret is a Worker binding named by the source's `hmac` auth; a source whose secret is unbound fails closed with `503 push_source_unconfigured`. Past verification, the body is parsed (`400 invalid_push_payload` on non-JSON), `receive` is called exactly once to normalize the event into rows (a `receive` that throws is `500 push_receive_failed`), and those rows reach the handler as `input.rows` alongside `input.event` before feeding the same durable pipeline as a manual run.

A sync's declared `schedule` is honored automatically on a deploy that generates one: `hs-x deploy` normalizes each wall-clock cadence to a Cloudflare cron trigger and bakes a `scheduled` handler that mints a fresh grant and fans each fire out to its syncs as an empty-body dispatch — the same install-enumerating fan-out `/sync/<id>/run` runs. So a `schedule` of `"5m"` or a cron expression fires on its own on the deployed Worker; `"event"` sources are webhook-driven and get no cron, and `"manual"` sources are caller-only. A trusted server-side caller can use `createRuntimeSyncRunRequest(...)` with the same deploy scope and secret; never expose that signer or `HSX_SYNC_GRANT_KEY` to browser code. This is verified in the runtime tests; live end-to-end proof on real Cloudflare cron and HubSpot is still pending. [The syncs guide](/docs/guides/syncs) covers cursor semantics and schedules 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 `returnUrl` HubSpot supplied (marketplace installs), else the developer's configured `install.successUrl`, else the portal's connected-apps overview page. A missing `code`, or an unexpected `step` parameter, is a 400. Other 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

Three route families take a different credential entirely: a short-lived HMAC grant signed with the `HSX_SYNC_GRANT_KEY` secret that `hs-x deploy` generates once per linked deploy scope (account, project, environment, app) and stores in local state, so `hs-x flags` can sign without the control plane. All four grant-taking families read the grant from `Authorization: Bearer` only; a body-carried grant is rejected as `400 invalid_request`, which is why a card iframe (whose `hubspot.fetch` cannot set headers) has no path to these routes. The grant is `base64url(JSON claims).base64url(HMAC-SHA256)`; the claims carry the deployment identity (`accountId`, `projectId`, `environment`, `hubSpotAppId`), an enumerated scope list, issue and expiry timestamps, and a nonce. Flag-evaluation grants additionally bind the exact portal and canonical install and are consumed once. **A grant is only honored when its identity claims equal the Worker's own baked-in identity, and no grant may live longer than five minutes.** Error names are route-specific, but all fail closed for missing/invalid credentials, binding mismatches, and missing scopes.

`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 HS-X CRM destination uses them request-locally to write installer Contacts and sparse lifecycle Notes. App-object approval affects only the additive app-object writers.

`POST /_hsx/flags/define` and `POST /_hsx/flags/list` are the flag-authoring writes. This is what `hs-x flags create`, `hs-x flags list`, and the state changes `hs-x flags enable`, `disable`, and `archive` speak. Each request requires a grant carrying only the `flags:write` scope, bound to the Worker's account/project/environment/app identity, valid for no more than five minutes, and consumed once through an atomic tenant-D1 nonce claim before the body or flag stores are read. A replay returns `401 replayed_flags_authoring_grant`; missing or failed durable claim wiring returns `503 flags_authoring_auth_not_configured`. Both routes require `application/json`, cap the streamed body at 64&nbsp;KiB, reject excess top-level properties, bound list limits to an integer from 1 through 100, mark responses `Cache-Control: no-store`, and return `400 invalid_request` when those contracts fail.

Define is a full-definition monotonic upsert: the body identity must equal the Worker's scope (`403 identity_mismatch` otherwise), a higher version advances D1 and re-projects KV, a lower version returns `409 stale_flag_definition`, and a divergent definition at the current version returns `409 flag_version_conflict`. Retrying the exact current definition with a fresh grant is idempotent and returns `200` with `idempotent: true`. There is no delete; archival is the kill switch and must advance the version, after which the evaluator serves the default. Both routes answer `409 flags_authoring_not_configured` on deploys without the tenant D1 and flags KV bindings.

`POST /_hsx/install-lifecycle/uninstall` is the control plane's uninstall handoff. When HubSpot reports that a portal uninstalled the app, the control plane signs a grant carrying only the `install_lifecycle:uninstall` scope, bound to the Worker's deployment identity and to the exact `portalId`, `deliveryId`, and `occurredAt` of that delivery, and posts those three fields as the body. The runtime derives the install id from its own app binding and the grant-bound portal (the caller never names one), marks the installed-portal record uninstalled, and writes an `hsx.app_uninstalled` platform-event occurrence under a fenced per-portal lease. The write is idempotent: a repeat of the same delivery answers `200` with `deduped: true`, and a repeat whose recorded marker disagrees answers `409 completion_marker_mismatch`. A grant that is missing, expired, or bound to a different deployment, portal, or delivery is a `401` or `403`; a Worker without the lifecycle wiring answers `409 uninstall_lifecycle_not_configured`.

## 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 | `POST /_hsx/cards/<id>` only (unsigned, for the `hs-x dev` UI-extension bridge) | Workflow actions, webhooks, cards, OAuth |

The dev server serves four endpoints (health, manifest, invoke, and an unsigned `/_hsx/cards/<id>` bridge for the local UI-extension loop) and none of the signed 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 four-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 flags and delivering server-evaluated UI snapshots.

---

*Last updated: August 27, 2026. Behavior reflects the current `@hs-x/runtime` release, including the durable sync paths: the `/webhooks/<source>` push route, the generated cron that fires declared schedules, and the source-backed batch-upsert delivery with a poison-row DLQ. Those sync paths are verified in the runtime tests; live end-to-end proof on real Cloudflare and HubSpot is still pending. Refreshed when the route map or an auth model changes.*

