Product Audiences & Segments
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.
Table of Contents
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
| Goal | Setting |
|---|---|
| Security notice — must reach everyone affected | Include bundle owners |
| Renewal-style nudge — bundle owners see a different version | Direct buyers only (compose a separate campaign for bundle owners) |
| Cross-sell of an unrelated product | Either — depends on your messaging |
How ownership is determined
A subscriber is recorded as owning a product when:
- They complete a WooCommerce order containing a line item for that product. The hook is
woocommerce_order_status_changedintocompletedorprocessing. - 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 --commitonce 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
| Predicate | What it means |
|---|---|
all_subscribers | Every subscriber with status=active. The most permissive. |
on_list | list_id argument. Subscribers with status=subscribed on that list. |
prefers_category | term_id argument. Subscribers with prefers_category for that term (including defaulted-in). |
owns_product | product_id + include_bundles flag. As described above. |
owns_variation | variation_id. More specific than owns_product — only matches the specific variation. |
purchased_in_last_days | days argument. Made any qualifying purchase recently. Optional product_id narrows it. |
purchased_in_window | from + to absolute dates. Useful for “bought between 11 and 13 months ago” cohorts. |
owned_product_meta | key, compare (=, !=, IN, EXISTS), value. Owners of any product whose post-meta matches. |
meta_date_within | scope (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_attribute | attribute, value. Free-form: wp_user_role, etc. |
suppression | scope argument. Email is suppressed at that scope. |
all_of / any_of / not | Boolean 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 withNOT(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.
- Sending Campaigns — the audience picker UI
- Email Categories & Unsubscribes — the suppression mask that wraps every query
- WooCommerce Integration — how ownership data feeds into segments
- Email Migration Guide — backfilling ownership from existing WC orders