PluginPayload / PluginResponse contract
This page documents exactly what your plugin receives and must return: the payload plugsync builds before invoking your handler, and how it interprets what you hand back. It is the contract — anything a plugin step does at runtime is described here.
Handler shape
Your Lambda function is created with Runtime="nodejs20.x" and Handler="index.handler". This means:
-
Your bundled
index.jsmust export a namedhandlerfunction, not a default export:export async function handler(event: PluginPayload): Promise<PluginResponse> { // ... } -
The CLI bundles
src/index.tswithesbuild --bundle --platform=node --target=node18 --format=cjs, which turnsexport async function handler(...)intoexports.handler = ...— exactly whatindex.handlerresolves. Aexport defaultentrypoint will bundle fine but Lambda will fail to find thehandlerexport at invoke time.plugsync plugin initscaffolds a stub that already matches this: a namedexport async function handler(event), with no import (there is no@plugsync/sdkpackage — no SDK ships on npm or anywhere else). The shapes below are plain TypeScript interfaces you can paste directly into your plugin to type the stub’sevent/return value, no dependency required. -
The same
handleralso receives the plugin’s lifecycle hooks (setup/teardown), whose payload is not aPluginPayload: it has ahook_typeand nopayload. Branch onhook_typefirst, see lifecycle-hooks.md. -
There is no per-plugin
package.json/node_modulesinstall step server-side:esbuild --bundleinlines whatever youimport/requirefrom npm packages you installed locally into a single CJS file. Node built-ins (net,child_process,dgram,dns) are rejected by the push-time linter regardless of capabilities; see capabilities.md.
Your handler can be invoked more than once for the same event
A retry re-runs the whole flow from its first step, not from wherever it
last stopped, so a plugin step is called again on every retried attempt
that reaches it - regardless of whether your handler already ran (and
succeeded) on an earlier attempt of the same event. Nothing in the contract
de-duplicates this for you: if your handler has an external side effect,
design it to check first and skip the side effect on a repeat call. See
Reliability for the full
picture, and the store capability below for a ready-made way to record
“already done”.
PluginPayload (what your handler receives)
interface PluginPayload {
schema_version: number; // currently 1
connector_id: string;
flow_name: string;
source_name: string;
event_type: string; // the type of the event being processed
external_id: string | null;
payload: Record<string, unknown>; // the inbound event body ($.payload in the manifest)
canonical: Record<string, unknown>;
outputs: Record<string, unknown>; // outputs of earlier steps in this flow, keyed by step id
entities_index: Record<string, unknown>;
sources_index: Record<string, unknown>;
item: Record<string, unknown> | null;
capabilities: unknown[] | Record<string, unknown>; // this plugin's granted capabilities, as stored
config: Record<string, unknown>; // the authored step's `plugin.config`, passed through verbatim
store?: Record<string, unknown>; // present only with the `store: ["read"]` capability
auth?: Record<string, Record<string, string>>; // present only with an `auth: [...]` capability
}| Field | Always present | Notes |
|---|---|---|
schema_version | yes | 1 today. Bump signals a breaking payload shape change. |
connector_id | yes | The connector this event is running under. |
flow_name | yes | The name of the flow this step belongs to. |
source_name | yes | The EventSource name that produced this event. |
event_type | yes | The type of the event being processed, the same value $.event_type resolves to in the manifest: order.created for an event your system sent, 0-3.update for a HubSpot one. When the flow matches with a wildcard ("event_type": "order.*") this is the real type of the event, never the pattern. Added in October 2026 without a schema_version change: a handler that does not read it is unaffected. |
external_id | yes (nullable) | Message-level external id from the event envelope, distinct from payload. |
payload | yes | The inbound event body, exactly as $.payload resolves it in the manifest. |
canonical | yes | The shared canonical dict a transform step (if any, earlier in the flow) wrote into. |
outputs | yes | Every earlier step’s output in this flow, keyed by step id — the same map $<step_id> resolves against for manifest steps. Read a prior step’s output with event.outputs["find_company"]. The output of an upsert step is {id, ext_id, properties} (the target record id, the record’s external id, its properties); an upsert_many step yields a list of the same objects. Values that JSON cannot carry natively arrive as strings (timestamps in ISO 8601). |
entities_index | yes | The connector’s resolved entities (entity name -> schema config), no secrets. |
sources_index | yes | Every EventSource’s {name, type, config}, including outbound base_url/credential_name — but never resolved secrets. Read a target’s base URL with event.sources_index["doc_generator"].config.outbound.base_url. |
item | yes (nullable) | Set on per-item execution paths (batch/many actions); null otherwise. |
capabilities | yes | This plugin’s capabilities (dict or list shape — see capabilities.md), not anything authored on the step. They are the ones the plugin had when the connector was last published or the plugin was last promoted: a later change to the plugin’s capabilities reaches running flows only at the next publish or promotion. |
config | yes | The authored step’s plugin.config object, preserved verbatim across publish and re-promotion. Static, non-secret settings — e.g. the walkthrough plugin’s {"store_keys": [...], "doc_type": "..."}. |
store | only with store: ["read"] | Pre-loaded key/value snapshot — see below. |
auth | only with a non-empty auth grant | Resolved outbound headers for the granted source(s) — see below. |
store capability payload
config.store_keys (authored on the step’s plugin.config) is a list of $...-style expressions. Each is resolved against the live execution context (the same resolver manifest steps use, e.g. $.payload.object.id) to produce a concrete key string; plugsync then fetches that key’s stored value from the plugin’s key/value store (scoped to connector_id + plugin name) and pre-loads it into payload.store, keyed by the resolved key.
"plugin": {"logical_name": "acme_doc_generator",
"config": {"store_keys": ["$.payload.object.id"]}}If the deal id resolves to "12345", your handler sees event.store["12345"] — the previously stored value for that key, or null if nothing was ever written for it or the key was written with a ttl_seconds that has run out (see Expiry). A key expression that resolves to nothing (a missing field, null or an empty string) is skipped: no entry for it appears in event.store at all, so check for the key with in or ?. rather than assuming it is there. This is how a plugin implements one-write-per-record idempotency without maintaining its own database.
A key expression can resolve to any envelope value, not only to something in the body. With "store_keys": ["$.event_type"] the snapshot is keyed by the event type, and since the payload carries the same value your handler reads it back as event.store[event.event_type], with no key written by hand.
auth capability payload
For each source name granted under the auth capability, payload.auth[<source_name>] carries only that source’s resolved outbound headers (e.g. {"Authorization": "Bearer <token>"}), built the same way a manifest post step resolves its own outbound auth. HubSpot credentials are never reachable this way — see “What is never included” below.
What is never included
The payload builder explicitly omits, and a plugin can never reach:
sources_auth— the full map of every source’s plaintext outbound credentials. Only the capability-scopedauth:<source_name>subset described above ever crosses the boundary.sources_credentials— creds-in-body fields (username/password style credentials) for any source.hubspot_client,http_client— live async HTTP client objects; not serialisable, and this is also the token-custody boundary. A plugin cannot call HubSpot with the connector’s OAuth token.identity_service,log— live service handles, not serialisable and not part of the contract.op_records— internal trace bookkeeping, not part of the plugin contract.
PluginResponse (what your handler must return)
Your handler returns a plain JSON-serialisable object. Four keys are reserved and interpreted by the runtime; every other key is yours and is returned verbatim. None of it is shown in the record’s sync event unless the step asks for it, see Reporting values in the sync event.
interface PluginResponse {
action?: "continue" | "skip" | "retry"; // default: "continue"
requeue?: boolean; // with "retry" only: wait at the back of the queue, see "Waiting for an earlier event"
outcome?: "failed"; // the target refused the record, see "Declaring a failed outcome"
reason?: string; // human-readable message for `skip`, `retry` or a failed outcome
// Kept for the life of the connector, deleted with it; ttl_seconds opts a key into expiry.
store_writes?: { key: string; value: unknown; ttl_seconds?: number }[];
[key: string]: unknown; // anything else: passed through untouched
}action value | Runtime effect |
|---|---|
"continue" (or omitted, or any value other than "skip"/"retry") | The full response object is written to ctx.outputs[step.id]; the flow proceeds to the next step. |
"skip" | The flow halts here: no more steps run for this event. A reason is appended to the step’s message in the sync event: plugin '<name>' signalled skip for flow '<flow>': <reason>. |
"retry" | Treated as a transient failure, same as an HTTP 429/5xx from a core step: up to 5 delivery attempts, after which the record goes terminal as parked (or partial_success, if an earlier step of the flow already wrote to HubSpot) and stays replayable. See Reliability and What a retry holds back below. With requeue: true the record waits at the back of the queue instead, see Waiting for an earlier event. |
The entire response dict is written to ctx.outputs[step.id], not just a data sub-object — there is no required top-level wrapper key. A later manifest step reads any field of it with $<step_id>.<field>. For example, if your plugin’s step id is generate_doc and it returns {"action": "continue", "doc_url": "https://..."}, a later step reads it as $generate_doc.doc_url:
{"id": "write_doc_url", "action": "update_by_id", "target": "hubspot", "entity": "deal",
"object_id": "$.payload.object.id",
"data": {"doc_url": "$generate_doc.doc_url"}}What a retry holds back
A retry is not free for the rest of the connector. While an event waits for its next attempt, the other events of the same connector wait behind it: they are processed only after the retried one runs again. With the default backoff the waits are 10, 20, 40 and 80 seconds, so a plugin that answers "retry" on every attempt holds the connector’s other events back for about two and a half minutes before the record ends parked; a connector with its own backoff settings waits accordingly.
Return "retry" only for a condition that is likely to clear within that window, such as a target that is briefly unavailable. When the record waits for an earlier event of the same connector (an order whose customer has not synced yet), return "retry" with requeue: true instead, see Waiting for an earlier event. For a dependency that may take much longer and does not travel through the connector (a document that is generated in minutes elsewhere), declare a failed outcome and replay the record once the dependency is there, rather than retrying in a loop.
Waiting for an earlier event
A record that depends on another event of the same connector cannot be helped by a retry in place: the event it needs may be queued right behind it, and it waits behind the retried record. Answer instead:
{"action": "retry", "requeue": true, "reason": "customer C-17 not synced yet"}The record goes to the back of its connector’s queue and runs again when its turn comes round, so the events queued behind it, the one it waits for included, run first. Each trip round the queue is a lap:
| Laps | Up to 10 per record. |
| Shortest lap | 30 seconds. A record that comes back sooner, on a connector with little traffic, waits out the rest before running again, so ten laps cover at least five minutes. Under load each lap lasts as long as the backlog in front of it. |
| Between laps | Nothing is written to GET /api/connectors/{connector_id}/sync-events: the record shows only once it settles. |
| Laps spent | The record ends parked (or partial_success, if a lap already wrote to HubSpot) with error_struct.error_code plugin_requeue_exhausted, and an error_detail that names the laps spent and your reason. It stays replayable, and a replay starts with a full set of laps. |
The rules:
- Only the boolean
truecounts, and only next to"action": "retry". Any other value ("true",1) is ignored: the response is an ordinary"retry"and a structured warning is logged server-side.requeuenext to any otheractionis ignored silently. - The record goes round the queue only when every flow of the event that failed asked for it. If another flow of the same event failed for another reason, the event retries in place as an ordinary
"retry", and its row may carry the error codeplugin_requeueshould it end there. - On the next lap only the flows that asked run again. Flows of the same event that already completed are not repeated.
- A lap does not count as a failure towards the flow’s circuit breaker. A record that spends all its laps counts as one exhausted retry, like a
"retry"that spends its five attempts. requeueapplies to events processed one at a time. In a bulk import it is ignored and the response is an ordinary"retry".
What it costs: order. A record that goes round the queue no longer runs in its original position. A later update of the same record, sent after it, can run first, and the requeued copy then runs with the older payload. Two settings guard against this when it matters: conflicts.reject_stale_source discards an event older than what HubSpot already holds, and under the most_recent conflict strategy each property keeps the most recent value. The intended use, “create the dependency first”, is usually safe as it is: the requeued copy is the one that carries the creation.
If your tooling replays records that did not sync, include parked as well as failed in its filter: a record that spent its laps ends parked.
store_writes
A list of {key, value} pairs (literal keys, not $... expressions — resolve any dynamic key yourself before returning it). Applied only if the plugin holds the store: ["write"] capability; if store_writes is present in the response but the capability isn’t granted, the writes are dropped and a structured warning is logged server-side (not an error — your flow still completes).
{"action": "continue", "doc_url": "https://...",
"store_writes": [{"key": "12345", "value": {"doc_url": "https://..."}}]}Expiry. Keys have no default expiry: a key persists for the life of the connector and is deleted when the connector is deleted.
An entry may carry an optional ttl_seconds (a positive number) to make that key expire, for example {"key": "lock:12345", "value": true, "ttl_seconds": 3600}: the key expires that many seconds after the write. Each write sets the expiry again, so a later write of the same key without ttl_seconds makes it permanent again. Reading a key through store_keys does not change its expiry, and store_writes in a skip or retry response are not applied. Once a key has expired it reads exactly like a key that was never written. A ttl_seconds that is not a positive number is ignored (the key is written with no expiry) and a structured warning is logged server-side.
See generate-doc-walkthrough.md for the complete worked handler this pattern is drawn from.
Declaring a failed outcome
Sometimes your plugin reaches its target and the target answers “no”: the document service has no template for the record’s country, the downstream API rejects the payload with a 4xx. Nothing is broken, and a retry would get the same answer, but the record did not sync. Return outcome: "failed" with a reason to say so:
export async function handler(event: PluginPayload): Promise<PluginResponse> {
const res = await sendOffer(event.payload);
if (!res.ok) {
return { outcome: "failed", reason: `offer service: ${res.status} ${res.message}`,
sync_error: res.message };
}
return { offer_url: res.url };
}What happens next:
- The flow keeps running. The steps after the plugin still execute, and the whole response is in the step’s output as usual, so a later step can write the error back to the source record, e.g.
"data": {"sync_error": "$send_offer.sync_error"}on anupdate_by_idstep.store_writesare applied as usual. - The record closes as not synced. Once the flow ends, the record’s sync event is
failed, even if a later step (the error write-back) wrote to HubSpot. The error message isplugin '<name>' reported a failed outcome: <reason>and the structured error carries the codeplugin_outcome_failedwith the plugin name and yourreason. The record shows up among the connector’s failed events and can be replayed from the dashboard like any other failure. - It does not count against the flow’s health. A declared failed outcome is a business answer from your target, not a fault in the flow: it does not count toward the failure rate or the streaks that open a flow’s circuit breaker, and it is not reported as a discarded record.
If the flow fails for a real reason as well (a later step errors, a transient failure runs out of retries), that failure takes over and the record is handled exactly as it would be without your declaration.
Rules:
outcomeis read only when the flow continues, that is for anyactionother than"skip"and"retry"(see the table above). Withaction: "skip"or"retry"it is ignored:skiphalts the flow andretryretries it, exactly as without it."failed"is the only value with an effect. A missingoutcome, or any other value, leaves the step successful, exactly as before.reasonis optional; without it the message isplugin '<name>' reported a failed outcome. A non-stringreasonis converted to text.- Use
outcome: "failed"when a retry cannot help. When the target is temporarily unavailable, returnaction: "retry"instead.
Reporting values in the sync event
By default nothing your plugin returns appears in the record’s sync event: the step’s step_trace entry carries its status and, on a failure, its message. To see a counter or a cursor there, name the response keys on the step, in plugin.config.trace_output:
{"id": "pull_page", "action": "plugin",
"plugin": {"logical_name": "acme_poller",
"config": {"trace_output": ["processed", "cursor"]}}}If the handler returns {"processed": 12, "cursor": "p-17", "rows": [...]}, the step’s entry in the sync event reads:
{"step_id": "pull_page", "action": "plugin", "status": "success",
"output": {"processed": 12, "cursor": "p-17"}}Rules:
- Only top-level keys of the response, and only the ones you list. Up to 20 keys; a malformed list is rejected when the draft is saved.
- Only numbers, booleans and strings are reported. A string longer than 200 characters is cut to 200. An object or a list is never reported: return a count or an id instead.
- The reported values of one step add up to at most 2 KB; keys are taken in the order you list them, and the ones that do not fit are left out.
- Whenever a listed key was cut or left out, the entry also carries
"output_truncated": true. The step never fails because of it. - A listed key the response does not carry, or carries as
null, is simply absent. - The values are reported on a failed outcome too. A response with
action: "skip"or"retry", or a handler that throws, reports none. - The same holds when a batch of events runs through the flow in bulk: each record’s sync event carries the step’s entry with the values you listed, under the same limits.
These values are stored with the sync event and are readable by everyone who can read the connector’s sync events, for as long as the event is kept, which is longer than a failed event’s payload is. List counters, cursors and technical ids. Do not list a key that holds a name, an email address, a phone number or any other personal data: for that kind of detail use your function’s logs (plugsync plugin logs).
Execution limits
Every plugin runs with the same fixed limits. They are not configurable per plugin today.
| Limit | Value | What happens when it is exceeded |
|---|---|---|
| Run time per invocation | 25 seconds | The invocation is stopped and treated like an uncaught exception: the event is not retried and the record ends failed, or partial_success if an earlier step of the flow already wrote to HubSpot (see Errors). |
| Memory | 256 MB | Same as a timeout: the invocation fails and the record ends failed (or partial_success). |
| Runtime | Node.js 20 | - |
Size of the PluginPayload sent to your handler | 6 MB, serialized | The invocation is refused before your code runs, and the step fails. |
Size of the PluginResponse your handler returns | 6 MB, serialized | The invocation fails and the record ends failed (or partial_success). |
| Size of the pushed bundle | 50 MB, zipped | The push is accepted, but the deploy fails: plugsync plugin status shows error with the reason. |
Keep well below these values: the payload and response limits include the
JSON encoding, and a plugin that is close to the time limit on a quiet day
will cross it when the service it calls slows down. For work that can take
longer, return {"action": "retry", ...} and let the event come back later
rather than waiting inside the handler, keeping in mind that the connector’s
other events wait for that retry too (see
What a retry holds back).
Errors
An uncaught exception in your handler (Lambda FunctionError) is treated as a permanent failure: the event is not retried, and the record goes terminal as failed, or as partial_success when an earlier step of the same flow had already written to HubSpot (the write landed, so the row says so) — see Reliability. If a failure should be retried instead, catch it and return {"action": "retry", ...} explicitly.
A transport failure on the invoke itself (connection reset, connect or read timeout, endpoint unreachable) is the opposite case: your code never ran, so it counts as transient and the event is retried.