Extending Flowcrafter
How to write Flowcrafter node handlers that survive retries, stay inside the callback budget, replay side effects safely, and behave in builder tests.
Every workflow node runs as an Action Scheduler job. That gives Flowcrafter durability — a run picks up where it left off after a crash or a deploy — but it also means your handler can be called more than once for the same node. If your node charges a card, sends an email, or writes to a remote system, running twice can’t quietly turn into two charges.

This page is the contract for third-party node handlers: what Flowcrafter guarantees, what you have to guarantee, and the concrete APIs that get you there. If you haven’t read Extending Flowcrafter yet, start there — this page assumes you know how a node handler is registered and what execute() returns.
Flowcrafter and your integration each own a side of the safety line. Flowcrafter protects the workflow; you protect the side effect.
| Flowcrafter protects | Your integration protects |
|---|---|
| Workflow state and downstream dispatch intent survive queue failures. | A remote charge, email, order, or update can safely replay. |
| A completed node cannot be claimed as fresh work again. | A crash after the remote service succeeds but before local completion does not create a duplicate. |
| Run-level retry timing and retry limits. | Which failures are temporary, which are permanent, and what a retry should return. |
| Loop progress between completed items. | Each loop-body side effect and any long-running producer request. |
Flowcrafter gives you at-least-once execution around failure windows: work may run again, but it shouldn’t be lost silently. It can’t make a third-party API exactly-once for you — that part is yours.
Run inspection exposes the node and dispatch identities behind a real execution. This completed sandbox run’s report is actual command output, rendered as text.

The safe pattern for any external write is the same every time. Build a stable identity for the operation, check whether that identity already finished, and only then call the remote service:
At run scope, the identity usually includes the run ID and node ID — both stable across retries of the same node. The run ID is on $run->id; {{run.id}} and {{run.key}} are also exposed through the runtime context.
Inside a loop, add the loop node ID and the iteration index so each item gets its own identity. Those live in the runtime context under loop, keyed by the loop node’s ID:
$ctx = $run->get_runtime_context();$loop = $ctx['loop'][ $loop_node_id ] ?? null;// $loop === [ 'index' => 0, 'number' => 1, 'count' => 5, 'remaining' => 4 ]
$identity = sprintf( 'run:%d|node:%s', $run->id, $node['id'] );if ( $loop !== null ) { $identity .= sprintf( '|loop:%s|i:%d', $loop_node_id, $loop['index'] );}For builder tests that perform real work, base the identity on a fingerprint of the fully resolved operation — destination, method, behavior-changing headers, payload, and any side-effect settings — rather than the run/node IDs. A Quick Test re-run reuses the same run, so run/node IDs alone won’t tell two distinct test payloads apart. (More on tests below.)
{{. Resolve documented flow tags, then validate the resolved value for the target field.The identity you build decides what you’re protected against. Run and node IDs are born fresh on every run — a retry keeps them, but a full workflow rerun (the trigger fires again, or someone reopens the run over the same data) gets a brand-new run ID. So run/node identity guards duplicates inside one run; it does nothing across reruns. If the same business object must never be created twice no matter how many times the workflow runs, the identity has to come from the data, not the run.
| Requirement | Build identity from |
|---|---|
| Repeat the action intentionally on every run | Run ID + node ID (plus loop node ID and index inside a loop) |
| Never create the same business object twice across reruns | A provider/business key such as an order ID, plus the operation kind |
| Allow settings edits after a failure | A stable business key, plus a separately stored fingerprint of the resolved operation |
The fingerprint covers the resolved destination, method, behavior-changing headers, and payload — store just a hash when those values hold secrets. If the same business key ever shows up with a different fingerprint, that’s a settings edit after a failure, not a safe replay: fail with an idempotency conflict instead of sending changed work under an old key.
Inside a loop, derive the business identity from stable item data — not the run and iteration index alone — whenever a full rerun must not duplicate the remote effect. If the item has no stable key you can trust, block the node inside loops or clearly warn about loop placement in the UI and docs.
A custom parking node must carry both scope_key and park_id back to Engine::resolve_parked_node( $run_id, $node_id, $event_id, $next_nodes, $scope_key, $park_id, $output ). The executor stores them in park_data; a modern callback without them is rejected rather than guessing which visit it meant.
If you send a signed callback link inside the handler, create the identity first with Park_Occurrence::create( $run, $node['id'] ), sign both fields into the link, and include the same fields in the returned park_data. Otherwise the executor creates the identity when it parks. Reuse it for reminders, not a new UUID. The shared resolver logs output in the correct scope before publishing downstream work.
Legacy callbacks without identity work only for pre-existing, empty-scope requests without a UUID. They never resolve a new Go To pass or a retried modern request. Parking nodes still implement Pauses_Run and remain forbidden inside a For Each body.
There’s one window a local claim can’t close on its own: the worker dies after the provider accepts the request but before you save the success result. On the next attempt you can’t tell “never sent” apart from “sent, lost the receipt.” Guessing wrong either drops the work or duplicates it.
If the provider supports idempotency keys, resending with the same key does this reconciliation for you. If it doesn’t, that lookup is the only thing standing between an ambiguous crash and a double charge.
How you fail decides whether Flowcrafter tries again. Return a WP_Error and Flowcrafter treats it as retryable by default — it schedules a backoff retry until the node’s retry budget runs out. To fail immediately and permanently, put [ 'retryable' => false ] in the error data:
// Temporary — let Flowcrafter retry with backoff.return new \WP_Error( 'gateway_timeout', 'Payment gateway timed out.' );
// Permanent — no point retrying a missing credential.return new \WP_Error( 'missing_api_key', __( 'No API key configured for this node.', 'flowcrafter' ), [ 'retryable' => false ]);Run-scope retries use exponential backoff with jitter. A node’s max_retries defaults to 3 and is capped at 8; the total execution ceiling is 1 + max_retries. A zero or exhausted budget fails right away, even for a retryable error.
Loop-body failures work differently. A node running inside a For Each iteration does not get automatic backoff retries — the loop follows its own Continue or Stop policy instead. Continue records the failed item and moves on; Stop fails the run. Design loop-body side effects so that “run this item again later” is safe on its own, because a retry there means re-running the whole item, not resuming mid-handler.
| Failure | Retry? |
|---|---|
| Timeout, connection failure, rate limit, temporary 5xx | Yes |
| Local result-storage failure | Yes — after making the operation claim recoverable |
| Invalid settings, missing credentials, malformed resolved JSON | No (retryable => false) |
| Provider rejects the request as invalid | Usually no |
One handler call has to fit comfortably inside its Action Scheduler callback. The trap is pagination where every page can wait out a full HTTP timeout — bound the worst-case time, not just the page count. Your maximum pages × per-request timeout has to leave room for parsing and persistence inside the callback.
Ordinary nodes have no “continue later” result. They must finish within that bound, fail clearly, or report explicit truncation when a page ceiling leaves more data behind. For durable multi-callback work, don’t schedule Action Scheduler yourself from a handler — model the items through the core Loop node, which is built to span callbacks.
The For Each node checks elapsed time between completed items, saves its next index and counters, then continues in a later callback. That protects loop progress — it does not rescue a single slow body action or a producer that fetches every page before saving anything. Keep each iteration’s body short, and have producers save incrementally.
Flowcrafter’s own HTTP Request node routes requests through wp_safe_remote_request() — WordPress validates redirect targets against SSRF-prone hosts, not just the first URL. Follow the same rules in your own nodes:
wp_safe_remote_request() (or its safe convenience wrappers) so redirects are validated too, not only the initial host.https, then either set redirection => 0 and handle redirects yourself, or reject every non-HTTPS destination before resending.flowcrafter_node_http_request_node_max_timeout filter tunes that ceiling.limit_response_size when a response has a size ceiling. Checking strlen() after the download has already happened does nothing to protect worker memory.If you’re extending the core HTTP Request node rather than writing your own, the flowcrafter_node_http_request_node_request filter lets you rewrite the URL, headers, method, or body before the request goes out (return null to keep the node’s own request, an array to override it, or a WP_Error to fail the node). See Hooks for the full list.
Quick Test runs a node through its normal execute() — webhooks fire, notifications send, conditions evaluate. That’s usually what you want. But if testing a node should not send an email, charge a card, or create a remote record, implement the Has_Test_Mode_Override contract. When a handler implements it, Quick Test calls execute_in_test_mode() instead of execute():
use Flowcrafter\Nodes\Contracts\Has_Test_Mode_Override;use Flowcrafter\Workflow_Run;
class My_Charge_Node extends Abstract_Node_Handler implements Has_Test_Mode_Override {
public function execute( array $node, Workflow_Run $run, array $form ) { // …real charge… }
public function execute_in_test_mode( array $node, Workflow_Run $run, array $form ) { // Same return shape as execute(): next_nodes plus optional output. return [ 'next_nodes' => $this->get_next_nodes( $node ), 'output' => [ 'charge_id' => 'test_stub' ], 'test_note' => 'Charge skipped — test mode.', ]; }}execute_in_test_mode() returns the same shape as execute(): next_nodes (the IDs to run next), an optional output array, and an optional test_note string shown in the builder. Use the override for nodes whose real execute() would block indefinitely or leave side effects that stop you from re-testing (the core Wait for Payment, Wait for Approval, and Gravity Forms Feed nodes all do this).
If your override needs a choice from the tester first — the way Wait for Approval needs an approve-or-reject before it can pick a branch — read it from the run’s runtime context under test_input: $run->get_runtime_context()['test_input'] ?? []. The builder gathers it and sends it on POST /runs/{run_id}/step; the test runner forwards it untouched, so its shape is entirely yours. See 11 Test Mode.
If tests are meant to hit the live service, say so in the node’s UI and docs, and use the full resolved-operation fingerprint (see Where the identity comes from) as the idempotency key so a re-run doesn’t duplicate the side effect.
The builder lets a user select a real entry for a node test. Treat that as real execution context, not permission to send production mail or charge a card.

Before you ship a node that touches an external system, walk this list:
retryable => false.limit_response_size), not only afterward.