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
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.
- Steps
FlowWP::register_node()Triggers, actions and conditions. The type is what saved workflows store, so it never changes: prefix it, like acme.action.send. - Integrations
FlowWP::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. - Connections
FlowWP::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. - Dropdowns
FlowWP::register_option_source()Options a field loads while someone edits it, never while a workflow runs. One field can depend on another. - Token filters
FlowWP::register_formatter()The | shout in {{ trigger.order.total | shout }}: a named transformation authors pick in the token picker. - Condition operators
FlowWP::register_operator()New comparisons for If, Filter and Switch, testing one value or comparing it with another. - Plan features
FlowWP::register_feature()Gate your own steps to a plan. FlowWP’s own feature keys cannot be redefined; the attempt is refused and logged. - PHP functions
FlowWP::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
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
A trigger: something happened
HookTriggerlistens to an ordinary WordPress action.payloadturns the action’s arguments into the values later steps use as tokens, and returningnullmeans “not this one”.output_schemais what the token picker shows.matchesdecides 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
An action: do something
The three booleans are not documentation: the engine acts on them.
writesmakes a test run rehearse the step instead of doing it; a retry happens only when a step is bothidempotentandretryable.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
A condition: branch on it
CallbackConditionreturnsmatchedto pick the True or False branch. Anything else it returns becomes a token on both branches, and returning aNodeResultfails 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
| Flag | What it means | Get it wrong and |
|---|---|---|
| writes | This changes something | A test run in the editor does it for real |
| idempotent | Running twice lands where running once did | — |
| retryable | The engine may repeat this after a failure | A 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.
| An associative array | Those fields, as named |
|---|---|
| A list, a string, a number, a boolean, null | value |
| A WP_Error | The step fails with its message |
| An object, a resource, or anything containing one | The step fails: output is stored as JSON |
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.
| Hook | When |
|---|---|
| flowwp_booted | FlowWP is ready; the container exists |
| flowwp_workflow_saved / _activated / _deactivated | A workflow changed |
| flowwp_execution_created / _running / _waiting / _finished | A run moved |
| flowwp_node_executed | One step finished |
| flowwp_credential_deleted | A connection was removed: clean up anything you keyed to it |
| flowwp_test_credential | Check a connection of your own type when it is saved |
| flowwp_node_available | Override whether a step is usable on this site |
| flowwp_validate_graph | Add 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.
- Webhooks & HTTP RequestsHow receiving and sending works, and what each plan includes.
- Webhooks & Schedules stepsEvery trigger and action, with the data each one passes on.
- Data mappingTokens and the picker that moves values between steps.
- A webhook templateCapture leads from any app with a webhook: a ready-made workflow to start from.
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.
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.
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.