Skip to main content

Result Handlers & the Completion Transaction

The resultHandler is where your plugin turns a finished workflow into persisted domain data. It's the most involved of the four extension points, and the most important to get exactly right — because it runs inside the core's completion transaction, and a mistake there can roll back a whole run.

When and how the core calls it

When a workflow completes, n8n POSTs to /complete with a result payload. The core then runs a completion transaction:

  1. It looks up your resultHandler (via the entity slug) and calls it, passing: workflowExecutionId, workspaceId (the original workspace), resultPayload (whatever the workflow submitted), createsNewWorkspace and resultingStatusId (from the frozen snapshot), and a client — the transaction's DB client.
  2. If your handler succeeds, the core transitions the execution to completed and updates the workspace status to the resulting status (when not creating a new workspace).
  3. If your handler throws, the core rolls back everything — including your writes — and marks the execution failed_permanent.

The transaction holds a FOR UPDATE lock on the execution row, so concurrent /complete calls block; your handler is guaranteed to run exactly once per execution.

The rules you must follow

These aren't style preferences — they keep completion atomic:

  • Use params.client for every DB operation. Do not call getClient(), and do not issue BEGIN / COMMIT / ROLLBACK — the core owns the transaction. Using a different connection would escape the transaction and break atomicity.
  • Do not update workspace status. The core does that after you return, using the workflow's resulting_status_id. (Even an accidental status write would be rolled back on failure — but don't attempt it.)
  • Write only to plugin-owned tables (plus core workspaces via coreWorkspaceService). Never write directly to workflow_execution, workflow_execution_log, or other core tables.
  • Validate the whole payload first. Reject the entire result if any part is invalid, rather than persisting a partial one.

The two branches

Your handler behaves differently based on createsNewWorkspace:

  • false — persist your artifacts against the original workspace and return. The core advances that workspace's status.
  • true — create a new workspace with coreWorkspaceService.create({ workspaceStatusId: resultingStatusId }, client), add a join row linking it to your entity, persist artifacts against the new workspace, and update your entity's active_workspace_id. The core leaves the original workspace's status unchanged (the new one already has the right status).

This is how a workflow like News Revision produces a fresh branch while leaving the original intact for comparison.

Why the transaction design matters

Handing you a shared transaction is what makes completion trustworthy: your domain persistence and the core's status advancement commit together or not at all. There's no window where the platform thinks a run completed but your data didn't save, or vice versa. Respect the client, don't touch status, validate up front — and completion is atomic by construction.