Every hook on this page exists in both the free version and Pro unless it’s marked . The tables, the REST namespace and the stored values are identical too.
Terms used on this page
Safety-clamped: a limit is re-applied after your filter runs, so a listener can’t produce a nonsensical result. Named per hook. Additive only: add keys freely, and the plugin’s own are restored afterwards. Removing or rewriting one breaks the feature rather than extending it. Seeded conservatively: the value with no listener attached is the cautious one. Pro attaches at priority 10 like anyone else, so a listener at a later priority still has the last word.Lifecycle actions
Every “this happened” hook has a counterpart for when it’s undone, so a listener can clean up what it granted without polling.cartpresets_instance_removed_from_cart fires whenever a preset leaves the cart as a whole: after a locked line is removed, when a preset is taken out because it’s no longer whole, and when one is taken out because an item changed under it.
cartpresets_after_redemption_recorded fires once per claim, even when two requests complete the same order at once.
cartpresets_redemptions_released is the counterpart to after_redemption_recorded. An order leaving the counted statuses gives its slot back, so a listener that granted something on record can revoke it on release.
cartpresets_carrier_product_created fires both when the plugin creates a carrier product and when a merchant points it at an existing one. A listener needs to know which product is the carrier, so one hook covers both cases.
Worked example: grant a membership when a preset is bought
Pricing
cartpresets_item_source_price changes the price the discount is worked out from: the product’s regular or sale price, before any preset discount is applied. It runs before the discount formulas, so what you return is discounted exactly as a real price would be.
cartpresets_item_unit_price is the last word on what a line costs. Safety-clamped: max( 0, ... ) is re-applied afterwards, so no listener can produce a negative price and hand the customer money.
cartpresets_item_subscription_terms decides how far a preset discount reaches into a subscription, as [ 'payments' => int, 'waive_fee' => bool ].
paymentscounts the payments the discount covers, including the one being made.0holds the preset’s price on every payment for the life of the subscription.1buys the first payment and bills renewals at the product’s own price.3is one purchase and two discounted renewals.waive_feezeroes the product’s sign-up fee for that line.
payments => 0 (every payment), fee charged. Honouring the item’s stored payment limit and fee waiver is Pro, and Pro attaches at priority 10 like anyone else, so a listener at a later priority still has the last word. Both settings are stored on either tier.
Safety-clamped: a returned array with no payments key keeps the seed, so a listener that only answers waive_fee can’t cut the discount short by accident. The value goes through absint().
A limit of 1 is applied by shared pricing, on both tiers. A limit of 2 or more is put back to the full price after N payments by Pro, which the free version doesn’t have. So on free, a listener returning 2 or more gets a discount on every payment.
Cart
cartpresets_cart_item_data is additive only. Append your own keys freely; the plugin’s are re-applied afterwards. The integrity rules read them, and a filter rewriting an instance ID would break preset isolation for every other line in the cart.
cartpresets_cart_group_id is applied on both sides of every comparison. Override it and “the rest of this preset” changes meaning everywhere, including away from the trigger line. A wider group has to hold every item any of its lines requires, or it’ll be taken out as incomplete. The same id is what a checkout holds, what the order line is stamped with and what the redemption row records, so one group counts once against a purchase limit. It’s cut to 64 characters, the width of the column that stores it.
Every preset line carries the IDs of the items its preset can’t be without. A cart loaded from the session loses any preset that isn’t whole, and a line with no such list counts as not whole. Every line also needs its preset item to still exist and point at the same product and variation; one that doesn’t takes its preset out with the cascade_removed notice.
cartpresets_clear_cart decides the outright clear: the whole cart, the customer’s own shopping included. It’s seeded with the resolved answer: the store’s Clear the existing cart, or the preset’s own when its cart_override is on. It fires once per link, before the limit check, so the filter and the removal can’t reach different conclusions. The two narrower settings it outranks are only read when it resolves false.
A preset answers all three cart questions or none of them. cart_override decides which level is read, and clear_cart, allow_duplicate and keep_existing are its answers when it’s on, so one screen tells you how a cart will behave.
cartpresets_preset_coupon is seeded empty, and shared code never reads the stored setting itself. With nothing attached, a preset applies no coupon. Honouring the stored code is Pro. The code comes off again once no line of the preset that applied it is left in the cart. A code the customer applied themselves is left alone.
cartpresets_post_add_url is safety-clamped against the host allow-list.
cartpresets_notice_text receives one customer-facing sentence before its values are put in, with the notice ID and whether this is the plural form of a pair. See Cart notices and wording.
Availability and eligibility
cartpresets_item_is_valid is additive only in the restrictive direction. It can turn a valid item invalid (a membership gate, a regional rule), but it can’t rescue one the plugin has already hard-failed. A deleted product or a missing carrier would make the cart line broken rather than merely unwanted, so the filter isn’t applied at all in those cases.
cartpresets_eligibility is seeded allowed => true, and every verdict comes from a listener, including the plugin’s own. On free nothing is attached, so the verdict stays yes.
It’s asked when the link is followed and again at every way into payment: the classic checkout, the Blocks checkout and order-pay. Your listener should give the same answer at checkout as it did at the link. Call PresetUrlHandler::eligibility( $preset ) to ask the same question yourself.
Worked example: a regional rule
Tier
cartpresets_preset_premium_fields covers created_at, end_at, max_total, max_per, refused_action, refused_url, refused_notice and coupon. cartpresets_premium_settings covers one store-wide key: redemption_statuses.
Narrowing which order statuses use up a slot makes a stored cap count fewer orders than the merchant asked it to, which puts a sold-out run back on sale, so it needs Pro. Where a refused customer lands can’t do that, however it’s set, so the store-wide refusal setting ships free. The per-preset one needs Pro, because a downgrade shouldn’t send a customer somewhere other than where the merchant chose.
Neither is a validation step. A rejected value is replaced by the stored one without an error, and the rest of the same save goes through.
Schema
disc_type means underneath the pricing engine.
Writes walk the schema rather than the payload, so a key you haven’t registered here is never saved, whatever a request contains.
Identity and keys
cartpresets_generated_slug runs before uniquification, and its result is sanitised. A listener shapes the slug; it can’t break the URL.
cartpresets_redemption_customer_key decides how “the same customer” is recognised for per-customer limits. Default: user:<ID> for a signed-in customer, and email:<billing email>, lower-cased, for a guest. Return a stable key per real person.
The two built-in forms answer for each other when a per-customer cap is counted. user:<ID> also counts claims under that account’s email and billing email, and email:<address> also counts claims by an account with that email or billing email. A key of any other shape stands alone.
cartpresets_carrier_product_id points custom items at a carrier product other than the configured one. It also decides which product can’t be bought on its own: ?add-to-cart= and the Store API refuse it, and so does checkout. Safety-clamped: whatever you return still has to pass the same existence and purchasability checks as a configured ID, so an invalid override is rejected the same way an invalid setting is, instead of producing a broken cart line.
Purchase limits
max_total and max_per, until its order reaches a counted status, is cancelled, fails or is refunded, or the hold runs out. Safety-clamped to at least one minute.
$context says which hold you’re setting:
cartpresets_redemption_limit_exceeded fires with $limit_type set to max_total or max_per when a limit refuses a purchase at checkout. It fires with paid_over_limit when a payment arrives after its hold ran out and takes a preset past its limit. That order is counted, not refused, and gets an order note.
Listing
cartpresets_list_args is the listing’s resolved query arguments, after defaults and before any SQL is built. Every key is always present: search, status (all, one derived status, a comma list or an array), sort (name, status, items, price, revenue, orders or publish) with dir (asc or desc), per_page, page, and filters.
search matches a preset’s name, slug and note, and the products inside it: product name, variation name, either one’s SKU, or a custom item’s name.
filters takes either the encoded query-string form (field~op~value rows joined by |, each value percent-encoded) or an array of { field, op, value }:
The first six are the ones the listing’s Filters popover offers. The rest are accepted from code.
price is what the customer pays today, worked out from live product prices. revenue is WooCommerce’s net revenue on the order lines the preset was stamped on: the same figure the listing’s Revenue column shows. Both are applied after the SQL rather than inside it. order_revenue is also accepted and means revenue.
items counts a preset’s items; orders counts its redemptions. date compares the publish date in whole days, and every bound is inclusive: after includes its own day, before runs to the last second of its day, and a between written the wrong way round is read as the range you meant. A full date-time is read as its day. product makes the same match the search makes.
What you return is validated exactly like a caller’s own arguments. An unknown sort key falls back, statuses outside the ladder are dropped, rows outside the allow-lists are dropped, and every value still reaches SQL through prepare().
The REST route takes at most 8 filter rows, and at most 3 from product, product_name and stock_problem together. Those limits are the route’s, so rows a listener adds here aren’t counted against them.
cartpresets_listing_revenue is how the listing learns revenue. It starts as null, meaning revenue isn’t available. A listener returns an array of preset ID to net revenue as a float, and a preset missing from it counts as zero. It fires at most once per request, and only when a listing query sorts or filters by revenue. The listener returns data, not SQL.
Pro answers it from WooCommerce’s Analytics lookup tables, with the same query the Revenue column’s figures come from. With nothing answering, as on the free version, the /presets route refuses a revenue sort or filter with a 400. PresetRepository::get_list() called directly falls back to the default sort and drops the revenue rows.
Admin picker
simple, variable, subscription, variable-subscription.
External, grouped and composite types are left out because they can’t be added to a cart by product ID. Only add a custom type here if it can.
The admin panel
cartpresets_admin_boot is everything the React panel is handed at boot: REST base, the store’s clock and currency, the settings, the status ladder, the filter fields, the tier, the store-wide preset counts, the outbound links and the wording catalogue. Append a key and your own screen can read it without a second request on every page load. The boot payload lists every key the panel is given.
It’s additive only. A listener that removes or rewrites an existing key breaks the panel, and nothing downstream checks.
Premium code also uses this seam to publish state that shared code must not know about. Pro adds a licence key, with the licence’s status and expiry, so the licence card opens in the right state before its own request answers. Shared code can’t name a class the free version doesn’t ship.
cartpresets_admin_links is every address the panel links out to, handed over as the boot payload’s links:
Change any value, or add a key for a screen of your own. A value that isn’t a URL goes back to the default.
Order and display
cartpresets_order_line_item_meta is additive only. cartpresets_custom_item_shipping_data and cartpresets_admin_capability are safety-clamped.
cartpresets_badge_text rewords the “part of a preset” badge. One filter covers all four places it appears: $context is cart, checkout, order or email. $name is the preset name; in the order and email contexts that’s the name as it was snapshotted when the order was placed, not the preset’s name today.
cartpresets_preset_url shapes output only. It doesn’t affect how an incoming request is parsed. That read path is separate and this filter doesn’t rewire it, so changing one keeps the other working.
Reporting
cartpresets/v1/sales routes aren’t registered at all. Import and export work the same way.
Updates
https://smartwpplugins.com/updates/cartpresets-for-woocommerce-pro.json. Point it elsewhere to run a site on a pre-release channel, or return an empty string to switch automatic updates off for that site entirely.
Fired from the admin and cron only; the storefront never reaches it. The licence is checked before the manifest is fetched, so a filter returning a different URL changes which release a licensed site is offered. It doesn’t bypass licensing.
The request is a GET with site and version in the query string, and the licence key in an X-CartPresets-Licence header. That lets the update host check the licence before it serves a download. It’s never in the URL, because URLs end up in server and CDN logs.
What is deliberately not exposed
These protect the store, so no hook can weaken them:- No filter bypasses a capability or nonce check.
- No filter turns a hard validation failure back into a valid item.
- No filter bypasses purchase-limit enforcement at either stage.
- No filter disables cascade removal or weakens instance isolation.
- No filter sits between a query and
$wpdb->prepare(). - No PHP filter registers anything the React admin renders.