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>, 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

RouteMethodAuthAnswers for
/_hsx/healthGETnoneLiveness and identity; the promote waiter's ping target
/_hsx/manifestGETnoneThis Worker's capability inventory
/capabilities/<id>/invokePOSTHubSpot v3 signature, always (fails closed without a secret)Any declared capability; the universal dispatch door
/workflow-actions/<id>/invokePOSTHubSpot v3 signature, always (fails closed without a secret)worker.action() / worker.tool()
/workflow-actions/<id>/batched/invokePOSTHubSpot v3 signature, always (fails closed without a secret)Batched actions (needs a completion queue)
/_hsx/cards/<id>POSTHubSpot v3 signature, always (fails closed without a secret)worker.cardBackend()
/webhooks/hubspot/<id>POSTHubSpot v3 signature, always (fails closed without a secret)worker.trigger()
/webhooks/<source>POSTRaw-byte HS-X HMAC, always (fails closed without a bound secret)A sync whose source is defineSource.push(...)
/sync/<id>/runPOSTOne-time HS-X HMAC grant bound to tenant, app, and capabilityworker.sync()
/oauth-startGETnoneSelf-initiated install: redirect to HubSpot authorize
/oauth-callbackGETOAuth code exchange; signed state verified when presentInstall completion and token storage
/_hsx/flags/evaluatePOSTServer-only Bearer grant with flags:evaluateTrusted server flag reads
/_hsx/flags/define, /_hsx/flags/listPOSTOne-time signed grant with exact flags:write scopeFlag authoring (hs-x flags)
/_hsx/sync/readPOSTSigned grant with read scopesControl-plane reads of tenant data
/_hsx/install-lifecycle/uninstallPOSTHS-X grant, install_lifecycle:uninstall scope, bound to one portal and deliveryThe 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 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:

$ 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 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 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 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 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 KiB with no media-type check, the flag routes at 64 KiB, /_hsx/sync/read and the uninstall route at 16 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 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 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 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:

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 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 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.

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

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

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 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 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 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 routesPOST /_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 documents the dev side of this table.