Skip to content
FlowWP logo
Menu

Developers

Extend FlowWP with steps of your own

Add triggers, actions and conditions from your plugin through one documented class, FlowWP\SDK\FlowWP. They appear in the step library, the token picker and run history exactly like the built-in ones.

  • PHP 8.1+
  • WordPress 6.5+
  • One public class
  • Versioned contract
your-plugin.phpPHP
add_action( 'plugins_loaded', function () {    if ( ! class_exists( \FlowWP\SDK\FlowWP::class ) ) {        return; // FlowWP is not installed, or is older than the SDK.    }​    \FlowWP\SDK\FlowWP::register_node( my_step() );} );

Call the SDK whenever you like; FlowWP waits for its registries if it has to.

Extension points

Everything a plugin can add

One class to talk to, and a handful of published contracts to build with. Everything else in FlowWP is internal and may change between releases.

  • StepsFlowWP::register_node()Triggers, actions and conditions. The type is what saved workflows store, so it never changes: prefix it, like acme.action.send.
  • IntegrationsFlowWP::register_integration()Your steps under your name and icon. If your plugin is missing, its steps show greyed out with your requirement instead of vanishing.
  • ConnectionsFlowWP::register_credential_type()For services that need a key. FlowWP encrypts the values, keeps secret ones out of API responses and exports, and builds the form.
  • DropdownsFlowWP::register_option_source()Options a field loads while someone edits it, never while a workflow runs. One field can depend on another.
  • Token filtersFlowWP::register_formatter()The | shout in {{ trigger.order.total | shout }}: a named transformation authors pick in the token picker.
  • Condition operatorsFlowWP::register_operator()New comparisons for If, Filter and Switch, testing one value or comparing it with another.
  • Plan featuresFlowWP::register_feature()Gate your own steps to a plan. FlowWP’s own feature keys cannot be redefined; the attempt is refused and logged.
  • PHP functionsFlowWP::register_function()Expose a PHP function as a step with its own form, without writing a node class.

A complete example

A whole integration, one step at a time

FlowWP includes a working example plugin in examples/flowwp-acme-example/. Copy it into wp-content/plugins/, activate it beside FlowWP, and every extension point appears in the editor: the steps under “Acme”, the connection in FlowWP → Connections, the | shout filter and the “is a weekend” operator.

Nothing in it talks to a real service. The point is the shape. The samples below are condensed from it.

  1. 1

    Register everything

    Everything registers on plugins_loaded. FlowWP works out whether the registry you are adding to has been built yet, and either waits for it or adds to it now, so timing is not your problem.

    The integration groups the steps in the library. The connection type gives FlowWP → Connections a form for the Acme key; secret fields are encrypted and never sent back to the browser.

    flowwp-acme-example.phpPHP
    namespace Acme\FlowWP;​use FlowWP\SDK\FlowWP;​add_action( 'plugins_loaded', __NAMESPACE__ . '\\register' );​function register(): void {    // FlowWP is not installed, or is too old to have the SDK.    if ( ! class_exists( FlowWP::class ) ) {        return;    }​    FlowWP::register_integration( new AcmeIntegration() );​    FlowWP::register_credential_type( 'acme', array(        'label'       => 'Acme',        'description' => 'An Acme API key, for the Acme steps.',        'fields'      => array(            'apiKey' => array(                'label'    => 'API key',                'secret'   => true, // Encrypted, and never sent back to the browser.                'required' => true,                'help'     => 'In Acme under Settings → API.',            ),        ),    ) );​    FlowWP::register_nodes( array( trigger(), action(), condition() ) );}
  2. 2

    A trigger: something happened

    HookTrigger listens to an ordinary WordPress action. payload turns the action’s arguments into the values later steps use as tokens, and returning null means “not this one”.

    output_schema is what the token picker shows. matches decides which of the workflows listening to the hook actually run, using each workflow’s own settings.

    trigger()PHP
    function trigger(): HookTrigger {    return new HookTrigger(        type: 'acme.trigger.thing_happened',        icon: 'bolt',        label: 'An Acme thing happens',        description: 'Runs when Acme reports something. Optionally only above an amount.',        hook: 'acme_thing_happened',        // Turn the action's arguments into tokens. Null means "not this one".        payload: static function ( mixed $thing ): ?array {            if ( ! is_array( $thing ) ) {                return null;            }            return array(                'thing' => array(                    'id'     => (string) ( $thing['id'] ?? '' ),                    'name'   => (string) ( $thing['name'] ?? '' ),                    'amount' => (float) ( $thing['amount'] ?? 0 ),                ),            );        },        accepted_args: 1,        schema: array(            'type'       => 'object',            'properties' => array(                'minimum' => array( 'type' => 'number', 'title' => 'Only above' ),            ),        ),        // The token picker is built from this.        output_schema: array(            'type'       => 'object',            'properties' => array(                'thing' => array(                    'type'       => 'object',                    'title'      => 'Thing',                    'properties' => array(                        'id'     => array( 'type' => 'string', 'title' => 'ID' ),                        'name'   => array( 'type' => 'string', 'title' => 'Name' ),                        'amount' => array( 'type' => 'number', 'title' => 'Amount' ),                    ),                ),            ),        ),        // Which of the workflows listening to this hook should actually run.        matches: static function ( array $config, array $payload ): bool {            $minimum = trim( (string) ( $config['minimum'] ?? '' ) );            return '' === $minimum || (float) ( $payload['thing']['amount'] ?? 0 ) >= (float) $minimum;        },    );}
  3. 3

    An action: do something

    The three booleans are not documentation: the engine acts on them. writes makes a test run rehearse the step instead of doing it; a retry happens only when a step is both idempotent and retryable.

    Connection values are decrypted inside run() and nowhere else, and a failure message says what to do next, because whoever reads it in a run log usually did not write the step.

    action()PHP
    function action(): CallbackAction {    return new CallbackAction(        type: 'acme.action.send',        icon: 'send',        label: 'Send something to Acme',        description: 'Posts a note to a project in Acme.',        category: NodeCategory::Action,        writes: true,      // A test run rehearses it instead of doing it.        idempotent: false, // Running it twice would post twice...        retryable: false,  // ...so the engine must not repeat it.        schema: array(            'type'       => 'object',            'required'   => array( 'connection', 'project', 'note' ),            'properties' => array(                'connection' => array( 'type' => 'string', 'title' => 'Acme connection', 'x-options' => 'connections.acme' ),                'project'    => array( 'type' => 'string', 'title' => 'Project', 'x-options' => 'acme_projects', 'x-options-parent' => 'connection' ),                'note'       => array( 'type' => 'string', 'title' => 'Note', 'format' => 'textarea' ),            ),        ),        output_schema: array(            'type'       => 'object',            'properties' => array(                'noteId' => array( 'type' => 'string', 'title' => 'Note ID' ),            ),        ),        // What a test run shows instead of posting.        preview: static fn ( ExecutionContext $context ): array => array( 'noteId' => 'preview-only' ),        run: static function ( ExecutionContext $context ): NodeResult {            $id     = absint( (string) $context->get( 'connection', '' ) );            $values = 0 < $id ? $context->credential( 'acme', $id ) : null; // Decrypted here only.            $key    = is_array( $values ) ? trim( (string) ( $values['apiKey'] ?? '' ) ) : '';​            if ( '' === $key ) {                return NodeResult::failure(                    'acme_no_connection',                    'The step’s Acme connection is missing or can no longer be read. Choose one in FlowWP → Connections.'                );            }​            // Tokens in the note are already resolved by the time get() returns.            $note = trim( (string) $context->get( 'note', '' ) );​            if ( '' === $note ) {                return NodeResult::failure( 'acme_empty_note', 'The note resolved to an empty value, so nothing was sent.' );            }​            // A real step would call Acme here.            return NodeResult::success( array( 'noteId' => 'acme-' . wp_generate_uuid4() ) );        },    );}
  4. 4

    A condition: branch on it

    CallbackCondition returns matched to pick the True or False branch. Anything else it returns becomes a token on both branches, and returning a NodeResult fails the step outright.

    condition()PHP
    function condition(): CallbackCondition {    return new CallbackCondition(        type: 'acme.condition.is_known',        icon: 'check',        label: 'Acme knows this project',        description: 'True when the project exists in Acme. Use the False branch for everything else.',        schema: array(            'type'       => 'object',            'required'   => array( 'project' ),            'properties' => array(                'project' => array( 'type' => 'string', 'title' => 'Project' ),            ),        ),        output_schema: array(            'type'       => 'object',            'properties' => array(                'known' => array( 'type' => 'boolean', 'title' => 'Known to Acme' ),            ),        ),        // 'matched' picks the branch; anything else becomes a token on both branches.        test: static function ( ExecutionContext $context ): array|NodeResult {            $known = in_array( trim( (string) $context->get( 'project', '' ) ), array( 'alpha', 'beta' ), true );            return array( 'matched' => $known, 'known' => $known );        },    );}

Writing a step well

The details the engine acts on

The three booleans on an action
FlagWhat it meansGet it wrong and
writesThis changes somethingA test run in the editor does it for real
idempotentRunning twice lands where running once did—
retryableThe engine may repeat this after a failureA timeout that actually delivered sends twice

A retry is only safe when both idempotent and retryable are true. A sent message is neither. A write keyed on an email address is both.

  • Failure messages are read by whoever has to fix it

    Say what to do next — “Choose one in FlowWP → Connections” — not “credential error”.

  • The output schema is the token picker

    A value you return but do not declare works in a token somebody types by hand, and is invisible to everyone else.

  • Give anything that writes a preview

    It is what a test run shows instead of doing it.

Your own PHP

Offer PHP functions as steps

There is no “Run PHP” box in FlowWP, because a box anyone with the editor capability can type into is a box an attacker who takes that account can type into. Instead, a site offers functions from PHP, and each becomes a step with its own form.

A workflow stores only the function’s name; the callable comes from the site at run time. An imported workflow naming exec gets a step that does not exist, not a shell.

Your own functions as steps are part of the Agency plan. Below it they are still listed, locked, so a site can see what its workflows depend on. Steps that other plugins register work on every plan.

What your function returns becomes the step’s output
An associative arrayThose fields, as named
A list, a string, a number, a boolean, nullvalue
A WP_ErrorThe step fails with its message
An object, a resource, or anything containing oneThe step fails: output is stored as JSON
functions.phpPHP
add_filter( 'flowwp_functions', function ( array $functions ) {    $functions['order_risk_score'] = array(        'label'       => 'Score an order for risk',        'description' => 'Our own rules, 0 to 100.',        'callback'    => 'acme_order_risk_score',        'writes'      => false, // A pure read: test runs really run it.        'fields'      => array(            'orderId' => array( 'label' => 'Order ID', 'type' => 'integer', 'required' => true ),            'strict'  => array( 'label' => 'Strict mode', 'type' => 'boolean' ),        ),        'returns'     => array(            'score'   => 'number',            'reasons' => 'array',        ),    );​    return $functions;} );​function acme_order_risk_score( array $arguments ): array {    $order = wc_get_order( $arguments['orderId'] );​    return array(        'score'   => $order ? 72 : 0,        'reasons' => array( 'new customer', 'large order' ),    );}

The step appears under Tools → Your functions with an Order ID field and a Strict mode checkbox, and later steps read {{ nodes.<id>.output.score }}. A function that writes is rehearsed on test runs; declare 'writes' => false for a pure read.

Hooks

Listen to what FlowWP does

The SDK covers registration. These WordPress actions and filters are for listening and adjusting.

HookWhen
flowwp_bootedFlowWP is ready; the container exists
flowwp_workflow_saved / _activated / _deactivatedA workflow changed
flowwp_execution_created / _running / _waiting / _finishedA run moved
flowwp_node_executedOne step finished
flowwp_credential_deletedA connection was removed: clean up anything you keyed to it
flowwp_test_credentialCheck a connection of your own type when it is saved
flowwp_node_availableOverride whether a step is usable on this site
flowwp_validate_graphAdd your own validation to a workflow

No PHP needed

Connect other systems with webhooks and HTTP

A webhook trigger gives a workflow its own address that any app can call. The HTTP Request step calls any API with headers, parameters and a body, and hands the response to later steps.

Versioning

A contract that tells you when it changes

FlowWP::SDK_VERSION is the contract version. It changes on a breaking change to the published surface and never on an internal refactor. Check it if you rely on something added recently.

your-plugin.phpPHP
if ( version_compare( \FlowWP\SDK\FlowWP::SDK_VERSION, '1.0.0', '<' ) ) {    return;}

Know the limits

What the SDK does not do

  • It does not sandbox you.

    Your step runs in the site’s own process with the site’s own permissions. FlowWP validates configuration against your schema and redacts credentials from logs; everything else is yours.

  • It does not version your node types.

    Changing a type orphans saved workflows. Add a new step and leave the old one registered.

  • It does not make your steps free.

    A step with no feature runs on every plan including Free. That is a decision, so make it deliberately.

FAQ

Developer questions

Plan rules, security and compatibility, answered from FlowWP’s own documentation.

See every integration
Do steps from my plugin need a paid FlowWP plan?

No. Steps other plugins register work on every plan, and a step with no feature key runs on every plan including Free. If you want a step on a paid plan only, declare your own feature with register_feature(). A site’s own PHP functions offered as steps are part of the Agency plan.

Can a workflow run arbitrary PHP?

No. There is no “Run PHP” box. A site offers functions to FlowWP from PHP, a workflow stores only the function’s name, and the callable comes from the site at run time.

Will my steps break when FlowWP updates?

The published contracts do not change incompatibly without FlowWP::SDK_VERSION changing with them. Everything outside them is internal. Never rename a step’s type: saved workflows store it.

How are API keys for my service stored?

Declare a connection type with register_credential_type(). FlowWP encrypts the values, never sends secret fields back to the browser, and keeps them out of API responses and workflow exports. Your step reads them with $context->credential().

How do I test a step that changes something?

Mark it writes: true and give it a preview. A test run in the editor then shows what the step would have done instead of doing it.

When should I call the SDK?

Whenever you like: from your plugin’s main file, on plugins_loaded, or in a callback of your own. FlowWP works out whether the registry you are adding to exists yet and waits for it if it has to.

Build your WordPress workflow visually.

Start with a template or a blank canvas. Nothing runs until you activate it.