view .md
Reference · Runtime

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.

Time
≈ 11 min read
Outcome
The full production route map: which endpoint each capability kind answers on, what authenticates each one, and the exact request and response shapes.

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

RouteMethodAuthAnswers for
/_hsx/healthGETnoneLiveness and identity; the promote waiter's ping target
/_hsx/manifestGETnoneThis Worker's capability inventory
/capabilities/<id>/invokePOSTnoneAny declared capability; the universal dispatch door
/workflow-actions/<id>/invokePOSTHubSpot v3 signature when a secret is configuredworker.action() / worker.tool()
/workflow-actions/<id>/batched/invokePOSTHubSpot v3 signature when a secret is configuredBatched actions (needs a completion queue)
/_hsx/cards/<id>POSTnoneworker.cardBackend()
/webhooks/hubspot/<id>POSTHubSpot v3 signature when a secret is configuredworker.trigger()
/sync/<id>/runPOSTnoneworker.sync()
/oauth-startGETnoneSelf-initiated install: redirect to HubSpot authorize
/oauth-callbackGETOAuth code exchange; signed state verified when presentInstall completion and token storage
/_hsx/flags/evaluatePOSTnoneFlag reads for pure-UI cards
/_hsx/flags/define, /_hsx/flags/listPOSTSigned grant with flags:writeFlag authoring (hs-x flags)
/_hsx/sync/readPOSTSigned grant with read scopesControl-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 covers that pipeline.

Health and manifest

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

$ 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 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:

{
  "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 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:

{
  "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 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:

errorWhat failed
missing_hubspot_signatureNo x-hubspot-signature-v3 or no x-hubspot-request-timestamp header
invalid_hubspot_signature_timestampThe timestamp is not a millisecond epoch value
stale_hubspot_signatureThe timestamp is outside the skew window (5 minutes by default)
invalid_hubspot_signatureThe 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 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.

// 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 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:

{ "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 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:

resourcemodeRequired scope
installed_portal_usersby_portal (a portalId)installed_portal_users:read
installed_portal_usersby_role (optional roles, since)installed_portal_users:read
hsx_platform_event_occurrencesby_project (an environment)hsx_platform_event_occurrences:read
hsx_platform_event_occurrencesby_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 serverDeployed Worker
Invoke routePOST /_hsx/invoke/<id>POST /capabilities/<id>/invoke
Scope of one originThe whole project, every workerOne worker
Manifest shape{ "workers": [...] }{ "name", "capabilities" }
Health body{ "ok": true, "cliVersion" }{ "ok": true, "worker", "runtimeVersion" }
Handler logsReturned inline as logs: [...]Workers Logs, read via hs-x logs and the dashboard
Install identityFixture defaults (portalId: "0")Real installs; ctx.hubspot resolves stored portal tokens
HubSpot-facing routesNoneWorkflow 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 documents the dev side of this table.