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:
- It looks up your
resultHandler(via the entity slug) and calls it, passing:workflowExecutionId,workspaceId(the original workspace),resultPayload(whatever the workflow submitted),createsNewWorkspaceandresultingStatusId(from the frozen snapshot), and aclient— the transaction's DB client. - If your handler succeeds, the core transitions the execution to
completedand updates the workspace status to the resulting status (when not creating a new workspace). - 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.clientfor every DB operation. Do not callgetClient(), and do not issueBEGIN/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 toworkflow_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 withcoreWorkspaceService.create({ workspaceStatusId: resultingStatusId }, client), add a join row linking it to your entity, persist artifacts against the new workspace, and update your entity'sactive_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.