The hybrid split above is right, so I’ll only add the parts that bite in production once you’ve built it, because two of them cut against advice already in the thread.
First, the centralized Error Trigger workflow will not catch the worker crash you listed. The Error Trigger fires when an execution finishes in an errored state, which means the run has to survive long enough to record that it failed. A worker that gets OOM killed or has its container evicted dies before it can write that terminal state, so the execution is left hanging as running or crashed and no Error Trigger ever fires. The centralized workflow is the right home for logging and alerting, but it only ever sees failures orderly enough to report themselves, and a hard crash is not one of them.
That points at the bigger gap: the class of failure that produces no execution at all. Your JSON schema and a state table can only record runs that started. The scheduled trigger that silently stops firing, the workflow someone left deactivated, the webhook whose registration was lost on a restart, the queue that stopped being consumed because the only worker died: none of those create an execution, so none create an error row, and your monitoring shows zero failures. Zero failures reads identical to a clean day and to a workflow that has been dead for six hours. The only thing that catches it is an expectation held outside n8n. Each successful run writes a heartbeat, a last-success timestamp per workflow, and a separate cheap check alarms when a workflow that should have run in the last N minutes has not. That is absence detection, and it is a different mechanism from everything else in the thread because it fires on the absence of a row rather than the content of one.
Second, on idempotency, one correction to the pattern above: do not key it on the webhook execution ID. A recovery run is a new execution with a new execution ID, so keying on that means the recovery cannot recognise the original and your upsert cannot dedupe it. The key has to come from the business payload, something stable across every retry of the same logical request, and it has to be written before the external call, not after. That way a crash between the API call and the Save Result step still leaves a record that the action was attempted. Otherwise the dangerous case is the one that looks clean: the External API Call succeeds, the worker dies before Save Result, there is no failed row anywhere, and recovery cheerfully re-runs an action that already happened.
For what it’s worth, this kind of production hardening, the monitoring layer and the absence detection especially, is what I do for people running n8n in production, so if you’d want a hand turning it into something you can actually rely on, happy to talk. Either way the heartbeat is the piece I’d build first.