Messaging Hooks (SMS & WhatsApp)
FluentCampaign Pro IntermediateThese action hooks fire during messaging campaign lifecycle events, message sending, delivery tracking, and subscriber opt-in/out. All Messaging hooks require FluentCampaign Pro.
Channel-specific hook names
Campaign lifecycle hooks are built from the channel: fluent_crm/{channel}_campaign_created, _updated, _scheduled, _status_active, _duplicated, _archived, _deleted and _processing_start, where {channel} is sms or whatsapp. Anything documented below as sms_campaign_* has a whatsapp_campaign_* twin with the same arguments.
Campaign Lifecycle
fluent_crm/sms_campaign_created
Fires when a new SMS campaign is created.
Parameters
$campaignSMSCampaign Model
Usage:
add_action('fluent_crm/sms_campaign_created', function($campaign) {
// New SMS campaign created
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_updated
Fires when an SMS campaign is updated.
Parameters
$campaignSMSCampaign Model
Usage:
add_action('fluent_crm/sms_campaign_updated', function($campaign) {
// SMS campaign was modified
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_status_active
Fires at the start of SMSController::schedule(), once the campaign has passed the "must still be a draft" guard but before any status change is written.
WARNING
The campaign passed to this hook still has status = 'draft'. Re-read the model if you need the post-schedule status.
Parameters
$smsCampaignSMSCampaign Model - still indraftstatus at this point
Usage:
add_action('fluent_crm/sms_campaign_status_active', function($smsCampaign) {
// SMS campaign activated
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_scheduled
Fires when an SMS campaign is scheduled for future sending. It does not fire for a send-immediately campaign — that path schedules the batch-generation job directly instead.
Parameters
$smsCampaignSMSCampaign Model - freshly re-read from the database, so itsstatusandscheduled_atare the saved values$scheduledAtString - the campaign'sscheduled_atcolumn, a site-localY-m-d H:i:sdatetime (not a Unix timestamp)
Usage:
add_action('fluent_crm/sms_campaign_scheduled', function($smsCampaign, $scheduledAt) {
// $scheduledAt is a MySQL datetime string, e.g. '2026-08-12 09:30:00'
$timestamp = strtotime($scheduledAt);
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_processing_start
Fires when a pending-scheduled campaign flips to processing — that is, when its scheduled time is less than six minutes away and the admin screen polls for processing stats. The campaign has already been saved with status = 'processing' and recipients_count = 0 when this runs.
Parameters
$campaignSMSCampaign Model - already saved asprocessing
Usage:
add_action('fluent_crm/sms_campaign_processing_start', function($campaign) {
// SMS campaign processing started
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_duplicated
Fires when an SMS campaign is duplicated.
Parameters
$newCampaignSMSCampaign Model - the new copy, created as adraftwith a[Duplicate]title prefix and the original's labels already attached$oldCampaignSMSCampaign Model - the original
Usage:
add_action('fluent_crm/sms_campaign_duplicated', function($newCampaign, $oldCampaign) {
// SMS campaign was duplicated
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
fluent_crm/sms_campaign_archived
Fires when an SMS campaign is archived — either because a recurring/auto-processing campaign has no future runs left, or by the cleanup cron when a finished campaign has no unsent messages remaining. The campaign row is saved as status = 'archived' in all cases, but only the first two call sites re-read the model before firing; on the cron path the model still carries its pre-archive status.
Parameters
$smsCampaignSMSCampaign Model - the row is saved asarchived, though on the cron path the passed instance may still show the previous status
Usage:
add_action('fluent_crm/sms_campaign_archived', function($smsCampaign) {
// SMS campaign archived
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php, fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageScheduler.php
fluent_crm/sms_campaign_deleted
Fires after an SMS campaign is permanently deleted, both from the single-delete endpoint and once per campaign from the delete_campaigns bulk action.
WARNING
The campaign row and its message/meta data are already gone when this fires — only the ID is passed. Capture anything you need on fluent_crm/sms_campaign_updated instead.
Parameters
$campaignIdINT - deleted campaign ID
Usage:
add_action('fluent_crm/sms_campaign_deleted', function($campaignId) {
// SMS campaign deleted
});Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageController.php
Sending & Delivery
One hook per message, named after the channel
The scheduler fires fluent_crm/{channel}_sent or fluent_crm/{channel}_failed exactly once per outbound message, where {channel} is the conversation thread's channel (sms or whatsapp). A WhatsApp message fires the whatsapp_* hook instead of the sms_* one, never both. A channel registered by an add-on gets its own fluent_crm/{slug}_sent and _failed the same way.
$message is a Message model (an fc_messages row), already updated when the hook runs. The channel, destination number and contact hang off $message->thread (channel, phone_number, contact_id, and ->subscriber). Legacy listeners written against the old SMSMessage model should read $message->thread->phone_number instead of mobile_number, and the error text from $message->meta['error_message'] instead of notes.
fluent_crm/sms_sent
Fires after an SMS message is successfully sent. By the time the listener runs, the message row has been updated and the campaign's sent_count incremented.
Parameters
$messageMessage Model - already updated:statusissent,sent_atandupdated_atare set, andprovider_message_idis filled when the provider returned one$resultArray - the driver's response:status(success),status_code,message,response, andprovider_message_idwhen the provider returns one
Usage:
add_action('fluent_crm/sms_sent', function($smsMessage, $result) {
// SMS sent successfully
// $result contains provider-specific response data
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageScheduler.php
fluent_crm/sms_failed
Fires after an SMS message fails, once the message row has been marked failed and the campaign's failed_count incremented. It fires on every failure path: pre-send guards that never reach the provider (no conversation thread, no phone number, unknown channel, or a contact who is not subscribed on the channel), a provider error response, and an exception thrown during the send.
Parameters
$messageMessage Model - already updated:statusisfailedandmeta['error_message']holds the reason$errorMessageString - the guard's reason, the driver response'smessage(orUnknown errorwhen it carries none), or the exception message
Usage:
add_action('fluent_crm/sms_failed', function($message, $errorMessage) {
// SMS failed - log or retry
error_log('SMS failed: ' . $errorMessage);
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageScheduler.php
fluent_crm/whatsapp_sent
WhatsApp counterpart of fluent_crm/sms_sent: a message fires this hook when its thread's channel is whatsapp, and does not fire fluent_crm/sms_sent. It runs after the message row has been marked sent and the campaign's sent_count incremented, and fires once per message.
Parameters
$messageMessage Model - already updated:statusissent,sent_atandupdated_atare set, andprovider_message_idis filled when the provider returned one$resultArray - the WhatsApp driver's response (status,status_code,message,response); carriesprovider_message_idwhen the provider returns one
Usage:
add_action('fluent_crm/whatsapp_sent', function($message, $result) {
// WhatsApp message sent successfully
// $result contains provider-specific response data
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageScheduler.php
fluent_crm/whatsapp_failed
WhatsApp counterpart of fluent_crm/sms_failed. Fires after a WhatsApp message fails, once the message row has been marked failed and the campaign's failed_count incremented. Like the SMS hook it fires on every failure path, including pre-send guards that never reach the provider, such as a contact whose WhatsApp thread is not subscribed; in that case $errorMessage is Contact is not subscribed for WhatsApp.
Parameters
$messageMessage Model - already updated:statusisfailedandmeta['error_message']holds the reason$errorMessageString - the guard's reason, the driver response'smessage(orUnknown errorwhen it carries none), or the exception message
Usage:
add_action('fluent_crm/whatsapp_failed', function($message, $errorMessage) {
// WhatsApp send failed - log or retry
error_log('WhatsApp failed: ' . $errorMessage);
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageScheduler.php
Opt-in & Opt-out
fluent_crm/contact_sms_subscribed
Fires when an inbound message opts a contact in to SMS. The contact is matched by phone, so nothing fires for an unknown number, although the number's consent is still recorded. Consent lives on the conversation thread (fc_message_threads.status), not on the contact: every SMS thread of the contact is already subscribed when this fires. Unless the provider is Twilio, which sends its own compliance reply, a confirmation message has already been queued to the contact.
Parameters
$subscriberSubscriber Model - the contact whose SMS threads are nowsubscribed$dataArray - the inbound webhook context; includes aproviderkey
Usage:
add_action('fluent_crm/contact_sms_subscribed', function($subscriber, $data) {
// Contact opted in to SMS
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/SMS/SMSHelper.php
fluent_crm/contact_sms_unsubscribed
Fires when an inbound message opts a contact out of SMS. The contact is matched by phone, so nothing fires for an unknown number, although the number's consent is still recorded. Consent lives on the conversation thread (fc_message_threads.status), not on the contact: every SMS thread of the contact is already unsubscribed when this fires. Unless the provider is Twilio, which sends its own compliance reply, the opt-out confirmation has already been queued; it is the one outbound message the scheduler delivers to an unsubscribed contact.
Parameters
$subscriberSubscriber Model - the contact whose SMS threads are nowunsubscribed$dataArray - the inbound webhook context; includes aproviderkey
Usage:
add_action('fluent_crm/contact_sms_unsubscribed', function($subscriber, $data) {
// Contact opted out of SMS
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/SMS/SMSHelper.php
fluent_crm/contact_whatsapp_subscribed
WhatsApp counterpart of fluent_crm/contact_sms_subscribed. Fires when an inbound WhatsApp message — a start/subscribe keyword arriving on the Twilio WhatsApp or Meta Cloud webhook — opts a contact in to WhatsApp. The contact is matched by phone, so nothing fires for an unknown number, although the number's consent is still recorded. Consent lives on the conversation thread (fc_message_threads.status): every WhatsApp thread of the contact is already subscribed when this fires.
Parameters
$subscriberSubscriber Model - the contact whose WhatsApp threads are nowsubscribed$dataArray - the inbound webhook context; includes aproviderkey (twilio_whatsappormeta_cloud)
Usage:
add_action('fluent_crm/contact_whatsapp_subscribed', function($subscriber, $data) {
// Contact opted in to WhatsApp
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/WhatsApp/WhatsAppHelper.php
fluent_crm/contact_whatsapp_unsubscribed
WhatsApp counterpart of fluent_crm/contact_sms_unsubscribed. Fires when an inbound WhatsApp message — a stop/cancel/unsubscribe keyword arriving on the Twilio WhatsApp or Meta Cloud webhook — opts a contact out of WhatsApp. The contact is matched by phone, so nothing fires for an unknown number, although the number's consent is still recorded. Consent lives on the conversation thread (fc_message_threads.status): every WhatsApp thread of the contact is already unsubscribed when this fires. The opt-out confirmation, if enabled, is sent directly through the provider after this hook.
Parameters
$subscriberSubscriber Model - the contact whose WhatsApp threads are nowunsubscribed$dataArray - the inbound webhook context; includes aproviderkey (twilio_whatsappormeta_cloud)
Usage:
add_action('fluent_crm/contact_whatsapp_unsubscribed', function($subscriber, $data) {
// Contact opted out of WhatsApp
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/WhatsApp/WhatsAppHelper.php
WhatsApp Inbound & Receipts
fluent_crm/whatsapp_message_received
Fires after an inbound WhatsApp message has been stored in its conversation thread. The messaging module ships no inbound automation trigger, so this is the extension point for reacting to messages contacts send you. It fires for every stored inbound message, including keyword replies such as STOP and START.
The contact may be null
$subscriber is null when the sender's number does not match a CRM contact, which is the common case for a first contact. Always check it before use.
Parameters
$recordMessage Model - the stored inbound message (directionisinbound,statusisreceived)$subscriberSubscriber Model|null - the contact who sent it, ornullfor an unknown number$dataArray - the raw, normalized provider payload for the message
Usage:
add_action('fluent_crm/whatsapp_message_received', function($record, $subscriber, $data) {
if (!$subscriber) {
return; // Unknown sender
}
if (stripos($record->content, 'pricing') !== false) {
$subscriber->attachTags(['Asked About Pricing']);
}
}, 10, 3);Source: fluentcampaign-pro/app/Modules/Messaging/WhatsApp/WhatsAppHelper.php
fluent_crm/whatsapp_message_{type}
Delivery-receipt hook built from the receipt type. It fires when a provider status webhook reports that an outbound WhatsApp message was delivered or read, so the two concrete hooks are fluent_crm/whatsapp_message_delivered and fluent_crm/whatsapp_message_read. The WhatsApp funnel benchmarks listen on these. Other receipt types (such as failed) do not fire a hook of this form.
The hook fires after the message row has been updated, and fires even when an out-of-order receipt did not change the stored status (for example a late delivered arriving after read).
Parameters
$messageMessage Model - the outbound message, freshly reloaded after the receipt was applied
Usage:
add_action('fluent_crm/whatsapp_message_read', function($message) {
// The contact has read the message
error_log('WhatsApp message #' . $message->id . ' was read');
});Source: fluentcampaign-pro/app/Modules/Messaging/WhatsApp/WhatsAppReceiver.php
Webhooks & Conversations
fluent_crm/messaging_webhook_rejected
Fires when a messaging provider webhook is rejected before it reaches a receiver (a missing or invalid provider, a missing or invalid webhook hash, a provider signature that does not verify, or a request to the retired Twilio URL, which answers 410). The provider only sees a bare HTTP error and nothing is written to the error log, so hook this when you need a trace of why a webhook was refused. The request ends right after the action runs.
Parameters
$reasonString - human-readable reason, also sent as the response body$providerString - provider slug from the request; empty when the provider was the missing piece$statusInteger - HTTP status code being returned$detailString - extra context for diagnostics only; never sent in the response
Usage:
add_action('fluent_crm/messaging_webhook_rejected', function($reason, $provider, $status, $detail) {
error_log(sprintf('Messaging webhook rejected (%s, HTTP %d): %s %s', $provider, $status, $reason, $detail));
}, 10, 4);Source: fluentcampaign-pro/app/Modules/Messaging/Handlers/MessageHandler.php
fluent_crm/messaging_thread_contact_linked
Fires after an inbox conversation that had no contact is attached to a CRM contact from the admin inbox. It does not fire when the conversation was already linked to that same contact.
Parameters
$threadMessageThread Model - the conversation that was linked$contactSubscriber Model - the contact it was linked to
Usage:
add_action('fluent_crm/messaging_thread_contact_linked', function($thread, $contact) {
// React to a conversation being matched to a contact
}, 10, 2);Source: fluentcampaign-pro/app/Modules/Messaging/Http/Controllers/MessageThreadController.php