Capability allow-list
A plugin declares what it’s allowed to do via its capabilities, a JSON value with three recognised namespaces: http, store, auth. There is no capability that lets a plugin reach HubSpot directly.
Where capabilities live and how to set them
Capabilities belong to the plugin resource, not to the flow step that invokes it (the step only supplies logical_name and config — see Promotion and version pinning). Set them at create time or update them later:
# at creation
curl -X POST "$BASE/api/plugins" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"name": "acme_doc_generator", "capabilities": {"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]}}'
# updating an existing plugin's capabilities
curl -X PATCH "$BASE/api/plugins/$PLUGIN_ID" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{"capabilities": {"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]}}'The plugsync CLI does not currently have a dedicated command to set or update capabilities on an existing plugin — plugsync plugin push only uploads the bundle and source; capabilities are untouched by it. plugsync plugin init scaffolds plugin.json with an empty object ("capabilities": {}), which is only used on the first plugsync plugin push (the one that creates the plugin, when plugin.json’s id is still null). Fill in your real capability object in plugin.json before that first push, or PATCH /api/plugins/{id} afterwards as above.
Format
The canonical shape is a JSON object, namespace -> list of granted values:
{"http": ["*.acme.example"], "store": ["read"], "auth": ["doc_generator"]}An equivalent flat-list shape is also accepted everywhere capabilities are read at runtime — one parser is shared by the push-time linter and the runtime invoker, so the two can never drift:
["http:*.acme.example", "store:read", "auth:doc_generator"]Both shapes parse to the same grants. POST/PATCH /api/plugins accept both, validate the namespaces (anything outside http, store, auth is rejected with a 422), and persist the canonical object shape. The worked example on this site (the walkthrough’s acme_doc_generator) uses the object shape; prefer it.
http: outbound HTTP allow-list
A list of host patterns your plugin’s bundle is allowed to call, matched with fnmatch glob semantics (* wildcards). An exact hostname with no wildcard characters is a valid literal-match entry.
{"http": ["generate-doc.example.cloudfunctions.net"]}or with a wildcard covering a whole domain:
{"http": ["*.acme.example"]}Enforcement is push-time only, and best-effort. POST /api/plugins/{id}/push runs a static linter over your bundled JS text: it scans for fetch("https://..."), http(s).request("https://..."), and bare URL string literals, extracts each hostname, and rejects the push with 422 plugin_capability_violation if any hostname isn’t covered by an allowed glob. It also rejects require/import of the raw Node network/process modules (net, child_process, dgram, dns) outright, regardless of capabilities.
A few consequences of how the scan works:
- It reads the whole bundle, not just your handler. Any
http://orhttps://string literal counts, including ones inside bundled dependencies (documentation URLs, default endpoints of an SDK you never call). Each of those hosts must be covered by your allow-list, or the push is rejected. - A wildcard covers subdomains only:
*.acme.examplematchesapi.acme.examplebut notacme.exampleitself. Declare the apex separately if you call it. - The scan runs on push only. Changing capabilities later with
PATCH /api/plugins/{id}does not re-lint the bundle already deployed; the next push is checked against the new list.
This is explicitly not a runtime network sandbox: string concatenation, template literals, eval, and minified/obfuscated bundles can trivially evade the linter’s text scan, and there is no egress proxy or VPC-level enforcement of the allow-list once the Lambda is running. The residual risk is accepted by design — the real trust boundary is the linter (catches accidental/negligent violations) plus explicit promotion to live plus the org’s tier contract, not sandboxed execution.
store: plugsync-mediated key/value store
{"store": ["read", "write"]}Grants your plugin a small persistent key/value store, scoped to (connector_id, plugin name). Keys persist for the life of the connector and are deleted when the connector is deleted; a key can opt into expiry with ttl_seconds (see store_writes). Your Lambda never reaches the store itself — plugsync mediates every read and write:
store: ["read"]pre-loads keys declared in the step’splugin.config.store_keysintopayload.storebefore invoking your handler.store: ["write"]lets the runtime applystore_writesentries from yourPluginResponseafter your handler returns.
See payload-contract.md for the exact resolution mechanics and generate-doc-walkthrough.md for the working example (one-document-per-deal idempotency).
auth: scoped outbound credentials
{"auth": ["doc_generator"]}Each entry is the name of an EventSource (a rest source with config.outbound.credential_name, default_headers and/or secret_headers set) whose resolved outbound headers your plugin is allowed to see, under payload.auth[<source_name>]. Only that one source’s headers are exposed — never the full credential map, and never a HubSpot credential (HubSpot OAuth tokens are excluded from this resolution entirely).
You never put an API key in your bundle: you declare it once as a platform credential, bind it to a source, and plugsync hands your handler the finished headers on every invocation. Those headers carry the secret in plaintext (Authorization: Bearer <secret>, apikey: <secret>; only oauth2_client_credentials substitutes a short-lived token), so treat event.auth as sensitive: never log it, never echo it back in your PluginResponse. Rotating the key is a credential update — no re-push, no re-promotion, no re-publish.
Wiring one up, end to end
1. Create the credential (POST /api/credentials, stored Fernet-encrypted; the accepted credential_type values and the data shape each one expects are under Outbound targets in Event sources):
POST /api/credentials
{"name": "kvk_api_key", "credential_type": "api_key", "data": {"value": "<the-key>"}}To rotate this credential later, PUT /api/credentials/{id} with a new data (see the endpoint table under Outbound targets): the source keeps resolving kvk_api_key by name, so nothing else changes.
2. Bind it to a rest source on the connector (POST /api/connectors/{connector_id}/sources) whose config.outbound names the credential and says how to present it. Sources are per connector while the plugin is per org: every connector that runs this plugin needs a source with this exact name, because the grant in step 3 is matched by name at runtime. Preflight reports a grant that matches no such source on the connector, both on the connector’s preflight and in the warnings[] of the publish response (see below):
{"name": "kvk", "type": "rest",
"config": {"outbound": {"base_url": "https://api.kvk.nl",
"credential_name": "kvk_api_key",
"auth_type": "header",
"auth_header": "apikey"}}}3. Grant the capability on the plugin — the source name under auth, the host under http — then publish the connector (“Review changes” -> “Publish”). Capabilities are embedded into the manifest at publish time, so a PATCH /api/plugins/{id} alone changes nothing for a connector that is already live until its next publish (or the plugin’s next promotion). The http entry is declarative in this pattern: the push-time linter only checks URL string literals, and with the host coming from sources_index there is none to check:
{"http": ["api.kvk.nl"], "auth": ["kvk"]}4. Read it in your handler. The headers arrive ready to use; the base URL comes from sources_index. Both are in every real flow invocation. plugsync plugin invoke --auth kvk reproduces the headers half without publishing anything — it resolves that source’s outbound auth the same way and enforces this same grant (see the dev loop) — while sources_index still only arrives through a real flow, so a handler that reads base_url from it is exercised on a test-mode connector:
const nr = String((event.payload || {}).kvk_number || "");
if (!nr) {
throw new Error("kvk plugin: payload.kvk_number missing");
}
const headers = (event.auth || {}).kvk || {}; // {"apikey": "..."}
if (!headers.apikey) {
// {} when no headers were resolved for kvk -- see "What lands in payload.auth" below
throw new Error("kvk plugin: kvk credential not resolved");
}
const src = ((event.sources_index || {}).kvk || {}).config || {};
const baseUrl = (src.outbound || {}).base_url; // "https://api.kvk.nl"
if (!baseUrl) {
throw new Error("kvk plugin: kvk outbound.base_url not configured");
}
const resp = await fetch(baseUrl + "/v2/zoeken?kvkNummer=" + encodeURIComponent(nr), { headers });
if (resp.status === 429 || resp.status >= 500) {
return { action: "retry", reason: "kvk HTTP " + resp.status }; // retried, not failed on the spot
}
if (!resp.ok) {
throw new Error("kvk plugin: kvk HTTP " + resp.status);
}What lands in payload.auth[<source>]
The outbound auth headers a manifest post step resolves for that destination (step-level headers are not part of it) — config.outbound.default_headers merged in first, then the values of config.outbound.secret_headers (see Secret static headers), then the credential-derived header on top:
credential_type | auth_type | Header your handler receives |
|---|---|---|
api_key / bearer_token | header (default for api_key) | {"<auth_header>": "<secret>"}, and auth_header defaults to X-API-Key |
api_key / bearer_token | bearer (default for bearer_token) | {"Authorization": "Bearer <secret>"} |
api_key / bearer_token | basic | {"Authorization": "Basic base64(<secret>:)"} — the secret is the username and the password is empty, not a user/password pair |
oauth2_client_credentials | not used | {"Authorization": "Bearer <token>"}, with plugsync fetching and caching the token; auth_type is ignored |
login | not used | Nothing usable. login is a creds-in-body type: its username/password go to sources_credentials, which never crosses the plugin boundary. Your handler sees only that source’s default_headers and secret_headers. |
A source with default_headers but no credential_name resolves to just those static headers — a valid way to hand a plugin a fixed routing header with no secret involved. The same holds for secret_headers, when the fixed header is a secret such as a gateway key.
payload.auth[<source>] is an empty object — {}, never undefined — whenever no outbound headers resolved for that source. One case is silent by design: a login source without default_headers or secret_headers, whose username/password go to sources_credentials and never cross the plugin boundary, so the source is correctly configured and simply has no header to hand over. Every other way to get {} is reported before it reaches runtime:
| Situation | What you get |
|---|---|
| The grant names no source of this connector (a typo, or the plugin reused on a connector whose source is named differently) | Warning plugin_auth_grant_unknown_source, with the closest source name as a suggestion |
The grant names a real source that declares none of outbound.credential_name, outbound.default_headers or outbound.secret_headers — a poller whose credential sits under config.inbound, for instance, resolved by the poll and not by this resolver | Warning plugin_auth_grant_source_without_outbound_auth |
| Someone renames or deletes a source that the connector’s published version consumes only through a grant | 422 on PATCH/DELETE .../sources/{name}, listing the blocking step and flow, exactly as for a step.target |
Both warnings are advisory, like every preflight finding, and never block a save or a publish. They appear in GET /api/connectors/{connector_id}/preflight (what the Review-changes drawer renders) and in the warnings[] of the POST /publish response, so publishing from the CLI, the MCP server or the API without opening the preview still surfaces them. They are not part of the per-flow dry-run (POST /drafts/flows/{name}/dry-run), whose warnings[] carry only that run’s per-step findings and outbound-credential errors.
The preflight reads the plugin’s current grants, which is what the next publish will embed into the manifest, so a grant fixed with PATCH /api/plugins/{id} clears the warning immediately while the live connector keeps the old grant until its next publish.
When a rename does go through (nothing published depends on the old name), it cascades into the grants embedded in this connector’s draft, and stops there: a plugin’s capabilities are org-scoped and shared by every connector running that plugin, so rewriting them would silently repoint a sibling connector’s grant. Update the plugin yourself after the rename; until you do, the preflight warning above is what tells you the two have drifted. In the normal authoring flow the draft carries only logical_name (plus an optional config) and has no grants to cascade at all — the embedded block appears in a draft restored from a published version.
Clearing a grant that blocks a rename or a delete takes one extra step, and the 422 says so. PATCH /api/plugins/{id} updates the plugin, but the active version keeps the copy embedded at its last publish, and a publish with no other edit is refused as a no-op (the diff strips the embedded plugin fields before comparing, so the removed grant is invisible to it). After clearing the grant, either publish a config change that drops the plugin step from the flow, or run plugsync plugin promote, which rewrites the plugin block on every active version running that plugin — then retry the rename or the delete. plugsync plugin push does not do it: it deploys the dev function and leaves every active version’s embedded block untouched.
A credential that fails to resolve (name not found in the org, an unsupported type, a blank or undecryptable secret, an oauth2_client_credentials token endpoint that errors) is a different case: it is a flow-level error, and plugsync pre-fails every flow that depends on that source before any step runs, the steps whose target is that source (post, find, delete) and the plugin steps granted it under auth alike. The event carries an outbound auth unavailable error, and the failure feeds the flow’s circuit breaker (a transient token-endpoint error is retried instead), so your handler is never invoked with a broken credential. It can still be invoked with {} in the no-error cases above, and {} is truthy, so if (!event.auth.kvk) never catches it: check for the header you expect (if (!headers.apikey) ..., as in step 4), otherwise the request leaves unauthenticated and the target answers 401.
Reserved, not implemented
A hubspot:<verb> capability namespace (e.g. hubspot:get_associated, hubspot:update_object) letting a plugin request a mediated, allow-listed HubSpot operation is the sanctioned future widening path — deferred, not implemented. Do not declare it; nothing in the runtime recognises it today, and POST/PATCH /api/plugins reject any namespace outside http, store, auth with a 422.
Tier gate
POST /api/plugins, POST /api/plugins/{id}/push and POST /api/plugins/{id}/promote require the org’s tier to include the Plugin System; every other tier gets 402 Payment Required with error: plugin_system_not_allowed. The other plugin endpoints are not gated.
Creating a mode=test connector requires the same tier feature, and POST /api/connectors 402s the same way when it is missing.
The body of the 402, and what each plan includes: Plan limits.