Skip to content

REST API Reference

{/* AUTO-GENERATED from ../docs/rest-api-reference.md by scripts/sync-dev-docs.mjs — do not edit by hand. */}

Dino Discounts exposes admin REST routes under /wp-json/dino-discounts/v1/, registered by the per-resource controllers in includes/API/ (wired up by RestAPI::register_routes()). It also attaches cart data to the WooCommerce Store API under extensions["dino-discounts"]. This page lists every registered route. The rule wire shape itself is contracted by the live JSON Schema served at GET /wp-json/dino-discounts/v1/schema — when this prose and the schema disagree, the schema wins.

  • Version: v1 (baked into the namespace — there is no /v2)
  • Base URL: /wp-json/dino-discounts/v1
  • Content type: All request bodies and responses are JSON unless an endpoint declares otherwise (CSV / ZIP / TXT exports).
  • Related reading: Architecture · Hooks reference

  1. Authentication
  2. Conventions
  3. Schema — /schema (live JSON Schema for rules)
  4. Rules — /rules, /rules/diff-summary, /rules/draft, /rules/reset-to-seeded
  5. Usage counters — /rules/{rule_id}/usage/reset, /rules/{rule_id}/per-customer-uses/reset, /usage/reset-all
  6. Global settings — /global-settings, /settings/*, /zones/{id}, /telemetry-consent
  7. Rule snapshots — /snapshots
  8. Cart preview — /preview
  9. Cart scenarios — /cart-scenarios
  10. Coupons — /coupons
  11. Analytics — /analytics/*
  12. Product & taxonomy search — /products/search, /products/resolve-skus, /taxonomy/search
  13. Diagnostics — /performance/log, /cache-compat, /bug-report/*, /telemetry/error
  14. Compatibility Center — /compat
  15. Demo store — /demo-store/seed
  16. Onboarding — /onboarding
  17. Store API extension — wc/store/v1/cart extensions.dino-discounts
  18. Error shape

Almost every route registers AbstractController::check_permission() as its permission_callback, which requires the manage_woocommerce capability. The exceptions:

RoutePermission
POST /telemetry-consentmanage_options
POST /demo-store/seedmanage_options
POST /bug-report/submitLogged in, plus edit_posts (filterable via dino_discounts_bug_report_capability). Rate limited per user; manage_woocommerce users are exempt from the limit.
POST /compat/{id}/probePublic (__return_true). Called by the storefront probe asset; accepts only a scoped probe token (X-Dino-Compat-Probe header) and one enumerated outcome. See Compatibility Center.

Under-privileged requests get the standard WordPress errors (see below).

MethodHeader / paramNotes
Logged-in cookie + nonceX-WP-Nonce: <nonce>The canonical admin path. The plugin localises a fresh wp_rest nonce into window.dinoDiscountsAdmin.nonce (includes/Admin.php).
Application PasswordAuthorization: Basic <base64(user:app_password)>WordPress built-in. Works on HTTPS.
Basic Auth plugin / JWT / OAuth1Per pluginNot bundled — use if you have an existing setup.

The plugin does not add custom CORS headers. If you need to call these endpoints from another origin, configure CORS at the WordPress layer (rest_pre_serve_request) or a reverse proxy.

$nonce = wp_create_nonce( 'wp_rest' );

From JavaScript inside the admin app, use window.dinoDiscountsAdmin.nonce.

StatusError codeMeaning
401rest_forbiddenNot logged in (no cookie + nonce, no application password).
403rest_forbiddenLogged in but missing the route’s capability.
403rest_cookie_invalid_nonceNonce header stale (WP default TTL is 24h via the nonce_life filter) or bound to a different user. Fetch a fresh nonce.

JSON only. Set Content-Type: application/json on every POST/PUT/PATCH/DELETE that carries a body.

There is no single envelope — each route returns a resource-shaped object. Mutating routes typically return { "success": true, ... } plus the updated resource; read routes return the resource directly.

Several routes declare args with a validate_callback. When one fails, WordPress rejects the request before the handler runs with rest_invalid_param (400); a missing required arg is rest_missing_callback_param (400).

Only a few routes paginate:

  • /analytics/discounts — page (default 1), per_page (default 25, max 100); response carries a pagination object.
  • /products/search — page (default 1, max 1000), per_page (default 20, max 100); response carries top-level total / totalPages.
  • /coupons/find — offset, 50 results per page.
  • /coupons/{coupon}/history/{batch_id}/codes — page, per_page (default 50, max 200); response carries total / total_pages.

GET /snapshots returns the 20 most recent snapshots. Other list routes (scenarios, coupons, etc.) return the full collection in one response.

There is no general rate limit. Specific caps:

  • Rule saves are capped at 200 rules (too_many_rules). A plan may also cap the number of active rules (active_rule_cap_reached).
  • Bulk coupon generation is capped at 10,000 codes per request.
  • /preview accepts at most 500 cart rows.
  • POST /bug-report/submit is rate limited per user (429 with Retry-After).
  • POST /compat/{id}/probe is throttled per entry and outcome (429).

Date parameters use YYYY-MM-DD. Analytics date ranges are interpreted against the store timezone (DateTimeHelper::store_now()); the resolved timezone is echoed back in the response under date_range.timezone or timezone.

Every rule sent to or returned by /rules, /rules/diff-summary, /rules/draft, /snapshots/*, and /preview follows the section-shaped rule contract:

  • Header: id, name, is_active (required); exclusive, archived, allow_removal, promotion_id (optional — the sanitiser fills defaults false, false, true, null).
  • Seven required section objects: who, when, where, trigger, rewards, messaging, metadata.
  • Inside who, when, where and trigger, every listed key is required; an unconstrained field is sent as null, not omitted. A rule missing a required key fails validation. The exceptions are trigger.cart_total_qty_threshold and trigger.pools, which are optional.
  • id is a UUID v4 string (the admin app mints it client-side). null or 0 is accepted for a new rule; a legacy positive integer id still round-trips.
  • There is no top-level targeting_tree, no type discriminator (use rewards.kind), and no priority field — priority is the index of the rule in the array; POST /rules writes rules in display / evaluation order.
  • rewards.kind is one of tiered, bulk, pooled_match.

The admin app posts rules as a node tree (promotion nodes with child rules); a plain flat array of rules is also accepted. See the Schema endpoint for the authoritative shape.


The live JSON Schema for rules is served by a dedicated read-only endpoint. Use it as the wire contract — client validation, codegen, and integration tests should target this endpoint rather than scrape examples from this page.

Return the top-level rule JSON Schema (Draft-07) composed from the per-section fragments in includes/Engine/Schema/ (RuleSchemaValidator::rule_schema() — the same schema the save path validates against). Read-only.

Response — 200 OK

{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://dino-discounts/schemas/rule.json",
"title": "Dino Discounts rule",
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "is_active",
"who", "when", "where", "trigger", "rewards", "messaging", "metadata"],
"properties": {
"id": { "oneOf": [ /* null | integer >= 0 | UUID v4 string */ ] },
"name": { "type": "string", "minLength": 1, "maxLength": 200 },
"is_active": { "type": "boolean" },
"exclusive": { "type": "boolean" },
"archived": { "type": "boolean" },
"allow_removal": { "type": "boolean" },
"promotion_id": { "oneOf": [ /* null | UUID v4 string */ ] },
"who": { /* WhoSchema::definition() */ },
"when": { /* WhenSchema::definition() */ },
"where": { /* WhereSchema::definition() */ },
"trigger": { /* TriggerSchema::definition() */ },
"rewards": { /* RewardsSchema::definition() */ },
"messaging": { /* MessagingSchema::definition() */ },
"metadata": { /* MetadataSchema::definition() */ }
}
}

The per-section sub-schemas are served inline — fetch the endpoint once and validate against the resulting document with any standard Draft-07 validator (Ajv, jsonschema, opis/json-schema, …). Passing the schema is necessary but not sufficient: the save path also applies sanitiser checks (for example strict tier ordering and message length caps) and the rule limits above. The admin app’s JS mirror of the schema is src/contracts/ruleJsonSchema.js.


Manage the active discount rule set and its in-progress draft.

List the currently-active rules and any saved draft.

Response — 200 OK

{
// `rules[]` is in evaluation / display priority order — index 0 fires first.
"rules": [
{
"id": "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11",
"name": "10% off Summer",
"is_active": true,
"exclusive": false,
"archived": false,
"allow_removal": true,
"promotion_id": null,
"who": { "roles": null, "is_logged_in": null, "first_time": null,
"min_orders": null, "total_spent": null, "days_since_last_order": null },
"when": { "schedule": { "start": "2026-06-01 00:00:00",
"end": "2026-08-31 23:59:59" },
"weekdays": null, "time_of_day": null },
"where": { "zones": { "match": "any", "values": ["uk", "eu"] },
"currency": { "match": "any", "values": ["GBP", "EUR"] } },
"trigger": {
"spend_threshold": { "operator": "gte", "value": 50 },
"cart_total_qty_threshold": null,
"cart_line_count": null,
"cart_weight": null,
"coupon_ids": null,
"wc_coupon_stacking": "allow",
"usage_limits": null
},
"rewards": {
"kind": "tiered",
"metric": "spend",
"tiers": [
{ "threshold": 50, "reward": { "kind": "percent_off", "value": 10 } }
],
"reward_target": { "target": "cart" }
},
"messaging": { "cart_label": "Summer 10%", "cart_notice": null,
"nudge": null, "storefront": null, "eligibility": null },
"metadata": { "recipe_intent": null }
}
],
"node_tree": [ /* the same rules as a node tree, with promotion nodes */ ],
"usage_counts": { "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11": 42 },
"plan": { /* active-rule cap indicator: active, cap, unlimited, ... */ },
"draft": {
"rules": [...],
"global_settings": {...}
},
"draft_tree": [...]
}

Array order is evaluation / display priority — index 0 is highest. There is no separate priority field. draft and draft_tree are omitted if no draft is saved. usage_counts maps rule id to store-wide redemption count.

Money values in trigger.spend_threshold and spend-tier thresholds are plain decimals in the cart’s currency (no currency key); where.currency is the only currency constraint.

Every field shown above maps to the live /schema document — the example is illustrative, not an alternative contract. Other shapes (rewards.kind: "bulk" | "pooled_match", trigger.pools item pools, rewards.reward_target: { "target": "pools", ... }, trigger.coupon_ids: ["BF20"]) follow the same rules — fetch the schema for the full enum and constraint set.

Messaging placement is store-wide. Where the cart label, spend-more nudge and product-page promo appear is set in Message Settings (the message_settings object on /global-settings), not per rule. The rule schema no longer has the retired per-rule keys messaging.cart_label_visible, messaging.nudge.position_* and messaging.storefront.position. A payload that still carries them has them dropped before validation, so it is accepted and they have no effect. Per rule, messaging.nudge carries one shared pre_trigger.message plus participate (set false to opt the rule out of spend-more nudges).


Overwrite the active rule set. Rules that fail schema validation are skipped and reported; the valid subset is published. An audit snapshot is written; if the snapshot write fails, the save is rolled back. Coupons whose last referencing rule was removed by this publish are archived.

Request body

FieldTypeRequiredNotes
rulesarray✅Section-shaped rules or a node tree (see Rule wire shape and the /schema endpoint). Array order is the evaluation priority — index 0 fires first. Max 200.
snapshot_namestring—Optional name for the audit snapshot.
commentstring—Optional free-text comment attached to the snapshot.

Response — 200 OK

{
"success": true,
"rules": [ /* sanitised rules — same shape as GET /rules */ ],
"skipped": [ { "rule_index": 2, "name": "Draft rule", "errors": [ { "path": "...", "message": "..." } ] } ],
"archived_coupons": [ "SUMMER10" ],
"plan": { /* active-rule cap indicator after the write */ }
}

Errors

StatusCodeWhen
400rest_missing_callback_param / rest_invalid_paramrules is missing, not an array, or has more than 400 entries.
400too_many_rulesMore than 200 rules supplied.
400unknown_rule_fieldThe payload is structurally malformed (for example an unknown key). Nothing is saved.
400rule.schema_invalidEvery rule failed validation, so nothing could be published. data.errors carries the first failing rule’s { path, message } rows; data.skipped lists every skipped rule.
400active_rule_cap_reachedThe publish would raise the number of active rules past the plan’s cap. data carries cap, active, incoming.
500snapshot_failedThe audit snapshot could not be written; the save was rolled back.

Example

Terminal window
curl -X POST "https://example.com/wp-json/dino-discounts/v1/rules" \
-H "X-WP-Nonce: $NONCE" \
-H "Content-Type: application/json" \
--cookie "$WP_COOKIE" \
--data @- <<'JSON'
{
"rules": [{
"id": "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11",
"name": "10% off Summer",
"is_active": true,
"who": { "roles": null, "is_logged_in": null, "first_time": null,
"min_orders": null, "total_spent": null, "days_since_last_order": null },
"when": { "schedule": { "start": "2026-06-01 00:00:00",
"end": "2026-08-31 23:59:59" },
"weekdays": null, "time_of_day": null },
"where": { "zones": null, "currency": null },
"trigger": { "spend_threshold": { "operator": "gte", "value": 50 },
"cart_line_count": null, "cart_weight": null, "coupon_ids": null,
"wc_coupon_stacking": "allow", "usage_limits": null },
"rewards": { "kind": "tiered", "metric": "spend",
"tiers": [ { "threshold": 50,
"reward": { "kind": "percent_off", "value": 10 } } ],
"reward_target": { "target": "cart" } },
"messaging": { "cart_label": "Summer 10%" },
"metadata": { "recipe_intent": null }
}]
}
JSON

POST /rules replaces the whole active set — send every rule you want to keep.

Fires: dino_discounts_rules_saved (and dino_discounts_first_rule_published on a store’s first publish), then dino_discounts_cache_flush.


Compare a proposed rule set against the currently-saved rules without saving. Used by the Publish confirmation.

Request body: same rules array as POST /rules.

Response — 200 OK

{
"diff_summary": "Added: Bulk Discount. Modified: 10% off Summer.",
"skipped_count": 0,
"coupons_to_archive": [ "FALL20" ]
}

diff_summary is a human-readable string. skipped_count is how many rules would be skipped as incomplete; coupons_to_archive lists coupons the publish would archive.

Errors: too_many_rules, unknown_rule_field, rule.schema_invalid (all 400).


Save an in-progress rule edit session (rules + global_settings) so it survives page reloads. Does not affect the live rules. Tier order is checked leniently here (strictly on publish).

Request body

FieldTypeRequired
rulesarray✅
global_settingsobject✅

Response — 200 OK

{ "success": true }

Errors: too_many_rules, unknown_rule_field, rule.schema_invalid (all 400).


Discard the saved draft.

Response — 200 OK

{ "success": true }

Replace the active rule set with the plugin’s starter rules. Destructive; an audit snapshot is written so the previous rules can be restored from History.

Response — 200 OK: { "success": true, "rules": [...], "cleanup": {...} }. cleanup reports the coupons archived by the reset, deleted_usage_counters (count, or null if that step failed), analytics_policy: "retained", and any warnings.

Errors: reset_in_progress (409) — another reset is running.


Clear the redemption counters that back trigger.usage_limits. Rule ids are UUIDs.

Clear the store-wide and per-customer counters for one rule.

Response — 200 OK: { "success": true, "rule_id": "...", "store_rows_deleted": 1, "per_customer_rows_deleted": 12 }.

POST /rules/{rule_id}/per-customer-uses/reset

Section titled “POST /rules/{rule_id}/per-customer-uses/reset”

Clear one customer’s per-customer counter for a rule.

FieldTypeRequiredNotes
user_idintone ofLogged-in customer.
emailstringone ofGuest billing email.
guest_handlingstring—match_by_email (default) or treat_as_first_time; only used with email.

Response — 200 OK: { "success": true, "rule_id": "...", "customer_key": "...", "rows_deleted": 1 }.

Errors: invalid_target (400) — neither or both of user_id / email, or an invalid email.

Clear every rule’s counters.

Response — 200 OK: { "success": true, "store_rows_deleted": 5, "per_customer_rows_deleted": 80 }.


Store-wide discount configuration (stacking mode, discount cap, coupon coexistence, custom zones, performance tracking, Message Settings).

Response — 200 OK

Returns the dino_discounts_global_settings option merged over GlobalSettingsDefaults::DEFAULTS (telemetry consent keys are not included), plus three keys read from their own storage:

  • message_settings — the store-wide Message Settings: applied_notice, label_visibility (cart / mini / checkout), nudge (enable, slot_mini, slot_cart, slot_checkout, …), product_promo, removal, rejection, discount_links.
  • coupon_precedence — dino_first or wc_first.
  • coupon_rejection_messages — merchant overrides for coupon rejection copy.

Request body

FieldTypeRequiredNotes
settingsobject✅Settings object. Omitting custom_zones preserves the existing zones (a missing key is not treated as “clear the zones”). message_settings, coupon_precedence and coupon_rejection_messages are saved to their own storage when present. Telemetry consent keys are ignored here (use /telemetry-consent).

Response — 200 OK

{ "success": true, "settings": { /* sanitised settings */ } }

Errors: rest_missing_callback_param / rest_invalid_param (400) if settings is missing or not an object.

Fires: dino_discounts_settings_saved, dino_discounts_cache_flush.


Read or set whether Dino or WooCommerce resolves a typed coupon code first. GET returns { "precedence": "dino_first" }; POST takes { "precedence": "dino_first" | "wc_first" } and returns { "success": true, "precedence": "..." }.

Read or set the priority of the engine’s cart hook. GET returns { "priority": 20, "default": 20, "min": 16 }; POST takes { "priority": <int> } (clamped to at least 16) and returns { "success": true, "priority": ... }.

Rename a custom zone (also accepts PUT / PATCH). Body: { "name": "..." } (max 100 characters).

Response — 200 OK: { "success": true, "zone": {...} }.

Errors: invalid_params (400), not_found (404), system_zone_immutable (403, the Auto ROW zone), mirror_zone_immutable (403, zones mirrored from WooCommerce shipping zones — rename those in WooCommerce).

Save beta telemetry consent. Requires manage_options. Body is either { "consent": bool, "usage": bool, "errors": bool, "plugins": bool } or { "dino_telemetry_choice": "enable" | "decline" | "customize" }.

Response — 200 OK: { "success": true, "consent": { "consent": ..., "usage": ..., "errors": ..., "plugins": ... } }.

Errors: dino_invalid_consent_request (400).


Historical copies of the rule set written on every POST /rules. Used for audit and restore. Up to 50 unpinned snapshots are kept.

List snapshot metadata (no rule bodies), newest first, 20 most recent.

Response — 200 OK

[
{ "id": 42, "snapshot_name": "Pre-Black-Friday", "comment": "...", "user_id": 1, "user_name": "Mark", "diff_summary": "Modified: 10% off Summer.", "created_at": "2026-04-18 09:00:00" }
]

Fetch a single snapshot including its full rules payload.

Response — 200 OK: the snapshot row with a populated rules array and a promotion_meta map.

Errors

StatusCodeWhen
404not_foundNo snapshot with that id.
500snapshot_corruptThe stored rule data is invalid. Delete and recreate.

Update snapshot metadata. Body accepts snapshot_name (max 200 bytes) and/or comment (max 4000 bytes); omitted fields are left unchanged.

Response — 200 OK

{ "success": true }

Errors: rest_invalid_param (400, over the byte cap), not_found (404), db_error (500).


Delete a snapshot.

Response — 200 OK: { "success": true }.

Errors: cannot_delete (404) — the snapshot no longer exists.


What a restore would change, without writing anything.

Response — 200 OK: { "added": 1, "overwritten": 2, "added_names": [...], "overwritten_names": [...] }.

Errors: not_found (404), snapshot_corrupt (500).


Merge this snapshot’s rules into the active rule set: a snapshot rule with the same id as a live rule overwrites it, a snapshot rule new to the store is added, and live rules not in the snapshot are kept. Nothing is deleted. A new snapshot recording the restore is written.

Response — 200 OK

{ "success": true, "rules": [ /* the merged rule set */ ] }

Errors: not_found (404), snapshot_unrestorable (400) — a snapshot rule no longer passes validation; the active rules are unchanged.

Example

Terminal window
curl -X POST "https://example.com/wp-json/dino-discounts/v1/snapshots/42/restore" \
-H "X-WP-Nonce: $NONCE" \
--cookie "$WP_COOKIE"

Download a snapshot’s full payload without restoring it.

Response — 200 OK: { "format": "dino-discounts/snapshot-export", "format_version": 1, "snapshot": { "id", "snapshot_name", "comment", "created_at", "user_name", "diff_summary" }, "rules": [...], "promotion_meta": {...} }.

Errors: not_found (404), snapshot_corrupt (500).


Run the discount engine against a hypothetical cart with in-memory rules — no session, no persistence. Used by the admin Cart Preview.

Request body

FieldTypeRequiredNotes
cartarray✅Array of { product_id, variation_id, quantity, price, name, isCustom }. Max 500 rows.
rulesarray✅Rule set to evaluate (does not have to match the saved rules).
global_settingsobject—If set, replaces the store’s global settings for this evaluation.
scopestring—all-active-rules (default) or this-rule-draft. With this-rule-draft, an incomplete rule returns a 400; otherwise incomplete rules are listed in skipped.
cart_totalnumber—Ignored — recalculated server-side from cart.
currencystring—Preview currency (e.g. USD). With WCPBC, the country’s zone currency can override it; the response’s effective_currency is what was used.
countrystring—ISO-2 country code.
user_rolestring—Simulate a specific user role (e.g. customer, subscriber).
is_logged_inbool—Simulate a logged-in or guest shopper.
coupon_codesarray of string—Simulated applied coupon codes.
coupon_codestring—Legacy single-code param. Prefer coupon_codes.
order_countint—Simulated customer order count.
total_spentnumber—Simulated customer total spent.
days_since_last_orderint—Simulated history field.
simulate_datestring—Evaluate as if on this date.
simulate_timestring—HH:MM in the store timezone.
day_of_weekstring—Simulated weekday.
debugbool—If true, includes per-rule trace output.

Response — 200 OK

{
"success": true,
"discounts": [
{ "rule_id": "3f2b8c1e-…", "type": "tiered", "name": "10% off Summer", "cart_label": "Summer 10%",
"amount": 3.50, "percent": 10, "reward_type": "percent_off" /* , ... */ }
],
"cart": [ /* sanitised + repriced cart */ ],
"cart_total": 35.00,
"cart_tax": 0,
"effective_currency": "GBP",
"price_format": { "prefix": "£", "suffix": "", "decimals": 2, "decimal_separator": ".", "thousand_separator": "," },
"cart_label_visible": true,
"nudge": null,
"coupon_recognition": { "SUMMER10": "..." },
"skipped": [],
"trace": [ /* only when debug=true */ ],
"global_settings_trace": { /* only when debug=true */ }
}

Each discounts[] entry is an engine discount result; type is the rule’s rewards.kind (tiered, bulk, pooled_match). When WP_DEBUG is defined and truthy, an additional _debug object is included.

price_format is the cart’s money format for effective_currency: the symbol where wc_price() puts it, with the store’s separators and that currency’s precision. cart_label_visible is false when the store hides the discount label on the cart page; the preview then hides its discount rows, as the cart does.

Errors: rule.schema_invalid (400, scope: "this-rule-draft" only), rest_invalid_param (400, cart or rules malformed or over the cap).


Merchant-saveable named cart-preview contexts (items + filters), shared across admins. Stored in the dino_discounts_cart_scenarios option. On first read, the default scenarios are seeded (up to eight, built from the store’s catalogue).

Response — 200 OK

{
"scenarios": [
{
"id": "single-low-value",
"name": "Single low-value item",
"is_default": true,
"items": [ { "product_id": 12, "variation_id": 0, "quantity": 1, "price": 9.99, "name": "Widget", "is_stale": false } ],
"filters": { "currency": "USD", "region": "US", "customerRole": "", "isLoggedIn": false, "couponCodes": [] },
"created_at": "2026-05-12T09:00:00+00:00",
"updated_at": "2026-05-12T09:00:00+00:00"
}
]
}

Each item carries is_stale: true if the referenced product no longer exists in the catalogue.


Create a new scenario. The id is server-assigned.

Request body

FieldTypeRequired
namestring✅
itemsarray—
filtersobject—

Response — 200 OK: { "success": true, "scenario": {...} }.

Errors: invalid_params (400).


Partial update (also accepts POST / PUT). Any of name, items, filters may be omitted — omitted fields are left unchanged. Send expected_updated_at (the updated_at you last read) to get a 409 instead of overwriting another admin’s edit.

Response — 200 OK: { "success": true, "scenario": {...} }.

Errors: invalid_params (400), scenario_not_found (404), scenario_conflict (409).


Delete a scenario (defaults and merchant-saved alike).

Response — 200 OK: { "success": true, "id": "..." }.

Errors: scenario_not_found (404).


Re-seed the default scenarios. Merchant-saved scenarios are preserved.

Response — 200 OK: { "success": true, "scenarios": [...] }.


Manage coupons and bulk code pools used to unlock rules.

The {coupon} path segment matches [a-zA-Z0-9_\-]+. Several responses still carry a campaign key that mirrors coupon.

List coupons.

Query

ParamTypeDefault
include_archivedboolfalse

Response — 200 OK: an array of rows { coupon, campaign, activation_type, coupon_status, total, used, pct, redemptions, expires_at, url_token, ... }. activation_type is single_code, url_token or bulk_pool; coupon_status is active or archived (mirrored as campaign_status).


Create a coupon.

Request body

FieldTypeRequiredNotes
coupon_idstring✅The code: 1–80 characters of A–Z, 0–9, -, _ (uppercased).
activationobject✅{ "kind": "single_code" }, { "kind": "url_token", "token": "..." } (token optional — minted when absent), or { "kind": "bulk" }.
namestring—Display name.
statusstring—active (default) or expired.
usage_limitint—Usage limit. Omitted = unlimited for single_code / url_token; bulk codes default to 1 and are never unlimited.
url_apply_noticestring—Notice shown when the coupon is applied from a link.
bulk_quantityintbulk only1–10000 codes to generate. Codes use the coupon code as prefix.
bulk_expires_atstring—Bulk only. Future datetime in the store timezone.

Response — 200 OK — single_code / url_token

{ "success": true, "coupon": "SUMMER10", "campaign": "SUMMER10", "coupon_entity": { "coupon_id": "SUMMER10", "name": null, "status": "active", "activation": { "kind": "single_code" }, "url_apply_notice": null, "usage_limit": null, "created_at": "2026-04-18 09:00:00" } }

Response — 200 OK — bulk

{
"success": true,
"complete": true,
"partial": false,
"message": "",
"requested": 1000,
"coupon": "SUMMER",
"campaign": "SUMMER",
"generated": 1000,
"attempts": 1024,
"collisions": 24,
"exhausted": false,
"stats": { "total": 1000, "used": 0, "remaining": 1000 },
"coupon_entity": { /* as above */ }
}

If not all codes could be generated due to collisions, complete: false, partial: true, and a message explaining the top-up path are returned.

Errors: invalid_params (400), quantity_too_large (400), campaign_exists (400, an active coupon already has this code), coupon_archived (409, an archived coupon has this code — restore it instead), coupon_string_conflict (409, the code or token is already in use elsewhere), create_failed (500).


Fetch one coupon in the coupon_entity shape above.

Errors: invalid_coupon (400), not_found (404).


Search coupons by code or fragment (case-insensitive). Returns up to 50 ranked matches per page.

Query

ParamTypeRequiredNotes
codestring✅Full code or substring.
offsetint—Default 0.

Response — 200 OK

{
"found": true,
"total": 3,
"results": [ { "coupon": "SUMMER10", "campaign": "SUMMER10", "activation_type": "single_code" } ],
"result": { /* first result, back-compat */ }
}

Errors: invalid_code (400).


Autocomplete rows for the Cart Preview coupon field: one row per single-code / link coupon plus a few unredeemed codes per bulk pool. Each row has code, activation_type and display_label.

Check rule coupon references. Query ids=A,B,C (or ids[]=). Returns only the broken ones: { "broken": { "FOO": "missing", "BAR": "inactive" } }. An empty map means every reference can fire.


Generate a unique random coupon code without saving it.

Query

ParamTypeRequired
prefixstring— (max 20 characters)

Response — 200 OK: { "success": true, "code": "PFX-4F9A-12BD" }.

Errors: code_generation_failed (500).


Check whether a WooCommerce native coupon already owns the given code.

Query

ParamTypeRequired
codestring✅ (else returns clash: false)

Response — 200 OK: { "clash": true, "code": "SUMMER10" }.


Given a snapshot id, list active coupons whose linked rule would stop referencing them if that snapshot were restored.

Query

ParamTypeRequired
snapshot_idint✅

Response — 200 OK: { "orphaned": ["SUMMER10", "FALL20"] }.

Errors: invalid_snapshot (400), not_found (404).


GET lists a bulk pool’s codes: { "coupon_id": "...", "codes": [...] }. POST adds one code: body { "code": "...", "usage_limit": 1, "expires_at": "..." } (usage_limit defaults to 1); returns { "success": true, "coupon_id": "...", "code": "..." }.

Errors: invalid_coupon (400), invalid_activation (400, not a bulk coupon), invalid_params (400), not_found (404), code_already_in_pool (409), coupon_string_conflict (409), create_failed (500).

DELETE /coupons/{coupon}/bulk-codes/{code}

Section titled “DELETE /coupons/{coupon}/bulk-codes/{code}”

Remove one code from a bulk pool. Returns { "success": true, "coupon_id": "...", "code": "..." }.


Generation-batch audit trail for a bulk coupon, annotated with a per-user “downloaded” flag.

Response — 200 OK

{
"batches": [
{ "id": 7, "quantity": 500, "batch_no": 1, "generated_by": 3, "generated_by_label": "Mark", "generated_at": "2026-04-01 12:00:00", "downloaded": true }
]
}

Errors: invalid_coupon (400).


GET /coupons/{coupon}/history/{batch_id}/codes

Section titled “GET /coupons/{coupon}/history/{batch_id}/codes”

Paginated list of one batch’s codes. Query page, per_page (default 50, max 200).

Response — 200 OK: { "batch_id": 7, "page": 1, "per_page": 50, "total": 500, "total_pages": 10, "codes": [...] }.

Errors: invalid_params (400), not_found (404).


POST /coupons/{coupon}/history/{batch_id}/mark-downloaded

Section titled “POST /coupons/{coupon}/history/{batch_id}/mark-downloaded”

Mark a generation batch as “downloaded” for the current user (audit flag).

Response — 200 OK: { "success": true, "batch_id": 7 }.

Errors: invalid_params (400), rest_not_logged_in (401), not_found (404).


Download codes as CSV.

Query

ParamValueEffect
batch_idintExport just one generation batch.
formatinfo or auditInclude batch metadata columns. audit is an alias of info.

Response: text/csv with Content-Disposition: attachment. Not a JSON response — the handler streams bytes and exits.

Errors: invalid_coupon (400), not_found (404).


Download all codes as plain text (one per line). Response: text/plain attachment.

Errors: invalid_coupon (400).


Download a summary CSV of all coupons (one row per coupon).

Response: text/csv attachment, filename dino-campaigns.csv.


Download a ZIP containing the summary CSV plus one CSV per bulk-pool coupon.

Response: application/zip attachment, filename dino-campaigns-export-<date>.zip (the summary inside is campaigns.csv).

Errors: zip_unavailable (500) if PHP ZipArchive is missing; zip_open_failed (500).


Archive a coupon (it stops applying; codes and redemption history are kept).

Response — 200 OK: { "success": true, "coupon": "SUMMER10", "campaign": "SUMMER10" }.

Errors: invalid_coupon (400).


Restore an archived coupon.

Response — 200 OK: { "success": true, "coupon": "SUMMER10", "campaign": "SUMMER10" }.

Errors: invalid_coupon (400), not_found (404), not_archived (400).


Generate more codes for an existing active bulk-pool coupon. Inherits the coupon’s usage_limit.

Request body

FieldTypeRequired
quantityint✅ (1 ≤ q ≤ 10000)

Response — 200 OK: same shape as bulk create (complete, partial, generated, collisions, stats, …).

Errors: rest_invalid_param (400, quantity out of range), invalid_params (400), not_found (404, no active coupon).


Permanently delete a coupon (active or archived) with all its codes and generation-log rows.

Response — 200 OK: { "success": true, "coupon": "SUMMER10", "campaign": "SUMMER10" }.

Errors: invalid_coupon (400), not_found (404).


The read routes accept from / to (YYYY-MM-DD, store timezone) and default to the last 30 days.

Per-rule application counts, discount amounts, gross revenue, time series, and order-value distributions, plus promotion rollups.

Query

ParamTypeDefaultConstraint
fromdatenow − 30dYYYY-MM-DD
todatenowYYYY-MM-DD
per_pageint251–100
pageint1≥1

Response — 200 OK

{
"rules": [
{
"rule_id": "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11",
"currency": "GBP",
"rule_name": "10% off Summer",
"rule_type": "tiered",
"is_active": true,
"promotion_id": null,
"application_count": 120,
"order_count": 95,
"total_amount": 412.50,
"total_gross": 3720.00,
"whole_order_gross": 4100.00,
"aov_with_rule": 39.16,
"new_customer_pct": 22.1,
"new_customers": 21,
"gifts_given": null,
"time_series": [ { "date": "2026-04-01", "application_count": 4, "order_count": 3, "total_amount": 15.00, "total_gross": 120.00, "aov": 40.00 } ],
"distribution": [ { "bucket": 30, "label": "30-39", "count": 12 } ]
}
],
"promotions": [ /* per-promotion rollups */ ],
"ungrouped": { /* rollup of rules outside any promotion */ },
"totals": {
"total_orders_with_discounts": 95,
"total_discount_amount": 412.50,
"total_discount_by_currency": [ { "currency": "GBP", "amount": 412.50 } ],
"total_store_orders": 540
},
"pagination": { "page": 1, "per_page": 25, "total_rules": 7 },
"date_range": { "from": "2026-03-19", "to": "2026-04-18", "timezone": "Europe/London" }
}

Rows are per rule and currency, busiest first: ordered by order_count, then total_amount. gifts_given is the number of orders a free-gift rule’s gift went out on, and null for a rule that gives no gift. total_discount_amount is null when discounts were given in more than one currency — use total_discount_by_currency.

Errors: rest_invalid_param (400) on malformed from / to.


Per-coupon snapshot (codes issued, redeemed, remaining, utilisation%) and a per-coupon daily time series. gifts_given counts the orders in the window that received the gift of a coupon whose discount gives one, and is null otherwise.

Query: from, to (same semantics as above).

Response — 200 OK

{
"campaigns": [
{
"campaign": "SUMMER10",
"rule_id": "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11",
"rule_name": "10% off Summer",
"activation_type": "single_code",
"codes_issued": 1,
"codes_redeemed": 47,
"codes_remaining": 0,
"utilisation_pct": 4700.0,
"last_used_at": "2026-04-15 14:22:01",
"expires_at": null,
"gifts_given": null
}
],
"time_series": {
"SUMMER10": [ { "date": "2026-04-01", "redemptions": 4, "discount_total": 25.00, "discount_by_currency": [ { "currency": "GBP", "amount": 25.00 } ] } ]
},
"totals": {
"total_campaigns": 1,
"total_codes_issued": 1,
"total_codes_redeemed": 47,
"total_discount_given": 235.00,
"total_discount_by_currency": [ { "currency": "GBP", "amount": 235.00 } ]
},
"date_range": { "from": "2026-03-19", "to": "2026-04-18" },
"timezone": "Europe/London"
}

Day-of-week and redemption-velocity insights: { rules, store, logged_split, velocity, dow_windowed, date_range }. Only velocity follows from / to. The day-of-week figures are all-time, which dow_windowed: false signals.


Stream raw event rows as CSV for the same date window.

Response: text/csv attachment dino_analytics_<from>_to_<to>.csv with columns Order ID,Rule ID,Date,Currency,Discount Amount,Gross Revenue,Is New Customer.

Example

Terminal window
# -J (use server filename) requires -O. Combined short form -OJ is portable;
# -O -J or -J -O also work. Some shells glob differently — the combined form
# is safest in zsh.
curl -OJ "https://example.com/wp-json/dino-discounts/v1/analytics/export?from=2026-04-01&to=2026-04-18" \
-H "X-WP-Nonce: $NONCE" \
--cookie "$WP_COOKIE"

Permanently delete every recorded analytics event.

Response — 200 OK: { "success": true, "deleted": 1234 }.

Errors: analytics_delete_failed (500).


Search helpers powering the admin rule-builder pickers.

Query

ParamTypeDefaultNotes
searchstring""Free-text title / SKU. Empty returns first page.
pageint1Capped at 1000.
per_pageint20Capped at 100.
includestring""Comma-separated product IDs to hydrate (bypasses search/page).
categorystring""Comma-separated category slugs.
tagstring""Comma-separated tag slugs.
min_pricenumber—
max_pricenumber—
currencystring""With country, adds a WCPBC zone price to each row when one exists.
countrystring""

Response — 200 OK

{
"items": [
{ "id": 42, "name": "Widget - Blue", "price": "9.99", "sku": "WIDGET-BLUE", "images": [{"src": "..."}], "type": "variation", "stock_status": "instock", "parent_id": 41 }
],
"total": 120,
"totalPages": 6
}

Variation names are rendered Parent Product - Variation Label. Variation rows carry parent_id; rows priced for a WCPBC zone carry zone_price and zone_currency.


Resolve pasted SKUs. Body { "skus": ["A-1", "B-2"] } (up to 500). Each result has a status of resolved, ambiguous (several products share the SKU) or not_found (with a suggestion where one exists).

Response — 200 OK: { "results": [...], "truncated": false }.


Query

ParamTypeRequiredNotes
taxonomystring✅product_cat, product_tag, or any pa_* global attribute taxonomy.
searchstring—Free-text name/slug search (up to 30 results).
includestring—Comma-separated term IDs (product_cat, product_tag) or slugs (pa_*) to resolve by identity. Max 200.

Response — 200 OK

[ { "id": 10, "slug": "accessories", "name": "Accessories" } ]

Errors: rest_invalid_param (400) on an unsupported taxonomy (the detail is invalid_taxonomy); taxonomy_include_too_many (400).


Read accumulated PerformanceMonitor entries (admin REST spans, engine evaluations).

Response — 200 OK: { "entries": [...], "count": 42 } — or { "entries": [] } if monitoring isn’t available.

Clear the log.

Response — 200 OK: { "success": true }.

Check active page-cache plugins for missing compatibility settings.

Response — 200 OK: { "compatible": true, "incompatibilities": [ { "plugin": "...", "missing": [...] } ] }.

The environment summary attached to bug reports (plugin / WP / WC / PHP versions, theme, rules summary, checkout and cart type, HPOS, base currency and country, …). Add ?detailed=1 to include the active plugins list.

Submit a bug report or feedback. Body: title, body, type (bug, idea, general, nps), optional email with email_consent: true.

Response — 200 OK: { "success": true, "delivered": "worker" | "email" | "dropped", ... }.

Errors: rest_forbidden (401/403), rest_too_many_requests (429), rest_rate_limit_unavailable (503), bug_report_invalid (400), bug_report_consent_required (400), bug_report_too_large (413), 502 when delivery fails.

Registered only when a telemetry endpoint is configured, which every release zip is. Accepts an admin JS error (message, source, lineno, kind) and forwards it only when the merchant has opted in to error telemetry. Always returns { "accepted": bool }.


Backs the Dev Tools Compatibility Center.

List the compatibility entries with their detection status and state: { "entries": [ { "entry", "status", "state", "verdict", "probe", "verification", "installed_version", "applying", "toggleable", "needs_acknowledgement", ... } ], "empty": false }.

Turn a fix on or off. Body: { "enabled": true | false, "acknowledge_out_of_range": bool }. Returns the updated entry row.

Errors: dino_compat_unknown_entry (404), dino_compat_not_toggleable (400), dino_compat_invalid_payload (400), dino_compat_not_applicable (409), dino_compat_stood_down (409), dino_compat_needs_acknowledgement (409).

Public. Called by the storefront probe asset to record one measurement. Requires the entry’s probe token in the X-Dino-Compat-Probe header and an outcome of rule-present, rule-absent or subject-absent. It can never turn an entry on or off. Returns { "stored": true }.

Errors: dino_compat_probe_refused (400), dino_compat_probe_throttled (429).


Load the demo store’s products, discounts and coupon. Requires manage_options.

Response — 200 OK: { "success": true, "products": 12, "rule_count": 8, "promotions": 2, "coupon": "..." }.

Errors: demo_store_unavailable (409) — WooCommerce is not active.


Per-admin-user first-run state backing the welcome panel, the sample-rule activation path, the first-success callout and the “skip setup” dismissal. The three HTTP methods share one route in OnboardingController::register_routes().

Storage: per-user WordPress user-meta keyed by dino_discounts_onboarding_state (not the options table) — each admin user has an independent first-run experience. See OnboardingController::USER_META_KEY.

State shape (all fields optional; missing keys fall back to defaults):

KeyTypeNotes
welcome_skippedboolThe admin clicked “skip setup”.
welcome_completedboolWelcome panel finished (either by publishing a rule or explicit done).
first_rule_callout_dismissedboolFirst-publish success callout was closed.
first_publish_tsintUnix seconds — set once when the user first publishes.
activated_tsintUnix seconds — set on first admin-page load. Preserved across a DELETE.

Unknown keys in a POST body are dropped silently.

Return the current user’s onboarding state, merged over defaults.

Response — 200 OK

{
"welcome_skipped": false,
"welcome_completed": true,
"first_rule_callout_dismissed": false,
"first_publish_ts": 1713960000,
"activated_ts": 1713800000
}

Persist a partial update. The incoming state object is merged over the stored blob — callers can update a single flag without losing the rest.

Body

FieldTypeRequiredNotes
stateobject✅Partial state. Keys outside the table above are ignored.

Example

Terminal window
curl -X POST \
-H "Content-Type: application/json" \
-H "X-WP-Nonce: <nonce>" \
--cookie "wordpress_logged_in_*=..." \
-d '{ "state": { "welcome_completed": true } }' \
"https://example.com/wp-json/dino-discounts/v1/onboarding"

Response — 200 OK: { "success": true, "state": { ... merged state ... } }.

Errors: rest_missing_callback_param / rest_invalid_param (400) if state is missing or not an object; onboarding_persist_failed (500).


Reset the current user’s state to defaults. activated_ts is preserved; everything else goes back to defaults.

Response — 200 OK: { "success": true, "state": { ... defaults with preserved activated_ts ... } }.


WooCommerce Blocks cart/checkout and headless clients that consume the Store API get Dino Discounts data through the ExtendSchema mechanism (includes/Cart/StoreApiExtension.php). The plugin does not register any custom Store API routes — it attaches data to the existing cart and cart-item responses and registers one cart/extensions update callback.

Every Store API response that returns the cart, e.g. GET /wp-json/wc/store/v1/cart and the cart mutation routes.

Auth: standard Store API (cart token / Nonce header). No manage_woocommerce requirement — this is the shopper-facing cart.

The cart response gains extensions["dino-discounts"]:

{
"extensions": {
"dino-discounts": {
"discounts": [
{
"rule_id": "3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11",
"rule_name": "Summer 10%",
"kind": "amount",
"discount_type": "tiered",
"percent": 10,
"amount": "350",
"amount_display": "350",
"is_virtual": true,
"show_mini": true,
"held_zero": false,
"allow_removal": true,
"is_coupon_activated": false
}
],
"declined_offers": [],
"removal": { "restore_row": { "cart": true, "checkout": true } },
"chip_config": {
"dino_dd_3f2b8c1e-5d4a-4b6f-9a1e-7c2d0e8f4a11": { "label": "Summer 10%", "visible_cart": true, "visible_checkout": true }
},
"per_item": {},
"gift_product_ids": [],
"gift_item_keys": [],
"nudges_mini": {},
"nudges_cart": {},
"nudges_checkout": {},
"cart_notice": [],
"url_apply_notice": [],
"owned_apply_code": "",
"reject_proxy_codes": [],
"silenced_removals": []
}
}
}

discounts[] — one entry per applied Dino virtual coupon on the cart.

FieldTypeNotes
rule_idstringThe engine rule id.
rule_namestringThe resolved cart label (falls back to “Discount”).
kindstringamount, free_shipping or gift: how the line’s amount cell reads (“Free” unless amount).
discount_typestringMirrors rewards.kind — tiered, bulk, pooled_match.
percentnumber0 for fixed-amount discounts.
amountstringMinor units relative to the cart response’s totals.currency_minor_unit (the Store API convention — divide by 10^currency_minor_unit).
amount_displaystringThe same amount in the cart’s display tax basis, in minor units.
is_virtualbooleanAlways true for Dino auto-applied coupons.
show_minibooleanWhether the label shows in the mini-cart.
held_zerobooleanThe coupon is applied but currently discounts nothing.
allow_removalbooleanWhether the shopper may remove this discount.
is_coupon_activatedbooleanFired by a typed coupon code rather than auto-applied.

chip_config — per-applied-coupon display config keyed by the internal coupon code, CartCoupons::COUPON_PREFIX + rule id (dino_dd_<rule_id>). Each entry is { "label": string, "visible_cart": bool, "visible_checkout": bool }: the resolved cart label plus whether it shows on the block cart and checkout. Visibility comes from the store-wide Message Settings (label_visibility.cart / label_visibility.checkout), not from the rule. Only applied coupons are keyed. This is the only source of block coupon-chip labels: render label in place of the raw dino_dd_ code and honour the visibility flags. See StoreApiExtension::build_chip_config().

Other cart-level keys:

KeyNotes
declined_offersDiscounts the shopper removed this session that still qualify: { rule_id, label, is_gift }.
removal.restore_rowWhether the restore row shows on cart / checkout (from Message Settings).
per_itemPer-cart-item-key discount amounts in minor units.
gift_product_ids / gift_item_keysFree-gift lines.
nudges_mini / nudges_cart / nudges_checkoutSpend-more nudge HTML keyed by the store-wide position for each surface; empty when no rule nudges or the position is hidden.
cart_noticeResolved standalone cart-notice strings, one per applied rule that shows one.
url_apply_noticeDiscount-link rejection messages, consumed on read.
owned_apply_codeSet on an apply-coupon response when the code is a Dino coupon, so the block client can skip WooCommerce’s duplicate snackbar.
reject_proxy_codesTyped codes held as inert reject proxies; render no chip for them.
silenced_removalsCoupon codes whose removal shows no notice.

Cart items gain extensions["dino-discounts"] with is_free_gift, gift_label_hidden_surfaces, gift_removal_locked and gift_rule_id.

POST /wp-json/wc/store/v1/cart/extensions with { "namespace": "dino-discounts", "data": { "action": "reapply_discount", "rule_id": "<rule id>" } } restores a discount the shopper removed. Unknown actions are ignored. The plugin also lets the standard cart/remove-coupon route remove a dino_dd_ coupon.

If the Store API is not available (Automattic\WooCommerce\StoreApi\StoreApi class missing), the extension silently no-ops — the cart response is unmodified.


All errors follow the standard WordPress REST shape — a WP_Error serialized as JSON:

{
"code": "too_many_rules",
"message": "This store has reached the 200-rule limit. Remove or archive an unused rule before saving.",
"data": { "status": 400 }
}

The HTTP status mirrors data.status. Error codes are referenced per endpoint throughout this doc.

WooCommerce is a trademark of Automattic, Inc. WordPress is a trademark of the WordPress Foundation. Dino Discounts is not affiliated with or endorsed by Automattic or the WordPress Foundation.

Contact · Report a bug