Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
**Contributors:** octanist
**Tags:** tracking, analytics, forms, leads, conversions
**Requires at least:** 6.0
**Tested up to:** 7.0
**Stable tag:** 4.0.1
**Tested up to:** 7.1
**Stable tag:** 4.1.0
**License:** GPLv2 or later
**License URI:** https://www.gnu.org/licenses/gpl-2.0.html

Expand All @@ -20,6 +20,7 @@ The plugin acts as a first-party proxy for the Octanist pixel:

- **Pixel proxy:** The tracking script is served from your own WordPress site at `/wp-json/oct/p` from a local cache, with refreshes handled in the background.
- **Event proxy:** Events from the pixel POST to `/wp-json/oct/e`; the plugin forwards them immediately with a one-second upstream timeout so onboarding and live reporting stay responsive. Pixel failures are not persisted in WordPress, which prevents an upstream outage from filling the site database.
- **Call tracking proxy:** The pixel can request a dynamic number at `/wp-json/oct/call-tracking/assign`. The plugin forwards that request to Octanist so website numbers stay first-party.
- **Server-side form capture:** Form submissions from supported plugins are captured via server-side action hooks and forwarded synchronously with a longer timeout. Confirmed failures are queued for retry.

Supported form plugins:
Expand All @@ -46,6 +47,13 @@ Listener mode and consent mode are optional settings.

## Changelog

### 4.1.0

- Added an optional Call tracking setting, off by default.
- Added a first-party proxy for call tracking assignment (`POST /wp-json/oct/call-tracking/assign`).
- Setup codes can include a call tracking flag (`OCTA1.OCT-XXXXXXXX.s.a.t`).
- Tested up to WordPress 7.1.

### 4.0.1

- Pixel serving now uses the local cache immediately and refreshes upstream in the background.
Expand Down
34 changes: 34 additions & 0 deletions includes/class-octanist-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ class Octanist_Api
const COOKIE_PREFIX = 'octa_';
const PIXEL_PATH = '/p';
const EVENT_PATH = '/e';
const ASSIGN_PATH = '/call-tracking/assign';
const PIXEL_TIMEOUT = 1;
const COLLECT_TIMEOUT = 1;
const ASSIGN_TIMEOUT = 8;
const FORM_TIMEOUT = 10;
const RETRY_TIMEOUT = 3;
const FORWARD_TIMEOUT = 1;
Expand Down Expand Up @@ -145,6 +147,38 @@ public static function forward_event(array $payload, array $opts = [])
return $response;
}

/**
* Forward a DNI assignment request to the upstream /call-tracking/assign endpoint.
* Blocking: the pixel needs the leased number in the same request.
*/
public static function forward_call_tracking_assignment(array $payload, array $signals = [])
{
$headers = [
'Content-Type' => 'application/json',
];

if (!empty($signals['ip'])) {
$headers['X-Forwarded-For'] = $signals['ip'];
$headers['X-Octanist-Client-IP'] = $signals['ip'];
}
if (!empty($signals['country'])) {
$headers['X-Octanist-Client-Country'] = $signals['country'];
}
if (!empty($signals['ua'])) {
$headers['X-Octanist-Client-UA'] = $signals['ua'];
}

return wp_remote_post(OCTANIST_UPSTREAM . self::ASSIGN_PATH, [
'method' => 'POST',
'timeout' => self::ASSIGN_TIMEOUT,
'redirection' => 0,
'blocking' => true,
'headers' => $headers,
'body' => wp_json_encode($payload),
'limit_response_size' => 4096,
]);
}

public static function pixel_delivery_is_paused(): bool
{
return (bool) get_transient(self::PIXEL_CIRCUIT_TRANSIENT);
Expand Down
51 changes: 51 additions & 0 deletions includes/class-octanist-rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Octanist_Rest
const NAMESPACE = 'oct';
const PIXEL = 'p';
const COLLECT = 'e';
const ASSIGN = 'call-tracking/assign';
const MAX_BODY = 65536; // 64 KB

public static function register(): void
Expand All @@ -28,6 +29,12 @@ public static function register_routes(): void
'callback' => [__CLASS__, 'collect'],
'permission_callback' => '__return_true',
]);

register_rest_route(self::NAMESPACE, '/' . self::ASSIGN, [
'methods' => 'POST',
'callback' => [__CLASS__, 'assign'],
'permission_callback' => '__return_true',
]);
}

public static function pixel_url(): string
Expand Down Expand Up @@ -169,6 +176,50 @@ public static function collect(WP_REST_Request $request)
return $rest_response;
}

public static function assign(WP_REST_Request $request)
{
$raw = $request->get_body();
if (strlen($raw) > self::MAX_BODY) {
return self::assignment_unavailable();
}

$payload = json_decode($raw, true);
if (!is_array($payload)) {
return new WP_REST_Response(null, 400);
}

$settings = Octanist_Settings::get();
if (!empty($settings['measurement_id']) && empty($payload['mid'])) {
$payload['mid'] = $settings['measurement_id'];
}

$signals = Octanist_Api::collect_client_signals();
$response = Octanist_Api::forward_call_tracking_assignment($payload, $signals);

if (is_wp_error($response)) {
return self::assignment_unavailable();
}

$code = (int) wp_remote_retrieve_response_code($response);
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($body)) {
return self::assignment_unavailable();
}

return new WP_REST_Response($body, $code >= 200 && $code < 600 ? $code : 200);
}

private static function assignment_unavailable(): WP_REST_Response
{
return new WP_REST_Response([
'success' => false,
'enabled' => false,
'available' => false,
'phoneNumber' => null,
'reason' => 'assignment_unavailable',
], 200);
}

private static function send_headers(array $headers): void
{
foreach ($headers as $name => $value) {
Expand Down
59 changes: 56 additions & 3 deletions includes/class-octanist-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static function get(): array
'measurement_id' => '',
'listener_mode' => 'server',
'consent_mode' => 'auto',
'call_tracking' => false,
];
$settings = get_option(self::OPTION, []);
if (!is_array($settings)) {
Expand All @@ -52,6 +53,9 @@ public static function pixel_data_attrs(): array
'data-consent-mode' => $s['consent_mode'],
'data-cookie-mode' => 'server',
];
if (!empty($s['call_tracking'])) {
$attrs['data-call-tracking'] = 'true';
}
if ($s['listener_mode'] === 'client') {
// Value-less attribute, browser pixel binds to forms itself.
$attrs['data-forms'] = '';
Expand Down Expand Up @@ -92,6 +96,7 @@ public static function register_settings(): void
'measurement_id' => '',
'listener_mode' => 'server',
'consent_mode' => 'auto',
'call_tracking' => false,
],
]);
}
Expand All @@ -116,6 +121,9 @@ public static function sanitize($input): array
$input['measurement_id'] = $decoded['measurement_id'];
$input['listener_mode'] = $decoded['listener_mode'];
$input['consent_mode'] = $decoded['consent_mode'];
if (array_key_exists('call_tracking', $decoded)) {
$input['call_tracking'] = $decoded['call_tracking'];
}

add_settings_error(
self::OPTION,
Expand Down Expand Up @@ -149,6 +157,7 @@ public static function sanitize($input): array
'measurement_id' => $mid,
'listener_mode' => $listener,
'consent_mode' => $consent,
'call_tracking' => !empty($input['call_tracking']),
];
}

Expand All @@ -166,7 +175,7 @@ private static function decode_setup_code(string $code)
}

$parts = explode('.', $code);
if (count($parts) !== 4) {
if (count($parts) !== 4 && count($parts) !== 5) {
return new WP_Error(
'octanist_setup_code_shape',
__('Setup code should look like OCTA1.OCT-XXXXXXXX.s.a.', 'octanist')
Expand Down Expand Up @@ -211,11 +220,24 @@ private static function decode_setup_code(string $code)
);
}

return [
$decoded = [
'measurement_id' => $measurement_id,
'listener_mode' => $listener_mode,
'consent_mode' => $consent_mode,
];

if (isset($parts[4])) {
$call_code = sanitize_key((string) $parts[4]);
if (!in_array($call_code, ['t', 'n'], true)) {
return new WP_Error(
'octanist_setup_code_call_tracking',
__('Setup code contains an invalid call tracking flag.', 'octanist')
);
}
$decoded['call_tracking'] = $call_code === 't';
}

return $decoded;
}

public static function enqueue_assets($hook): void
Expand Down Expand Up @@ -362,6 +384,11 @@ class="octanist-input octanist-input--wide"
<dd><?php echo esc_html(self::format_consent_mode($settings['consent_mode'])); ?></dd>
<span><?php echo esc_html(self::format_consent_mode_help($settings['consent_mode'])); ?></span>
</div>
<div class="octanist-summary-tile">
<dt><?php esc_html_e('Call tracking', 'octanist'); ?></dt>
<dd><?php echo esc_html(self::format_call_tracking($settings['call_tracking'])); ?></dd>
<span><?php echo esc_html(self::format_call_tracking_help($settings['call_tracking'])); ?></span>
</div>
</dl>
</div>

Expand Down Expand Up @@ -413,6 +440,18 @@ class="octanist-input"
<option value="denied" <?php selected($settings['consent_mode'], 'denied'); ?>><?php esc_html_e('Denied, always off', 'octanist'); ?></option>
</select>
</label>

<div class="octanist-field">
<span class="octanist-field__label"><?php esc_html_e('Call tracking', 'octanist'); ?></span>
<input type="hidden" name="<?php echo esc_attr(self::OPTION); ?>[call_tracking]" value="0">
<label class="octanist-option <?php echo !empty($settings['call_tracking']) ? 'is-active' : ''; ?>">
<input type="checkbox" name="<?php echo esc_attr(self::OPTION); ?>[call_tracking]" value="1" <?php checked(!empty($settings['call_tracking'])); ?>>
<span class="octanist-option__content">
<span class="octanist-option__title"><?php esc_html_e('Replace website phone numbers', 'octanist'); ?></span>
<span class="octanist-option__desc"><?php esc_html_e('Turn this on after Octanist has enabled call tracking for this site.', 'octanist'); ?></span>
</span>
</label>
</div>
</div>
</details>

Expand All @@ -428,7 +467,7 @@ class="octanist-input octanist-input--wide"
autocomplete="off"
spellcheck="false"
aria-label="<?php esc_attr_e('Setup code', 'octanist'); ?>">
<p class="octanist-help"><?php esc_html_e('Saving a setup code replaces the current measurement ID, form capture mode, and consent mode.', 'octanist'); ?></p>
<p class="octanist-help"><?php esc_html_e('Saving a setup code replaces the current measurement ID, form capture mode, consent mode, and call tracking setting when the code includes that flag.', 'octanist'); ?></p>
</div>
</details>
</div>
Expand Down Expand Up @@ -530,6 +569,20 @@ private static function format_consent_mode_help(string $mode): string
return $labels[$mode] ?? $labels['auto'];
}

private static function format_call_tracking($enabled): string
{
return !empty($enabled)
? __('On', 'octanist')
: __('Off', 'octanist');
}

private static function format_call_tracking_help($enabled): string
{
return !empty($enabled)
? __('The pixel can replace website phone numbers.', 'octanist')
: __('Leave this off unless Octanist enabled call tracking for this site.', 'octanist');
}

private static function format_time($ts): string
{
if (empty($ts)) {
Expand Down
2 changes: 1 addition & 1 deletion languages/octanist.pot
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# This file is distributed under the same license as the Octanist plugin.
msgid ""
msgstr ""
"Project-Id-Version: Octanist 4.0.1\n"
"Project-Id-Version: Octanist 4.1.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/octanist\n"
"POT-Creation-Date: 2026-06-08 08:31+0000\n"
"MIME-Version: 1.0\n"
Expand Down
8 changes: 4 additions & 4 deletions octanist.php
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
<?php
/**
* Plugin Name: Octanist
* Description: First-party proxy for the Octanist pixel. Serves the tracking script, forwards events, and captures form submissions server-side from popular form plugins.
* Version: 4.0.1
* Description: First-party proxy for the Octanist pixel. Serves the tracking script, forwards events and call-tracking assignments, and captures form submissions server-side from popular form plugins.
* Version: 4.1.0
* Author: Octanist
* Author URI: https://www.octanist.com/
* Text Domain: octanist
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Requires at least: 6.0
* Tested up to: 7.0
* Tested up to: 7.1
*/

if (!defined('ABSPATH')) {
exit;
}

define('OCTANIST_VERSION', '4.0.1');
define('OCTANIST_VERSION', '4.1.0');
define('OCTANIST_PATH', plugin_dir_path(__FILE__));
define('OCTANIST_URL', plugin_dir_url(__FILE__));
define('OCTANIST_FILE', __FILE__);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "octanist-wp-plugin",
"private": true,
"version": "4.0.1",
"version": "4.1.0",
"description": "Development tooling for the Octanist WordPress plugin.",
"scripts": {
"start": "wp-env start",
Expand Down
11 changes: 9 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
Contributors: octanist
Tags: tracking, analytics, forms, leads, conversions
Requires at least: 6.0
Tested up to: 7.0
Stable tag: 4.0.1
Tested up to: 7.1
Stable tag: 4.1.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Expand All @@ -19,6 +19,7 @@ The plugin acts as a first-party proxy for the Octanist pixel:

* **Pixel proxy.** The tracking script is served from your own WordPress site (`/wp-json/oct/p`) from a local cache and refreshed from Octanist in the background. No third-party domain, no DNS setup, no ad-blocker signal to match on.
* **Event proxy.** Events from the pixel POST to `/wp-json/oct/e`; the plugin forwards them immediately with a one-second upstream timeout so onboarding and live reporting stay responsive. Pixel failures are not persisted in WordPress, which prevents an upstream outage from filling the site database.
* **Call tracking proxy.** The pixel can request a dynamic number at `/wp-json/oct/call-tracking/assign`. The plugin forwards that request to Octanist so website phone numbers stay first-party.
* **Server-side form capture.** Form submissions from Gravity Forms, Contact Form 7, WPForms, Ninja Forms, Elementor Pro, Fluent Forms, Formidable Forms, Forminator, SureForms, and Divi Contact Form are captured server-side via action hooks and forwarded synchronously with a longer timeout. Confirmed failures are queued for retry.

### Setup
Expand Down Expand Up @@ -61,6 +62,12 @@ Pixel events are forwarded immediately through the first-party WordPress endpoin

== Changelog ==

= 4.1.0 =
* **NEW:** Optional call tracking setting (off by default). When enabled, the pixel can replace website phone numbers.
* **NEW:** First-party proxy for call tracking assignment (`POST /wp-json/oct/call-tracking/assign`).
* **NEW:** Setup codes can include a call tracking flag (`OCTA1.OCT-XXXXXXXX.s.a.t`).
* **COMPATIBILITY:** Tested up to WordPress 7.1.

= 4.0.1 =
* **PERFORMANCE:** The pixel endpoint serves its local cache immediately and refreshes Octanist upstream in the background.
* **UPGRADE:** Existing v4 caches are migrated and refreshed through a non-blocking runtime upgrade routine.
Expand Down