Developer resources
The plugin is extensible end to end with PHP alone. Actions, categories, triggers, integrations, conditions, placeholders, settings tabs, REST routes and delivery channels are all registrable through WordPress filters — with no core edits and no JavaScript.
There are two equivalent paths:
- Helpers (recommended) — global
joinotify_register_*()functions, loaded with the plugin. - Filters — the
apply_filters()hooks underneath, when you need full control.
Register your extensions on a normal hook such as plugins_loaded or init.
The usual caveat for production code applies: back up the site before installing custom PHP code. UpdraftPlus does it for free.
Runtime helpers
Callable from anywhere — inside an action handler, your own hook, a REST callback.
| Helper | What it does |
|---|---|
joinotify_send_whatsapp_message_text( $sender, $receiver, $message, $delay ) | Sends a text message; returns the HTTP code |
joinotify_send_whatsapp_message_media( $sender, $receiver, $media_type, $media, $caption, $delay ) | Sends a media message; returns the HTTP code |
joinotify_dispatch_notification( $args ) | Sends through any registered channel; returns a Channel_Result |
joinotify_replace_placeholders( $message, $payload, $mode ) | Resolves the {{ … }} placeholders against a payload |
joinotify_prepare_message( $message, $payload ) | Resolves placeholders and AI tokens |
joinotify_convert_html_to_whatsapp( $message ) | Converts rich text HTML to WhatsApp formatting |
joinotify_prepare_receiver( $receiver, $payload ) | Resolves placeholders and normalises the phone number |
joinotify_format_phone( $phone ) | Validates and formats a phone number |
joinotify_get_placeholders( $integration, $trigger, $context ) | Lists the available placeholders |
joinotify_get_senders() | Lists the registered senders |
joinotify_get_first_sender() | The first connected sender |
joinotify_is_valid_sender( $sender ) | Checks whether the sender is allowed |
joinotify_get_setting( $key ) | Reads a plugin setting |
joinotify_get_message_history( $args ) | Queries the message history |
joinotify_get_workflows( $args ) | Lists workflows |
joinotify_get_workflow_content( $post_id ) | A workflow's content (node tree) |
joinotify_get_workflow_context( $post_id ) | The workflow context, e.g. woocommerce |
joinotify_find_workflow_item( $content, $item_id ) | Finds a node inside the workflow |
joinotify_workflow_has_content( $post_id, $type ) | Checks whether the workflow has nodes of a type |
joinotify_proxy_api_text_message_text_endpoint() | Text endpoint URL of the Proxy API |
joinotify_proxy_api_media_message_text_endpoint() | Media endpoint URL of the Proxy API |
joinotify_get_proxy_api_key() | The configured Proxy API key |
// Resolve placeholders against the payload and send from the first connected sender.
$text = joinotify_replace_placeholders( '{{ wc_billing_first_name }}, your order is on its way!', $payload );
$sender = joinotify_get_first_sender();
if ( $sender && joinotify_is_valid_sender( $sender ) ) {
joinotify_send_whatsapp_message_text( $sender, joinotify_format_phone( '11999998888' ), $text );
}
Registering extensions
| Helper | Underlying filter |
|---|---|
joinotify_register_action_category( $category ) | Joinotify/Builder/Action_Categories |
joinotify_register_action( $definition ) | Joinotify/Builder/Actions |
joinotify_register_action_handler( $slug, $cb ) | Joinotify/Workflow_Processor/Handle_Actions |
joinotify_register_action_description( $slug, $cb ) | Joinotify/Builder/Action_Description |
joinotify_register_integration( $integration ) | Joinotify/Settings/Tabs/Integrations |
joinotify_register_trigger( $context, $trigger ) | Joinotify/Builder/Get_All_Triggers |
joinotify_dispatch_trigger( $hook, $integration, $payload ) | runtime dispatch |
joinotify_register_conditions( $trigger, $conds ) | Joinotify/Validations/Get_Action_Conditions |
joinotify_register_condition_operators( $type, $ops ) | Joinotify/Conditions/Check_Condition_Type |
joinotify_register_condition_value( $type, $cb ) | Joinotify/Conditions/Get_Compare_Value |
joinotify_register_placeholders( $integration, $ph ) | Joinotify/Builder/Placeholders_List |
joinotify_register_dynamic_placeholder( $regex, $cb ) | Joinotify/Builder/Resolve_Dynamic_Token |
joinotify_register_settings_tab( $tab ) | Joinotify/Admin/Settings/Section_Tabs |
joinotify_register_settings_section( $section ) | Joinotify/Admin/Settings/Schema |
joinotify_register_rest_route( $route ) | Joinotify/Rest/Routes |
joinotify_register_notification_channel( $id, $class ) | Joinotify/Notifications/Channels |
A custom action
An action is a step the user drags onto the canvas. With settings_schema declared, the node's
settings form is rendered generically — no frontend work.
joinotify_register_action( array(
'action' => 'my_app_send_sms',
'title' => __( 'My App: send SMS', 'my-textdomain' ),
'description' => __( 'Send an SMS through My App.', 'my-textdomain' ),
'category' => 'my_app',
'category_label' => __( 'My App', 'my-textdomain' ), // creates the tab automatically
'has_settings' => true,
'is_expansible' => true, // allows chaining actions after this one
'context' => array(), // empty = available on every trigger
'default_data' => array(
'action' => 'my_app_send_sms',
'title' => __( 'My App: send SMS', 'my-textdomain' ),
'to' => '{{ wc_billing_phone }}',
'message' => '',
),
'settings_schema' => array(
array( 'key' => 'to', 'label' => __( 'Recipient', 'my-textdomain' ), 'component' => 'input', 'required' => true ),
array( 'key' => 'message', 'label' => __( 'Message', 'my-textdomain' ), 'component' => 'textarea', 'required' => true, 'rows' => 4 ),
),
'handler' => function( $action_data, $action, $post_id, $event_data ) {
$to = joinotify_replace_placeholders( $action_data['to'] ?? '', $event_data );
$msg = joinotify_replace_placeholders( $action_data['message'] ?? '', $event_data );
// ... call your SMS gateway here ...
return true; // the handler must return bool
},
) );
// The description shown under the node title on the canvas.
joinotify_register_action_description( 'my_app_send_sms', function( $data, $workflow_action ) {
return esc_html( sprintf( __( 'SMS to %s', 'my-textdomain' ), $data['to'] ?? '' ) );
} );
Components accepted in settings_schema[].component: input, textarea, number, select
(with options), date, time, code, switch, attachments, plus nested group and
repeater. The condition key drives conditional visibility, with the eq, neq, in,
not_in, truthy and falsy operators.
A trigger and its integration
A trigger only shows up in the builder when the matching integration is registered and
enabled (its setting_key option set to yes).
joinotify_register_integration( array(
'slug' => 'my_app',
'title' => __( 'My App', 'my-textdomain' ),
'description' => __( 'Automate messages from My App events.', 'my-textdomain' ),
'setting_key' => 'enable_my_app_integration',
) );
joinotify_register_trigger( 'my_app', array(
'data_trigger' => 'my_app_order_paid',
'title' => __( 'My App: order paid', 'my-textdomain' ),
'description' => __( 'Fires when a My App order is paid.', 'my-textdomain' ),
) );
// Fire it from your own hook.
add_action( 'my_app_order_paid', function( $order_id ) {
joinotify_dispatch_trigger( 'my_app_order_paid', 'my_app', array( 'order_id' => $order_id ) );
} );
The $payload you pass on dispatch is available to placeholders and conditions at send time.
New placeholders
joinotify_register_placeholders( 'woocommerce', array(
'{{ order_total }}' => array(
'triggers' => array(), // empty = every trigger of this integration
'description' => esc_html__( 'The order total', 'my-textdomain' ),
'replacement' => array(
'sandbox' => '$99.99',
'production' => function( $payload ) {
$order = isset( $payload['order_id'] ) ? wc_get_order( $payload['order_id'] ) : null;
return $order ? wc_price( $order->get_total() ) : '';
},
),
),
) );
sandboxis the value shown in the builder preview;productionis the one used at send time, and it can be acallableresolved on the spot.triggerslists thedata_triggervalues the placeholder is valid on. This applies at send time too since version 2.1.0: a wrong slug simply stops the placeholder from resolving.
For placeholders that take an argument — {{ my_app_field=[total] }} — register a PCRE resolver:
joinotify_register_dynamic_placeholder( '/\{\{\s*my_app_field=\[(.+?)\]\s*\}\}/', function( $matches, $payload ) {
return $payload['fields'][ $matches[1] ] ?? null; // null keeps the original token
} );
Registered resolvers run before the built-in ones, so you can override
{{ field_id=[…] }}, {{ wc_checkout_field=[…] }}, {{ wc_download_link=[…] }} and
{{ user_meta[…] }}.
Conditions
joinotify_register_conditions( 'my_app_order_paid', array(
'my_app_plan' => array(
'title' => __( 'Subscription plan', 'my-textdomain' ),
'description' => __( 'Check the customer plan.', 'my-textdomain' ),
),
) );
joinotify_register_condition_operators( 'my_app_plan', array( 'is', 'is_not' ) );
joinotify_register_condition_value( 'my_app_plan', function( $value_map, $type, $payload ) {
return get_post_meta( $payload['order_id'] ?? 0, '_my_app_plan', true );
} );
A REST route
joinotify_register_rest_route( array(
'route' => '/admin/my-app/ping',
'methods' => 'GET',
'callback' => function ( WP_REST_Request $request ) {
return rest_ensure_response( array( 'status' => 'success', 'pong' => true ) );
},
// 'permission' => fn() => current_user_can( 'manage_options' ), // default
) );
// → GET /wp-json/joinotify/v1/admin/my-app/ping
A delivery channel
A channel implements Channel_Interface and starts receiving the workflows' messages. That is
how WhatsApp Cloud, Telegram and e-mail coexist in the same plugin.
use MeuMouse\Joinotify\Notifications\Channel_Interface;
use MeuMouse\Joinotify\Notifications\Notification_Message;
use MeuMouse\Joinotify\Notifications\Channel_Result;
class My_Channel implements Channel_Interface {
public function get_id() {
return 'my_channel';
}
public function get_label() {
return __( 'My channel', 'my-textdomain' );
}
public function is_configured() {
return '' !== get_option( 'my_channel_token', '' );
}
public function get_capabilities() {
return array( 'text', 'media' );
}
public function supports( Notification_Message $message ) {
return '' !== trim( (string) $message->receiver )
&& in_array( $message->type, $this->get_capabilities(), true );
}
public function send( Notification_Message $message ) {
$ok = my_channel_send( $message->receiver, $message->content );
return $ok
? Channel_Result::success( $this->get_id() )
: Channel_Result::failure( $this->get_id(), 'my_channel_failed', true );
}
}
joinotify_register_notification_channel( 'my_channel', My_Channel::class );
Notification_Message carries channel, type, sender, receiver, content, media_type,
media_url, caption, attachments, delay, a context array and a free-form meta array
for service-specific fields. Read meta with $message->get_meta( $key ).
Attachments modify an existing message. Do not add them to get_capabilities() — that would
make supports() reject plain messages. Check $message->attachments instead.
And never fetch a WooCommerce download permission link to re-send its bytes: every download you
spend is one the customer loses. Use the resolved path, or send the link.
The same design applies to passwordless login codes, on the Joinotify/Otp_Login/Channels
filter.
Native trigger payloads
Each native trigger carries a set of parameters in the $payload array, available to
placeholders, conditions and action handlers. To read the order ID on the
woocommerce_new_order trigger, for example:
$order_id = $payload['order_id'];
Three keys exist on every trigger: type (always trigger), hook (the hook that fired) and
integration (the context — woocommerce, wordpress, elementor, wpforms…).
WooCommerce
| Trigger | When it fires | Extra parameters |
|---|---|---|
New order (woocommerce_new_order) | An order is created, with any status | order_id |
New order — processing (woocommerce_checkout_order_processed) | The order enters the "processing" status | order_id, status_transition |
Order completed (woocommerce_order_status_completed) | The status changes to "completed" | order_id, status_transition |
Status changed (woocommerce_order_status_changed) | Any order status change | order_id, old_status, new_status |
Fully refunded (woocommerce_order_fully_refunded) | The order is fully refunded | order_id, refund_id |
Partially refunded (woocommerce_order_partially_refunded) | The order is partially refunded | order_id, refund_id |
Digital access granted (woocommerce_grant_product_download_permissions) | WooCommerce grants the download to the customer | order_id |
Digital file downloaded (woocommerce_download_product) | The customer downloads a file from the order | order_id, product_id, download_id, user_id, user_email |
For delivery workflows, use Digital access granted. The download links only exist from that moment on — on earlier triggers the download placeholders come back empty.
WooCommerce Subscriptions
| Trigger | Extra parameters |
|---|---|
Subscription created (woocommerce_checkout_subscription_created) | subscription_id, order_id, recurring_cart |
Payment complete (woocommerce_subscription_payment_complete) | subscription_id |
Payment failed (woocommerce_subscription_payment_failed) | subscription_id |
Status changed (woocommerce_subscription_status_active, …_cancelled, …_expired) | subscription_id, new_status |
WordPress
| Trigger | When it fires | Extra parameters |
|---|---|---|
New registration (user_register) | A user registers | user_id, user_data |
Login (wp_login) | A user logs in | user_id, user_data |
Password reset requested (retrieve_password) | The user asks for the reset link | user_id, reset_password_link |
Password reset (password_reset) | The user completes the reset | user_id |
Post status changed (transition_post_status) | A post changes status | post_id, post_type, post_status, old_post_status |
Elementor
| Trigger | Extra parameters |
|---|---|
Form submitted (elementor_pro/forms/new_record) | id, fields, record, handler |
WPForms
| Trigger | Extra parameters |
|---|---|
Form submitted (wpforms_process_complete) | id, fields, entry, form_data, entry_id |
PayPal payment (wpforms_paypal_standard_process_complete) | id, fields, entry, form_data, entry_id |
Useful filters
New file formats
add_filter( 'Joinotify/Validations/Get_Mime_Types', function( $mime_types ) {
$mime_types['image'][] = 'image/svg+xml';
$mime_types['video'][] = 'video/webm';
$mime_types['document'][] = 'application/epub+zip';
$mime_types['audio'][] = 'audio/flac';
return $mime_types;
} );
Other hooks
| Filter | What it is for |
|---|---|
Joinotify/Builder/Action_Settings_Schema | Override an action's schema, built-in ones included |
Joinotify/Builder/Action_Default_Data | Override an action's default data |
Joinotify/Workflow_Processor/Delaying_Actions | Register an action that pauses the workflow |
Joinotify/Workflow_Processor/Branching_Actions | Register an action that branches the workflow |
Joinotify/Notifications/Default_Channel | Change the default delivery channel |
Joinotify/Notifications/Message_Before_Send | Mutate the message before sending |
Joinotify/Transport/Active | Force the resolved WhatsApp transport |
Joinotify/Settings/Integrations/Categories | Register your own category in the Applications tab |
Plus the Joinotify/Notifications/Message_Sent action, fired after every delivery attempt.
Gotchas
- Register early. Use
plugins_loadedorinit, before the builder bootstrap. - The handler returns
bool. Returning anything else can stop the workflow silently. - Trigger context. A context only appears when the integration card is registered and enabled.
- Everything is additive. Built-in schemas, default data and resolvers stay in place as the fallback whenever you do not override them.
Full reference
The exhaustive extension API reference, always aligned to the latest release, lives in the plugin repository:
- DEVELOPERS.md — every extension point
- docs/integrations.md — the declarative contract for integration cards
- examples/ — a runnable example extension