Extending Flowcrafter
Start workflows from external events with the transport-free External_Triggers API and the generic test-listen lifecycle.
An external trigger starts a workflow after your plugin receives an event. That might be an HTTP webhook, a queue message, or another provider callback. Your plugin owns receiving and authenticating the event; Flowcrafter owns creating the run and executing the workflow.

API Alchemist’s Webhook Trigger is a visible reference for an inbound node: it starts an example workflow and exposes its inbound controls in the node window. The URL token visible in this capture was revoked afterward and must not be reused.


Call the API after your provider has authenticated the event and resolved a saved workflow and trigger node. A request key identifies one delivery: send the same key on retries, and a new key for a genuinely new event.
$response = \Flowcrafter\External_Triggers::start( $workflow, $node_id, [ 'event_id' => $event_id, 'body' => $captured_body ], 'provider-event:' . $event_id);| Method | Purpose |
|---|---|
start(Workflow $workflow, string $node_id, array $payload, string $request_key): WP_REST_Response |
Start or deduplicate a production event. |
accept_test(int $run_id, string $node_id, array $payload): ?WP_REST_Response |
Capture one pending frozen test trigger, without fan-out. |
fail_test(int $run_id, string $node_id, Throwable $error, bool $claimed = false): void |
Claim and fail a pending test capture, or record a failure for a claim the caller already owns. |
cancel_test(int $run_id, string $node_id): void |
Cancel a still-pending test node without overwriting a completed or running capture. |
Production success returns run_id and status: accepted. A retry returns the first run’s ID and status: duplicate. Invalid or inactive workflows return 422. The existing Dispatch::TYPE_WEBHOOK_TRIGGER marker is retained so retries still find older deliveries.
The trigger’s execute() method is not called during production acceptance. Core records the supplied payload through Engine::complete_trigger() and fans out through the durable dispatch outbox. Test acceptance instead logs the output and completes only the selected trigger; the builder decides when to step downstream.
An empty array is a valid captured event. Re-running an external-trigger run preserves it just like a non-empty payload; only an absent payload is refused, such as when retention has removed the original node log.
Implement both methods of Supports_Test_Listen. The controller supplies the selected node from the test run’s frozen workflow (the copy captured when testing began), including unsaved builder settings.
public function prepare_test_listen( array $node, \Flowcrafter\Workflow_Run $run );public function stop_test_listen( array $node, \Flowcrafter\Workflow_Run $run, string $listener_id ): void;prepare_test_listen() returns a WP_Error or this generic shape. Put provider-specific display fields inside data, not at the top level:
return [ 'listening' => true, 'listener_id' => $listener_id, 'expires_at' => gmdate( 'Y-m-d\TH:i:s\Z', time() + 300 ), 'expires_in' => 300, 'data' => [ 'webhook_url' => $provider_url ],];Core records fc_test_session_{listener_id} with the run ID, node ID, and expiry. It also records the current session for that run/node and schedules flowcrafter_expire_test_session with wp_schedule_single_event(). Provider routing tokens and subscriptions belong in provider-owned storage, not in these generic records.
stop_test_listen() must revoke only the requested listener generation. Compare its ID before removing a shared routing pointer, so delayed cleanup cannot delete a replacement listener. Core calls this method on Stop, expiry, or run cancellation, then cancels a still-pending node only if the session is still current. A failed stop callback is retried. Start and cleanup share a site-scoped lock; providers must not acquire that lock themselves.
When a test event arrives:
External_Triggers::accept_test($run_id, $node_id, $payload). null means the pending claim was unavailable or the test context was invalid; do not fan out or overwrite the node yourself.fail_test() without claiming a node yourself. An ordinary sender-authentication error should normally leave the listener open for a valid event.accept_test() requires a registered Is_Trigger handler implementing Supports_Test_Listen, claims the pending node atomically, and records its output once. fail_test() cannot steal another request’s running claim unless the caller explicitly passes claimed: true for a claim it owns.
Flowcrafter does not register a public inbound HTTP route. GC API Alchemist owns Webhook Trigger’s endpoint, sender checks, payload normalization, idempotency-header selection, and token/listener transients. Its production and frozen-test evaluations are independent: a test failure must not replace a successful production response or prevent production dispatch.
Has_Inbound_Token remains useful for providers with token-bearing nodes. Core backfills tokens on save, indexes them for routing, and regenerates them when copying a workflow. If a provider is unavailable during a save, core keeps its existing indexed token for the same node/type so restoring and activating that provider restores routing without another workflow save.
Handles_Inbound_Request is an optional HTTP-provider contract. Neither it nor Has_Inbound_Token is required by External_Triggers. Non-HTTP providers can implement Is_Trigger and optionally Supports_Test_Listen without either HTTP contract.