Dynamic QR Codes

Developer docs

Dynamic QR Codes is built to be extended. It uses WooCommerce’s own cart and coupon APIs, and from v2.0.0 it has a documented developer contract: six PHP functions, a set of public hooks, WP-CLI commands and a read-only REST route. Everything on this page follows the deprecation policy. There are no external services — QR codes are drawn in the browser, and scans are handled on your own server.

Call the qrcw_*() functions, never the plugin’s classes (QRCW_Codes and friends are internal and change without notice). Features marked Pro need the paid add-on; everything else ships in the free plugin.

Upgrading from 1.x — breaking changes

2.0.0 is a major release. Codes are now identified by their code ID (a row in qrcw_codes), not by the ID of a Media Library image — saving a code no longer stores an image at all. If you built on 1.x, check these:

  • qrcw_cart_tagged — the first argument is now the code ID (it was the attachment ID). It fires with $product_id 0 for a bundle code.
  • qrcw_delete_qr is removed — use qrcw_delete_code( array $row ), which fires before the row is deleted.
  • Removed hooks: qrcw_intercept_scan, qrcw_resolve_attachment_id, qrcw_get_product_codes.
  • Now internal: hooks the 1.x page listed but that aren’t part of the contract — qrcw_settings_defaults, qrcw_sanitize_settings, qrcw_sanitize_qr_design, qrcw_admin_js_data, qrcw_codes_table_records, qrcw_clean_deleted_entities, qrcw_register_submenus, qrcw_fs_loaded. qrcw_clean_orphan_media no longer fires. See Hooks.
  • REST: the GET qrcw/v1/products/{id} response has a new shape (code_id is the identifier, image_url is usually null), and GET qrcw/v1/url is removed. See REST API.
  • PHP: QRCW_Tracker::count_by_attachment() is removed — use qrcw_get_code_stats().
  • JavaScript and markup: the localised objects (qrcwData, qrcwCodes, qrcwCampaigns, …) changed throughout 2.0 and several 1.x keys are gone. Code rows carry data-row-id + data-kind instead of data-attachment-id; the old Generate / Save buttons are replaced by one create button (#qrcw-create-btn, campaigns #qrcw-qr-create). Use the PHP API instead of scraping the admin.
  • Pro Analytics detail URLs use ?code= (was ?qr=); old bookmarks don’t carry over. Print sheets saved as PDF files in 1.x are no longer listed (the files stay in the Media Library).

The database update (schema 3.0) runs automatically on the first admin visit after updating, or with wp qrcw upgrade — never on a customer’s request. Until it has run, the storefront keeps working on the old tables and API writes return qrcw_schema_not_current. A failed step is retried later and never blocks scans. Back up before updating: you can go back to 1.x with your data intact and 1.x codes keep scanning, but codes created in 2.0 don’t scan on 1.x.

2.0.0 is the last release that breaks without notice. From now on, renamed or removed public functions and hooks go through the deprecation policy.

QR URL formats

Every code has a slug — six characters from a–z and 2–9, without o, l or i — and the QR encodes a short URL on your store built from it. The slug never changes, which is why a printed code can be edited later.

FormExampleUsed for
Standard/?qrcw={slug}New codes (default).
Short link/q/{slug}New codes while Short links is on. On a store at the domain root it’s encoded in capitals (YOURSTORE.COM/Q/K7M2XA) for a less dense code. Resolved case-insensitively, with or without a trailing slash.
Copied link/?qrcw={slug}&l=1 · /q/{slug}?l=1The shareable link (Copy link). Visits count as link clicks, not scans, and get utm_medium=link.
Legacy 1.x/?qrcw=1&p={product}[&v={variation}][&c={coupon}] · /?qrcw=1&qr={16-hex}Codes printed with 1.x. They resolve forever, and edits to a migrated code apply to its legacy URL.

Every form resolves whatever the Short links setting says. /q/ is handled before WordPress routing, never through rewrite rules — it needs any permalink setting except Plain, and a page or rewrite at /q/ blocks it. Unknown slugs redirect to the shop. Don’t build these URLs yourself: read url (what to encode) or display_url (what to show people) from qrcw_get_code().

PHP API

Six functions, all @since 2.0.0, defined in includes/qrcw-functions.php. They load on plugins_loaded, only when WooCommerce and the other requirements are met — guard with function_exists( 'qrcw_get_code' ).

No capability checks. Like wp_insert_post(), these functions trust the caller. Before acting on a user’s request, check what the admin checks: edit_products to create or edit a code, plus manage_woocommerce to set a destination or fallback page on another website. Writes return a WP_Error with code qrcw_schema_not_current until the database update has run.

The code array

qrcw_get_code() and qrcw_get_code_by_slug() return a code as an array with exactly these keys — always present, in both editions:

KeyTypeMeaning
idintThe code ID.
slugstringThe short ID in its URL. Never changes.
kindstringproduct, link, bundle or blank.
statusstringactive, retired or unassigned (a blank code).
label, captionstringInternal name; text printed under the code.
coupon_codestringCoupon applied on scan ('' = none).
product_id, variation_idintWhat a product code adds (0 for other kinds / no variation).
itemsarrayA bundle’s products: [{ product_id, variation_id, qty }] ([] otherwise).
target_urlstringA link code’s destination.
fallback_urlstringWhere a retired code sends scans ('' = product page, else shop).
campaign_idintThe campaign it’s in now (0 = none).
utm_contentstringIts utm_content tag.
show_on_ordersboolListed in every order’s QR box Pro.
urlstringThe exact string the QR encodes (may be uppercase for a short link — encode this one).
display_urlstringThe same address in lowercase, for people to read.
link_urlstringThe shareable link (counts as a link click).
attachment_idintIts Media Library image (0 = none; images are optional).
created, updated_atstringSite time, Y-m-d H:i:s.

campaign_id, fallback_url, utm_content and show_on_orders are written through the Pro add-on; in the free plugin they read as stored and can’t be changed.

qrcw_get_code() · qrcw_get_code_by_slug()

qrcw_get_code( int $id ): ?array
qrcw_get_code_by_slug( string $slug ): ?array

One code as the array above, or null. The slug lookup is case-insensitive, and a 1.x code’s 16-character token works too.

$code = qrcw_get_code_by_slug( 'k7m2xa' );
if ( $code && 'active' === $code['status'] ) {
    echo esc_url( $code['display_url'] );
}

qrcw_get_code_url()

qrcw_get_code_url( int $id, bool $display = false ): string

The URL the QR encodes, or with $display the human-readable form. Returns '' for an unknown ID.

// Render your own QR image of code 42.
$data = qrcw_get_code_url( 42 );

qrcw_create_code()

qrcw_create_code( array $args ): int|WP_Error

Creates a code, validated like the admin screens, and returns its ID. The code gets a fresh slug and the site’s default design; no image is stored. Fires qrcw_code_created.

ArgumentMeaning
kindproduct (default). Pro link, bundle and blank — in the free plugin they return qrcw_kind_unavailable.
product_id, variation_idProduct codes. A variation’s ID on its own in product_id works; a variation_id must belong to the product.
target_urlLink codes: the http(s) address it opens (required).
itemsBundle codes: [{ product_id, variation_id?, qty }], 1–20 lines, qty 1–99, purchasable products.
coupon_codeAn existing coupon. Never on a blank code or with a link to another website.
label, captionOptional text.
campaign_id, fallback_url, utm_content, show_on_ordersPro Ignored in the free plugin.

Error codes: qrcw_schema_not_current, qrcw_bad_kind, qrcw_kind_unavailable, qrcw_product_missing, qrcw_variation_mismatch, qrcw_coupon_missing (“That coupon doesn’t exist.”), qrcw_coupon_external, qrcw_target_required, qrcw_bundle_*, qrcw_campaign_missing, and qrcw_free_limit — the free plugin allows one code per product or variation (retired codes count).

$id = qrcw_create_code( array(
    'product_id'  => 123,
    'coupon_code' => 'summer10',
    'label'       => 'Shop window',
) );
if ( is_wp_error( $id ) ) {
    error_log( $id->get_error_code() . ': ' . $id->get_error_message() );
}

qrcw_update_code()

qrcw_update_code( int $id, array $changes ): true|WP_Error

A partial change, validated like the Edit QR code dialog. Free keys: label, caption, coupon_code (must exist), status (active / retired). Pro keys: product_id / variation_id, items, target_url, fallback_url, utm_content, show_on_orders, campaign_id. Unknown keys, keys that don’t fit the code’s kind, and the slug are ignored. A blank code leaves unassigned only by being assigned in the admin. Returns qrcw_code_missing for an unknown ID. Fires qrcw_code_updated (and qrcw_code_status_changed) once, only when something actually changed.

if ( current_user_can( 'edit_products' ) ) {
    $result = qrcw_update_code( 42, array( 'status' => 'retired' ) );
}

qrcw_get_code_stats()

qrcw_get_code_stats( int $id, array $args = [] ): array

Returns exactly { scans, unique_scans, link_clicks, conversions, net_revenue }, counting human visits only — the same figures as Analytics. Optional from / to (Y-m-d, inclusive, site time). conversions and net_revenue (completed orders less refunds, store currency) are Pro and read 0 in the free plugin. A deleted code’s history still counts.

$june = qrcw_get_code_stats( 42, array( 'from' => '2026-06-01', 'to' => '2026-06-30' ) );
printf( '%d scans, %d unique', $june['scans'], $june['unique_scans'] );

Hooks

The hooks below are the public ones — part of the contract and covered by the deprecation policy. Hooks marked Pro only fire when the Pro add-on is active. Every other qrcw_* hook is internal (admin rendering, tabs, modals, the link between the free plugin and the add-on) and may change in any release without notice — don’t build on them.

Actions

HookSinceArgumentsFires
qrcw_code_created2.0.0int $code_id, array $rowOnce per new code, after the insert, whichever screen or function made it.
qrcw_code_updated2.0.0int $id, array $changes, array $old_rowOnce after any change to a code (edit dialog, bulk Retire / Reactivate, assigning a blank code, qrcw_update_code(), Show on orders, Add to Media Library, a deleted campaign releasing it). $changes holds only the columns that changed. Never on a no-op or on create.
qrcw_code_status_changed2.0.0int $id, string $new, string $oldRetire, reactivate, assign (unassigned → active), right after qrcw_code_updated.
qrcw_delete_code2.0.0array $rowBefore a code row is deleted (the row is still readable). Replaces 1.x qrcw_delete_qr.
qrcw_scan_recorded1.0.0array $args, int $scan_idAfter every scan row is written, human or bot, any result. 2.0.0: $args gains code_id, is_bot, via.
qrcw_cart_tagged1.0.0int $code_id, int $product_id, string $coupon, int $scan_idA human scan left the visitor able to buy. 2.0.0: first argument is the code ID (breaking); $product_id is 0 for a bundle. Not for bots, external links, retired or blank codes.
qrcw_product_deleted1.0.0int $product_idAfter a permanently deleted product’s codes are removed (scans and conversions are kept).
qrcw_purge_data1.0.0—After Reset everything (Settings → Cleanup).
qrcw_conversion_linked Pro1.0.0int $scan_id, int $order_idOnce, when a paid order is first credited to a scan.
qrcw_conversion_updated Pro1.0.0int $order_idA credited order changed (refund, cancel, fail, complete).
qrcw_campaign_deleted Pro1.0.0int $campaign_id, bool $delete_codesAfter a campaign is deleted.
qrcw_all_campaigns_deleted Pro1.0.0bool $delete_codesAfter Delete all campaigns.

Filters

HookSinceArgumentsChanges
qrcw_client_ip1.0.0string $raw_ipThe IP to hash (e.g. from a trusted proxy header). Only the hash is stored.
qrcw_bot_user_agents2.0.0string[] $signaturesLowercase user-agent substrings treated as bots and link previews. Don’t add a bare bot — it matches “Cubot” phones.
qrcw_scan_nocache_headers2.0.0array $headersHeaders (name => value) sent with every scan response.
qrcw_scan_datalayer2.0.0array $payload, array $rowThe qr_scan dataLayer object (see Privacy). Return an empty array to send nothing.
qrcw_codes_table_columns1.0.0array $columnsAll QR Codes columns (key => label).
qrcw_codes_table_column_html1.0.0string $html, array $item, string $columnA cell’s markup ('' = default). 2.0.0: $item is a code row plus scans, unique_scans, link_clicks, visits, product_name.
qrcw_codes_table_row_actions2.0.0array $actions, array $itemRow actions. Core keys: edit_code, copy_link, status, download (one Download menu), delete; Pro adds assign on blank codes.
qrcw_codes_table_bulk_actions2.0.0array $actionsBulk actions. Core: qrcw_retire, qrcw_reactivate, delete, qrcw_site_design; Pro adds qrcw_print_selected, qrcw_show_on_orders, qrcw_hide_from_orders.
qrcw_codes_header_actions2.0.0string $htmlButtons after the All QR Codes search box (core adds Create QR code at priority 5).

The $item / $row arrays in the table and dataLayer filters are raw code rows — extra columns may appear. Read stable fields through qrcw_get_code( $item['id'] ).

Example — send retired codes to a team channel:

add_action( 'qrcw_code_status_changed', function ( $id, $new, $old ) {
    if ( 'retired' !== $new ) {
        return;
    }
    $code = qrcw_get_code( $id );
    my_notify_team( sprintf( 'QR code %s was retired.', $code['display_url'] ) );
}, 10, 3 );

Deprecation policy

From 2.0 on, a public function or public hook that is renamed or removed keeps working for at least one minor release, with a notice through _deprecated_function(), do_action_deprecated() or apply_filters_deprecated() — turn on WP_DEBUG to see them. Every deprecation is listed in the changelog. The return shapes documented on this page are part of the contract. Internal hooks, classes, AJAX actions and JavaScript objects are exempt.

WP-CLI

Two commands, in the free plugin — handy for hosts and agencies after a deploy.

CommandWhat it does
wp qrcw statusPlugin version; the version the last successful database update ran for (with a hint when it hasn’t run for this version yet, e.g. after a rollback and re-update); schema version and whether it’s current; the last failed update step; Short links on / off; codes by status.
wp qrcw upgradeRuns the database update now, ignoring the 10-minute wait after a failure. Prints each step with ok / FAILED and exits 1 when a step fails or another update holds the lock.
$ wp qrcw status
Plugin version:     2.0.0
Last upgrade for:   2.0.0
Schema version:     3.0 (this release needs 3.0)
Schema current:     yes
Last failed step:   none
Short links:        off
QR codes:           12 active, 2 retired (14 total)

REST API

Namespace qrcw/v1, read-only, one route. It requires the edit_products capability and works with WooCommerce REST API keys. Writes go through the PHP API.

GET /wp-json/qrcw/v1/products/{id}[?status=active|retired|unassigned]

Returns { product_id, count, codes }: the product’s own codes, then the bundle codes that add it. Each item:

FieldMeaning
code_idThe identifier (not attachment_id, as in 1.x).
kindproduct or bundle.
slug, statusAs in the code array.
qr_urlThe exact encoded string (uppercase for a root-install short link).
display_url, link_urlThe readable address; the shareable link.
coupon_code, variation_id, label, createdAs stored.
itemsA bundle’s products ([] for a product code).
scans, unique_scansScan counts.
attachment_id, image_urlThe Media Library image — image_url is null unless the code has one. Codes saved in 2.0 have none until Add to Media Library.
design{ settings, design, caption } — enough to render qr_url yourself.

GET qrcw/v1/url is removed in 2.0: every code now has its own slug URL, minted when it’s created — read qr_url, or call qrcw_get_code_url(). Pro adds no REST routes.

Admin AJAX & JavaScript

The admin screens talk to the server through wp_ajax_qrcw_* actions (nonce qrcw_admin) and localised JavaScript objects such as qrcwData and window.qrcwRender. These are internal, not a public contract — they changed throughout 2.0 and will keep changing. Use the PHP API instead.

Data & storage

Five custom tables (with your table prefix). Schema version 3.0, stored in the qrcw_db_version option. Read the tables for reporting if you need to, but write through the PHP API.

TableHolds
qrcw_codesOne row per code. New in 2.0: slug (unique), status, fallback_url, utm_content, updated_at, items (a bundle’s products, JSON), show_on_orders, url_form (query / path / path_uc; never changes), legacy_key / legacy_url (a migrated 1.x code’s old address).
qrcw_scansThe scan log. New: code_id, is_bot, via (qr / link). campaign_id is the campaign the code was in at scan time — use it, not qrcw_codes.campaign_id, for attribution.
qrcw_conversionsPro Orders credited to scans. New: code_id; campaign_id copies its scan’s.
qrcw_campaignsPro Campaigns. New: utm_source, utm_medium, utm_campaign, utm_term.
qrcw_print_sheetsPro New: saved print layouts (settings, not PDF files).
  • Settings live in the qrcw_settings option. New keys: utm_enabled (default on), utm_source (qr), utm_medium (print), scan_datalayer (on), short_links (off).
  • Options: qrcw_short_links_used (set once a short-link code exists, so /q/ keeps resolving after the setting is turned off), qrcw_version, and the update bookkeeping qrcw_upgrade_*.
  • Meta: user meta qrcw_onboarding (the Get started panel); attachment meta _qrcw_design_hash on images added with Add to Media Library.
  • Images: optional. Deleting a code deletes its image only if the plugin created it; files you uploaded are never deleted.
  • Uninstall (deleting the plugin) drops the tables and removes the options, meta and the images the plugin added.

Caching

A cached scan response swallows the scan and skips the add-to-cart, so every scan response (?qrcw= and /q/) sends Cache-Control: no-store, private plus WordPress’s nocache_headers(), and defines DONOTCACHEPAGE for page-cache plugins. Adjust the headers with qrcw_scan_nocache_headers.

Landing marker. The page the scan redirects to is a normal, cacheable page, so a cached copy could drop the scan’s notice and the qr_scan push. When a human scan lands on a same-site page other than the cart or checkout, the redirect appends a one-off qrcw_n=<6 chars> parameter (as text, so the destination’s own encoding is untouched), which makes page caches treat it as a fresh URL. A small script in wp_head removes it from the address bar. It is never added to the cart, the checkout, a bot’s redirect or an external destination. Exclude qrcw_n in analytics tools that report query parameters.

Caches that answer before PHP runs — host-level caching, a CDN, Varnish, LiteSpeed rules — never see those headers in time. Exclude /q/ and any URL with ?qrcw= from them.

Privacy

  • QR codes are drawn in the browser — nothing is sent to a third-party service.
  • Scan IPs are stored only as an HMAC-SHA256 hash, never in the clear (override the source IP with qrcw_client_ip). Unique scans count distinct hashes per code per day.
  • Bots and link previews are detected by user agent and request method (qrcw_bot_user_agents). They’re logged with is_bot = 1 and redirected without a cart, session, coupon or UTM tags.
  • Cookie qrcw_scan — a 10-minute, HttpOnly cookie set by a human scan that stays on your site. It holds {code_id}.{qr|link}.{kind}, no personal data, and is cleared by the next page view. It’s declared in the plugin’s privacy-policy text.
  • dataLayer — that page view prints one push in <head>: { event: 'qr_scan', qr_code, qr_label, qr_campaign, qr_kind, qr_via } (qr_kind = product|bundle|link|retired|unassigned, qr_via = qr|link). The plugin never loads Google Tag Manager or Analytics itself — your tag setup and consent banner decide what’s sent. Turn it off in Settings → Links & tracking, or change it with qrcw_scan_datalayer. A page served from a full-page cache delays the push to the next uncached page.
  • It implements the WordPress Privacy API exporters and erasers for the scan log; Pro conversions are anonymised so aggregate revenue figures survive.

Building an integration and need a hook that isn’t here? These are the stable, documented entry points for v2.0.0 — ask on the plugin’s WordPress.org support forum and we’ll point you at the right one.