Features

Product Audiences & Segments

Last updated July 7, 2026

Most of the time you’ll pick a campaign’s audience from a list (“send to my Newsletter list”). But for WooCommerce-driven sites, the more powerful pattern is targeting people by what they own rather than which list they’re on — “send this security notice to everyone who bought Scalability Pro” or “send this renewal nudge to Super Speedy Pack buyers whose renewal is due in the next 30 days.”

This page covers the product-audience picker and, for more advanced needs, the JSON segment-query language.


The simple case: “Owners of a product”

When composing a campaign (SSE Emails > Campaigns > Add New), the audience picker has an option Owners of a product. Pick this and you’ll see:

  • Product ID — the WooCommerce product whose owners you want to reach
  • Include bundle owners (checkbox) — see below
  • Direct buyers only (checkbox) — the inverse of the above

Bundle expansion explained

If you sell a “Super Speedy Pack” bundle that includes Scalability Pro, Super Speedy Search, etc., and a customer buys the Pack:

  • “Owners of Scalability Pro” with Include bundles ✅ → the Pack buyer is included. They effectively own it via the bundle.
  • “Owners of Scalability Pro” with Direct buyers only ✅ → the Pack buyer is excluded. Only people who bought Scalability Pro as a standalone purchase are reached.

The bundle composition is read from the WooCommerce Product Bundles plugin’s wp_woocommerce_bundled_items table. At write time, SSE pre-stores both direct=1 rows (the actual line item) and direct=0 rows (each component product), so the segment query is a flat lookup — no runtime joins, no slow queries on large audiences.

This is what makes “20% off Scalability Pro” reach 380 people in under a second even on a site with millions of orders.

When to use which

GoalSetting
Security notice — must reach everyone affectedInclude bundle owners
Renewal-style nudge — bundle owners see a different versionDirect buyers only (compose a separate campaign for bundle owners)
Cross-sell of an unrelated productEither — depends on your messaging

How ownership is determined

A subscriber is recorded as owning a product when:

  1. They complete a WooCommerce order containing a line item for that product. The hook is woocommerce_order_status_changed into completed or processing.
  2. They’re a customer of a bundle whose components include that product. Same hook.

The ownership record lives in sse_product_owners with (subscriber_id, product_id, direct). It includes first_purchased_at and last_purchased_at so you can filter on recency.

💡 If you’re setting up SSE on a site with years of order history, run wp sse migrate wc-customers --commit once to backfill the table from past orders. New orders are picked up automatically. See Email Migration Guide.


The advanced case: JSON segment query

For composer needs that don’t fit “subscribers on list” / “owners of product”, there’s an Advanced (JSON segment query) option in the audience picker. It accepts a tree of predicates.

A predicate is a JSON object with a type field plus type-specific arguments:

{ "type": "owns_product", "product_id": 456, "include_bundles": true }

Predicates can be combined with all_of, any_of, and not:

{
  "all_of": [
    { "type": "owns_product", "product_id": 456, "include_bundles": true },
    { "type": "not",
      "child": { "type": "suppression", "scope": "product_marketing:456" }
    }
  ]
}

The above reads as: “owners of product 456 (including bundle-mediated), who have not specifically suppressed product_marketing for that product.”

The full predicate set

PredicateWhat it means
all_subscribersEvery subscriber with status=active. The most permissive.
on_listlist_id argument. Subscribers with status=subscribed on that list.
prefers_categoryterm_id argument. Subscribers with prefers_category for that term (including defaulted-in).
owns_productproduct_id + include_bundles flag. As described above.
owns_variationvariation_id. More specific than owns_product — only matches the specific variation.
purchased_in_last_daysdays argument. Made any qualifying purchase recently. Optional product_id narrows it.
purchased_in_windowfrom + to absolute dates. Useful for “bought between 11 and 13 months ago” cohorts.
owned_product_metakey, compare (=, !=, IN, EXISTS), value. Owners of any product whose post-meta matches.
meta_date_withinscope (product_post / order_item / user), key, days_from_now_min, days_from_now_max. Date-window comparator against arbitrary meta. The main tool for “renewal due in the next 30 days” style queries.
has_attributeattribute, value. Free-form: wp_user_role, etc.
suppressionscope argument. Email is suppressed at that scope.
all_of / any_of / notBoolean combinators with children / child.

Common examples

Manual renewal nudge

Send to owners of Scalability Pro whose renewal is due in the next 30 days, including Pack buyers:

{
  "all_of": [
    { "type": "owns_product", "product_id": 456, "include_bundles": true },
    {
      "type": "meta_date_within",
      "scope": "order_item",
      "key": "_ssp_renewal_due_at",
      "days_from_now_min": 0,
      "days_from_now_max": 30
    }
  ]
}

Category for this campaign: product_marketing. The pipeline automatically excludes anyone with product_marketing:456 suppression — they don’t want renewal nudges for this product.

Lapsed customers

Owners of Scalability Pro whose renewal date is in the past year:

{
  "all_of": [
    { "type": "owns_product", "product_id": 456, "include_bundles": true },
    {
      "type": "meta_date_within",
      "scope": "order_item",
      "key": "_ssp_renewal_due_at",
      "days_from_now_min": -365,
      "days_from_now_max": -1
    }
  ]
}

“Bought from us recently and might buy more”

Anyone who’s bought anything in the last 90 days, except people who already own Scalability Pro:

{
  "all_of": [
    { "type": "purchased_in_last_days", "days": 90 },
    { "type": "not",
      "child": { "type": "owns_product", "product_id": 456, "include_bundles": true }
    }
  ]
}

A specific WordPress user role

If your site has subscriber-only premium content:

{
  "type": "has_attribute",
  "attribute": "wp_user_role",
  "value": "subscriber"
}

Automatic suppression masking

Every audience query is wrapped at the top level with a category-appropriate suppression mask. You never need to manually exclude unsubscribed people.

  • Marketing categories (newsletter, list_broadcast, product_marketing): wraps with NOT(suppression: global OR marketing OR category:X OR list:X).
  • Support / onboarding: wraps with NOT(suppression: global).
  • Transactional: no suppression mask — always sends.

So a manual { "type": "all_subscribers" } against a newsletter category really resolves as “all subscribers, minus those who are globally or marketing suppressed.” This is what makes the model safe to use casually.


Snapshotting

When a campaign moves from scheduled (or draft) to sending, the audience is resolved and frozen to a list of subscriber_ids. The send goes to that frozen list, not whoever happens to match the query at the moment each queue row is drained.

Why this matters:

  • If you scheduled a Tuesday morning newsletter, the audience is who matched on Tuesday morning. Adding new subscribers between then and the queue finishing draining doesn’t add them to that batch — they’re picked up by the next run.
  • The recipient count on the campaign report is accurate and stable.

Testing audience size before sending

Useful pattern: compose the campaign, set the audience, don’t hit send. Save as draft. Look at the campaign detail page — it tells you the resolved recipient count after suppression mask.

If the count is unexpectedly small, your suppression mask is biting harder than you thought. If unexpectedly large, your predicate might be too permissive (e.g. you forgot include_bundles: false for a direct-only send).

You can also resolve a segment query directly via WP-CLI to inspect the audience without saving. SSE_Segment::resolve() takes the query array plus the category (which applies the right suppression mask):

wp eval '$q = array( "type" => "owns_product", "product_id" => 6018 ); echo count( SSE_Segment::resolve( $q, "product_marketing" ) );'

(SSE_Campaigns::resolve_audience( $campaign ) is the campaign-level wrapper — it takes a saved campaign object and reads its audience_query + category.)


Future extension: visual segment builder

Currently advanced audiences are JSON-driven. A visual segment builder (drag-and-drop predicates) is on the long-term roadmap (Phase 2G — Polish). For now, the audience picker handles the 90% case, and JSON is available when you need it.


Related

Leave a Reply

Your email address will not be published. Required fields are marked *

×
1/1