Skip to Content
ReferencePluginsPayload contract

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.js must export a named handler function, not a default export:

    export async function handler(event: PluginPayload): Promise<PluginResponse> { // ... }
  • The CLI bundles src/index.ts with esbuild --bundle --platform=node --target=node18 --format=cjs, which turns export async function handler(...) into exports.handler = ... — exactly what index.handler resolves. A export default entrypoint will bundle fine but Lambda will fail to find the handler export at invoke time.

    plugsync plugin init scaffolds a stub that already matches this: a named export async function handler(event), with no import (there is no @plugsync/sdk package — 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’s event/return value, no dependency required.

  • The same handler also receives the plugin’s lifecycle hooks (setup / teardown), whose payload is not a PluginPayload: it has a hook_type and no payload. Branch on hook_type first, see lifecycle-hooks.md.

  • There is no per-plugin package.json/node_modules install step server-side: esbuild --bundle inlines whatever you import/require from 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 }
FieldAlways presentNotes
schema_versionyes1 today. Bump signals a breaking payload shape change.
connector_idyesThe connector this event is running under.
flow_nameyesThe name of the flow this step belongs to.
source_nameyesThe EventSource name that produced this event.
event_typeyesThe 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_idyes (nullable)Message-level external id from the event envelope, distinct from payload.
payloadyesThe inbound event body, exactly as $.payload resolves it in the manifest.
canonicalyesThe shared canonical dict a transform step (if any, earlier in the flow) wrote into.
outputsyesEvery 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_indexyesThe connector’s resolved entities (entity name -> schema config), no secrets.
sources_indexyesEvery 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.
itemyes (nullable)Set on per-item execution paths (batch/many actions); null otherwise.
capabilitiesyesThis 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.
configyesThe 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": "..."}.
storeonly with store: ["read"]Pre-loaded key/value snapshot — see below.
authonly with a non-empty auth grantResolved 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-scoped auth:<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 valueRuntime 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:

LapsUp to 10 per record.
Shortest lap30 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 lapsNothing is written to GET /api/connectors/{connector_id}/sync-events: the record shows only once it settles.
Laps spentThe 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 true counts, 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. requeue next to any other action is 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 code plugin_requeue should 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.
  • requeue applies 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 an update_by_id step. store_writes are 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 is plugin '<name>' reported a failed outcome: <reason> and the structured error carries the code plugin_outcome_failed with the plugin name and your reason. 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:

  • outcome is read only when the flow continues, that is for any action other than "skip" and "retry" (see the table above). With action: "skip" or "retry" it is ignored: skip halts the flow and retry retries it, exactly as without it.
  • "failed" is the only value with an effect. A missing outcome, or any other value, leaves the step successful, exactly as before.
  • reason is optional; without it the message is plugin '<name>' reported a failed outcome. A non-string reason is converted to text.
  • Use outcome: "failed" when a retry cannot help. When the target is temporarily unavailable, return action: "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.

LimitValueWhat happens when it is exceeded
Run time per invocation25 secondsThe 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).
Memory256 MBSame as a timeout: the invocation fails and the record ends failed (or partial_success).
RuntimeNode.js 20-
Size of the PluginPayload sent to your handler6 MB, serializedThe invocation is refused before your code runs, and the step fails.
Size of the PluginResponse your handler returns6 MB, serializedThe invocation fails and the record ends failed (or partial_success).
Size of the pushed bundle50 MB, zippedThe 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.

Last updated on