Skip to main content

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:

  1. Helpers (recommended) — global joinotify_register_*() functions, loaded with the plugin.
  2. Filters — the apply_filters() hooks underneath, when you need full control.

Register your extensions on a normal hook such as plugins_loaded or init.

Back up first

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.

HelperWhat 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​

HelperUnderlying 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() ) : '';
},
),
),
) );
  • sandbox is the value shown in the builder preview; production is the one used at send time, and it can be a callable resolved on the spot.
  • triggers lists the data_trigger values 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 are not a message type

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​

TriggerWhen it firesExtra parameters
New order (woocommerce_new_order)An order is created, with any statusorder_id
New order — processing (woocommerce_checkout_order_processed)The order enters the "processing" statusorder_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 changeorder_id, old_status, new_status
Fully refunded (woocommerce_order_fully_refunded)The order is fully refundedorder_id, refund_id
Partially refunded (woocommerce_order_partially_refunded)The order is partially refundedorder_id, refund_id
Digital access granted (woocommerce_grant_product_download_permissions)WooCommerce grants the download to the customerorder_id
Digital file downloaded (woocommerce_download_product)The customer downloads a file from the orderorder_id, product_id, download_id, user_id, user_email
Delivering digital products

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​

TriggerExtra 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​

TriggerWhen it firesExtra parameters
New registration (user_register)A user registersuser_id, user_data
Login (wp_login)A user logs inuser_id, user_data
Password reset requested (retrieve_password)The user asks for the reset linkuser_id, reset_password_link
Password reset (password_reset)The user completes the resetuser_id
Post status changed (transition_post_status)A post changes statuspost_id, post_type, post_status, old_post_status

Elementor​

TriggerExtra parameters
Form submitted (elementor_pro/forms/new_record)id, fields, record, handler

WPForms​

TriggerExtra 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​

FilterWhat it is for
Joinotify/Builder/Action_Settings_SchemaOverride an action's schema, built-in ones included
Joinotify/Builder/Action_Default_DataOverride an action's default data
Joinotify/Workflow_Processor/Delaying_ActionsRegister an action that pauses the workflow
Joinotify/Workflow_Processor/Branching_ActionsRegister an action that branches the workflow
Joinotify/Notifications/Default_ChannelChange the default delivery channel
Joinotify/Notifications/Message_Before_SendMutate the message before sending
Joinotify/Transport/ActiveForce the resolved WhatsApp transport
Joinotify/Settings/Integrations/CategoriesRegister your own category in the Applications tab

Plus the Joinotify/Notifications/Message_Sent action, fired after every delivery attempt.

Gotchas​

  • Register early. Use plugins_loaded or init, 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: