Notifications explained: channels, queue, cron, and template tokens

Why this matters

A MAM app sends a lot of small messages: a welcome email when someone signs up, a password-reset code, an order-shipped push, an SMS reminder. In MAM Suite all of these flow through one mechanism. A plugin fires a single message; the notification system figures out which delivery methods apply, when to actually send them, and what text each one carries.

Four pieces do that work, and knowing how they fit together turns “I sent a notification and nothing happened” from a mystery into a five-minute diagnosis:

  • Channels — the three ways a message can reach a person: email, SMS, and push.
  • The queue — a holding area in the database for messages that shouldn’t be sent in the moment.
  • Cron — the heartbeat that periodically drains the queue.
  • Template tokens — bracketed placeholders like [order_id] that get swapped for real values at send time.

This article walks one notification from the moment it’s fired to the moment it lands, so you can see where each piece takes over. It’s conceptual; the dedicated reference articles for each piece (linked at the end) carry the exact option keys, table names, and code shapes.


The one entry point

Everything starts with a single fire:

do_action( 'mam_notification_send_message', array(
    'message_type' => 'mam-my-plugin-order_shipped',
    'recipient_id' => 123,
    'replacements' => array(
        'order_id'        => '42',
        'tracking_number' => 'ABC123',
    ),
) );

A plugin does not choose channels at the call site. It doesn’t say “send an email” or “send a push.” It names a message type and a recipient, hands over some values, and lets the system decide the rest. The four pieces below are what make that decision.

The dispatcher (MAM_Notification_Dispatcher) picks up that fire and routes it. From there, the message fans out to as many of the three channels as apply, and each channel handles its own templating, queueing, and delivery.

Note for developers: there’s a lower-level, push-only hook (mam_notification_send_pn) used internally for special cases like system chat. In your own code, fire mam_notification_send_message and let the dispatcher route it — that’s what makes the channel, opt-out, and template logic apply.


Channels: the three ways out

A channel is a delivery method. MAM Suite supports three, each with its own sender class and its own transport:

Channel Transport Reaches the user via
Email wp_mail() (or the queue) The recipient’s email address
SMS A provider such as Twilio The recipient’s phone number (billing_phone)
Push APNs (iOS) and FCM (Android) The device tokens stored for the user

The important conceptual point: one fire can produce up to three deliveries. An order-shipped message might go out as an email and a push and an SMS, all from that one do_action. Each channel renders its own version from its own template, so the three can read differently while describing the same event.

Which channels actually fire isn’t up to the calling code — it comes down to two things working together:

  1. The notification type’s defaults. When a type is registered, it can declare default_on channels — for example array( 'email', 'push' ).
  2. Per-type admin toggles. In Mobile App Manager → Notifications → Settings, an admin can turn each channel on or off for each type.

So a developer firing mam-my-plugin-order_shipped doesn’t control whether SMS goes out. The admin does, by toggling the SMS channel for that type. This separation is deliberate: it lets the same code ship to one client who wants email-only and another who wants push-and-SMS, with no code change — just different toggles. (It also means “my push isn’t sending” is very often “push is toggled off for that type,” not a code bug.)

A channel also needs the recipient to actually have the right contact point. No phone number on file means SMS is skipped, regardless of the toggle; no stored device token means push is skipped. On top of all this, per-user opt-out (an unsubscribed email, a Twilio STOP reply) is honored at send time.


Template tokens: the same event, personalized

Each channel sends text, and that text comes from a per-channel template the admin edits. Templates are not static — they contain bracketed replacement tokens that get swapped for real values:

Your order [order_id] has shipped!
Track it: https://example.com/track/[tracking_number]
Thanks, [user_login].

Tokens connect three places:

  1. Declared when the notification type is registered, as a list of names without brackets: 'replacements' => array( 'order_id', 'tracking_number', 'user_login' ).
  2. Used in the admin-edited template with brackets: [order_id].
  3. Supplied at fire time, in the message’s replacements array: 'order_id' => '42'.

At send time, each channel’s sender substitutes the supplied values into its own template. That’s why the email, the SMS, and the push can all reference [order_id] and all show 42 — the same token set feeds every channel; only the surrounding template text differs.

Two conveniences are worth knowing:

  • User tokens are auto-filled. Tokens that describe the recipient — [user_login], [user_email], [first_name], and similar — are hydrated automatically from the recipient’s WordPress user record. You don’t have to pass them in replacements; you just have to declare them on the type.
  • Custom tokens must be supplied. Anything event-specific ([order_id], [tracking_number]) has no source other than your call, so you must include it in replacements.

This design has a few sharp edges worth knowing about. Tokens are case-sensitive ([user_login][User_Login]). An undefined token is left in the output verbatim — a customer seeing a literal [tracking_number] in their email almost always means a token name was misspelled or never supplied. And because custom and built-in tokens share one namespace, a custom token named the same as a built-in will override the auto-filled value, so prefixing custom tokens ([order_user_login]) avoids surprises.


The queue: why “send” doesn’t always mean “now”

For a single message to one person, sending is immediate — wp_mail() runs, the SMS goes out, the push dispatches, all within the request. The queue exists for the other case: bulk sends.

Imagine a custom notification to 800 users. Sending 800 emails synchronously inside one web request would blow past PHP’s execution time limit and the request would die partway through — some sent, some not, no clean record. The queue is the answer: instead of sending in the moment, the dispatcher writes the messages into database tables and returns immediately. The actual sending happens later, in small batches, driven by cron.

The dispatcher makes this decision per batch, and both of these have to be true:

  • The batch is larger than a threshold (more than 10 messages), and
  • Queue processing is globally enabled (the mam_notification_use_cron option is on).

If either is false, everything sends immediately. So a 3-message batch always sends now. And a 500-message batch on a site where cron is disabled also tries to send now — which is exactly the timeout scenario the queue was built to prevent. The practical takeaway: any site that does bulk sends should have cron enabled.

Queued messages live in dedicated tables — one for email, one shared between SMS and push (with a flag marking which channel each row is). They sit there with an “unsent” status until cron picks them up.


Cron: the heartbeat that drains the queue

A queue is only useful if something empties it on a schedule. That something is cron — but not WordPress’s built-in wp-cron.php, which only fires when a visitor happens to hit the site. A low-traffic app could leave queued notifications sitting for hours.

MAM Suite uses FastCron instead: an external service that pings a known endpoint on the app’s site at a fixed interval, regardless of visitor traffic. Each ping is a tick. On every tick, the notification system’s queue processor wakes up and:

  1. Pulls a batch of queued emails (a bounded number — currently 50) and sends each via wp_mail().
  2. Pulls a batch of queued SMS and push rows (a smaller bounded number — currently 20) and dispatches each to its provider.
  3. Marks each row sent, or — for email — bumps a retry counter so a transient failure gets another attempt on a later tick.

The batch sizes are deliberately bounded so that one tick never tries to do too much. A 1,000-message backlog drains over many ticks rather than in one giant request. That’s the whole point of the queue-plus-cron pairing: it trades a little latency for reliability and a clean audit trail. The 50/20 limits are hardcoded in the processor rather than exposed as settings; a very high-volume site can raise them, but only as a code change, and both directions cost something — bigger email batches drift back toward the timeout the queue was built to avoid, bigger SMS/push batches pay more per-send networking inside one tick.

A few behaviors follow from this:

  • Opt-out is checked at send time, not at queue time. A user who unsubscribes after a message is queued but before its tick fires will still have the opt-out honored — the sender re-checks just before sending.
  • Retry behavior differs by channel. Email gets a few automatic retries (a configurable count, three by default), and a permanently failed email is left in the table as a failed row rather than deleted. SMS and push do not retry — a failed send stays failed, and re-sending requires a fresh fire. (Failures are still recorded either way, so they’re visible rather than silent.)
  • Cron callbacks run serially on each tick. The mail processor shares the tick with every other job registered on the same cron manager, so a long-running job earlier in the tick can delay it. The bounded batch sizes are what keep any one job from monopolizing a tick.

If the queue isn’t draining at all, the usual cause is cron itself: FastCron not configured, its API token missing or set to the disabled sentinel, or the site behind authentication that blocks FastCron’s ping. When the queue tables just keep filling up and nothing sends, that’s the classic symptom.


Putting it together: one message, end to end

Here’s the full path of a single order-shipped notification, with each of the four pieces doing its job:

  1. Fire. Code calls mam_notification_send_message with a message_type, a recipient_id, and a replacements array. No channel is named.
  2. Resolve the type. The dispatcher looks up the type in the registry to learn its default channels, its templates, and its declared tokens.
  3. Pick channels. For each of email / SMS / push, the dispatcher checks the per-type admin toggle (and the type’s default_on), plus whether the recipient has the contact point and hasn’t opted out. Each surviving channel is a real delivery.
  4. Render text. Each channel takes its own template and substitutes the tokens — auto-filled user tokens plus the values you supplied — into the subject and body.
  5. Send now or queue. If this is a small batch (or cron is off), each channel sends immediately. If it’s a large batch and cron is on, the messages are written to the queue tables instead and the request returns.
  6. Drain via cron. On the next FastCron tick, the processor pulls a batch from the queue, sends each, re-checks opt-out, and records the result.
  7. Record. Every delivery — sent, failed, queued — is logged to the notification history table. A three-channel message produces three history rows. The in-app notification center and the admin Outbox both read from here.

The same seven steps describe a one-off password reset and an 800-recipient broadcast. The only difference is which branch step 5 takes.


How this connects to the rest of MAM Suite

  • The type registry is the table of contents for all of this. Every notification — bundled or from a sibling plugin — registers a type that declares its slug, its tokens, and its default channels. The dispatcher does nothing until it can find your message_type there. See Notification types registry.
  • Sibling plugins (contact form, reviews, special offers, and many more) almost never talk to channels, queues, or cron directly. They register a type and fire a message; the four pieces here do the rest. That’s why this concept is worth understanding once rather than per plugin.
  • The history and opt-out system is the read-side counterpart: it’s where deliveries are recorded, where the in-app notification center reads from, and where opt-out state lives. See Notification history and opt-out.
  • FastCron is shared infrastructure — the same tick that drains the notification queue also runs other scheduled MAM Suite work. See Integration: FastCron.

Common misconceptions

  • “Firing the notification sends it.” Firing offers it to up to three channels. Whether any of them sends depends on admin toggles, recipient contact points, and opt-out — none of which the calling code controls.
  • “The code chooses email vs. push.” It doesn’t. The notification type and the admin toggles choose. Code only names the type and the recipient.
  • “Queued means sending soon.” Queued means waiting for the next cron tick. If cron isn’t running, queued means waiting indefinitely.
  • “A missing token will be removed from the message.” It won’t — an unresolved [token] is left in the output as-is. Seeing literal brackets in a delivered message is the signal to check your token names.

  • Notifications overview
  • Notification channels: email, SMS, push
  • Notification queue and cron
  • Notification template replacements
  • Notification types registry
  • Notification history and opt-out
  • Integration: FastCron
  • Recipe: Register a notification type
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!