Paste in child theme functions.php - MUST HAVE i360 RUNNING!
OPTIONS['infu_service_key']
* (with a validated fallback sweep of iMember360/Keap options), then calls
* the Keap REST API with it. The working key is cached for 12h and
* revalidated automatically if Keap ever returns 401.
*
* Diagnostics: as an admin, open any wp-admin URL with ?tgbl_keap_diag=1
* appended, then read WooCommerce → Status → Logs, source "keap-billing-sync".
*/
add_action( 'woocommerce_customer_save_address', 'tgbl_i4w_sync_billing_address', 20, 2 );
function tgbl_i4w_sync_billing_address( $user_id, $load_address ) {
if ( 'billing' !== $load_address ) {
return;
}
$user = get_userdata( $user_id );
if ( ! $user || ! is_email( $user->user_email ) ) {
return;
}
$email = $user->user_email;
$key = tgbl_i4w_working_key();
if ( ! $key ) {
tgbl_i4w_sync_log( 'error', 'No working Keap key found among iMember360 settings - billing address not synced. Run the ?tgbl_keap_diag=1 diagnostic as an admin.' );
return;
}
// 1. Find the Keap contact by the WP account email (iMember360's match key).
$found = tgbl_keap_rest( $key, 'GET', '/contacts?email=' . rawurlencode( $email ) );
if ( is_wp_error( $found ) ) {
tgbl_i4w_maybe_reset_key( $found );
tgbl_i4w_sync_log( 'error', "Contact lookup failed for {$email}: " . $found->get_error_message() );
return;
}
if ( empty( $found['contacts'][0]['id'] ) ) {
tgbl_i4w_sync_log( 'warning', "No Keap contact found for {$email} - billing address not synced." );
return;
}
$cid = (int) $found['contacts'][0]['id'];
// 2. Read current addresses/phones so we replace ONLY the BILLING entry
// (a REST v1 PATCH replaces the entire addresses array).
$contact = tgbl_keap_rest( $key, 'GET', '/contacts/' . $cid );
if ( is_wp_error( $contact ) ) {
tgbl_i4w_maybe_reset_key( $contact );
tgbl_i4w_sync_log( 'error', "Could not read contact {$cid}: " . $contact->get_error_message() );
return;
}
$meta = function ( $k ) use ( $user_id ) {
return trim( (string) get_user_meta( $user_id, $k, true ) );
};
// Keap validates region against full state/province names ("Georgia"),
// while Woo stores codes ("GA") - convert using WooCommerce's state list.
$country_a2 = $meta( 'billing_country' );
$region = $meta( 'billing_state' );
if ( '' !== $region && function_exists( 'WC' ) && isset( WC()->countries ) ) {
$states = WC()->countries->get_states( $country_a2 );
if ( is_array( $states ) && ! empty( $states[ $region ] ) ) {
$region = html_entity_decode( $states[ $region ] );
}
}
$billing = array(
'field' => 'BILLING',
'line1' => $meta( 'billing_address_1' ),
'line2' => $meta( 'billing_address_2' ),
'locality' => $meta( 'billing_city' ),
'region' => $region,
'zip_code' => $meta( 'billing_postcode' ),
);
$iso3 = tgbl_keap_iso3( $country_a2 );
if ( $iso3 ) {
$billing['country_code'] = $iso3;
}
$addresses = array( $billing );
$allowed_fields = array( 'field', 'line1', 'line2', 'locality', 'region', 'zip_code', 'zip_four', 'country_code', 'postal_code' );
foreach ( (array) ( $contact['addresses'] ?? array() ) as $existing ) {
if ( isset( $existing['field'] ) && 'BILLING' !== $existing['field'] ) {
$addresses[] = array_intersect_key( $existing, array_flip( $allowed_fields ) );
}
}
$payload = array( 'addresses' => $addresses );
// 3. Mirror billing phone into PHONE1, preserving other numbers.
$billing_phone = $meta( 'billing_phone' );
if ( '' !== $billing_phone ) {
$phones = array( array( 'field' => 'PHONE1', 'number' => $billing_phone ) );
$phone_keys = array( 'field', 'number', 'extension', 'type' );
foreach ( (array) ( $contact['phone_numbers'] ?? array() ) as $existing ) {
if ( isset( $existing['field'] ) && 'PHONE1' !== $existing['field'] && ! empty( $existing['number'] ) ) {
$phones[] = array_intersect_key( $existing, array_flip( $phone_keys ) );
}
}
$payload['phone_numbers'] = $phones;
}
// 4. Push the update. If Keap still rejects the region string, retry
// without it so the rest of the address isn't lost.
$result = tgbl_keap_rest( $key, 'PATCH', '/contacts/' . $cid, $payload );
if ( is_wp_error( $result ) && false !== stripos( $result->get_error_message(), 'Region is invalid' ) ) {
unset( $payload['addresses'][0]['region'] );
tgbl_i4w_sync_log( 'warning', "Keap rejected region '{$region}' for contact {$cid}; retrying without region." );
$result = tgbl_keap_rest( $key, 'PATCH', '/contacts/' . $cid, $payload );
}
if ( is_wp_error( $result ) ) {
tgbl_i4w_maybe_reset_key( $result );
tgbl_i4w_sync_log( 'error', "Billing address update failed for contact {$cid}: " . $result->get_error_message() );
return;
}
tgbl_i4w_sync_log( 'info', "Billing address synced to Keap contact {$cid} ({$email})." );
}
/**
* Minimal Keap REST v1 client using the WP HTTP API.
*/
function tgbl_keap_rest( $key, $method, $path, $body = null ) {
$args = array(
'method' => $method,
'timeout' => 15,
'headers' => array(
'X-Keap-API-Key' => $key,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
),
);
if ( null !== $body ) {
$args['body'] = wp_json_encode( $body );
}
$response = wp_remote_request( 'https://api.infusionsoft.com/crm/rest/v1' . $path, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$raw = wp_remote_retrieve_body( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error( 'keap_http_' . $code, 'HTTP ' . $code . ': ' . $raw );
}
return json_decode( $raw, true );
}
/**
* If a call failed with 401, clear the cached key so the next attempt
* re-discovers and re-validates (self-heals after a key rotation).
*/
function tgbl_i4w_maybe_reset_key( WP_Error $error ) {
if ( 'keap_http_401' === $error->get_error_code() ) {
delete_transient( 'tgbl_keap_working_key' );
}
}
/**
* Return a VALIDATED key from iMember360's stored settings, cached for 12h.
* Checks the known location first ($i4w->OPTIONS['infu_service_key']),
* then sweeps related options. Every candidate is live-tested before use.
*/
function tgbl_i4w_working_key() {
$cached = get_transient( 'tgbl_keap_working_key' );
if ( is_string( $cached ) && '' !== $cached ) {
return $cached;
}
foreach ( tgbl_i4w_key_candidates() as $candidate ) {
if ( tgbl_i4w_validate_key( $candidate['value'] ) ) {
set_transient( 'tgbl_keap_working_key', $candidate['value'], 12 * HOUR_IN_SECONDS );
tgbl_i4w_sync_log( 'info', 'Validated Keap key from: ' . $candidate['source'] );
return $candidate['value'];
}
}
return '';
}
function tgbl_i4w_key_candidates() {
global $wpdb, $i4w;
$sak = array();
$others = array();
// Known iMember360 v5 location - checked first.
if ( is_object( $i4w ) && ! empty( $i4w->OPTIONS['infu_service_key'] ) && is_string( $i4w->OPTIONS['infu_service_key'] ) ) {
$sak[] = array( 'value' => trim( $i4w->OPTIONS['infu_service_key'] ), 'source' => "\$i4w->OPTIONS['infu_service_key']" );
}
$sources = array();
if ( is_object( $i4w ) ) {
$sources['$i4w object'] = get_object_vars( $i4w );
}
$rows = $wpdb->get_results(
"SELECT option_name, option_value FROM {$wpdb->options}
WHERE option_name LIKE '%i4w%' OR option_name LIKE '%imember%'
OR option_name LIKE '%keap%' OR option_name LIKE '%infusion%'"
);
foreach ( (array) $rows as $row ) {
$sources[ 'option:' . $row->option_name ] = maybe_unserialize( $row->option_value );
}
foreach ( $sources as $label => $value ) {
tgbl_i4w_harvest_strings( $value, $label, $sak, $others );
}
$candidates = array();
$seen = array();
foreach ( array_merge( $sak, $others ) as $c ) {
if ( ! isset( $seen[ $c['value'] ] ) ) {
$seen[ $c['value'] ] = true;
$candidates[] = $c;
}
}
return array_slice( $candidates, 0, 15 );
}
function tgbl_i4w_harvest_strings( $value, $source, array &$sak, array &$others, $depth = 0 ) {
if ( $depth > 5 ) {
return;
}
if ( is_string( $value ) ) {
$value = trim( $value );
if ( preg_match( '/^KeapAK-[A-Za-z0-9._\-]{16,}$/', $value ) ) {
$sak[] = array( 'value' => $value, 'source' => $source );
}
return;
}
if ( is_array( $value ) || is_object( $value ) ) {
foreach ( (array) $value as $k => $item ) {
tgbl_i4w_harvest_strings( $item, $source . '.' . $k, $sak, $others, $depth + 1 );
}
}
}
/**
* Live test: a valid Service Account Key returns 200 on the REST profile call.
*/
function tgbl_i4w_validate_key( $key ) {
$response = wp_remote_get(
'https://api.infusionsoft.com/crm/rest/v1/account/profile',
array(
'timeout' => 10,
'headers' => array( 'X-Keap-API-Key' => $key, 'Accept' => 'application/json' ),
)
);
return ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response );
}
function tgbl_i4w_sync_log( $level, $message ) {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->log( $level, $message, array( 'source' => 'keap-billing-sync' ) );
}
}
/**
* WooCommerce stores ISO 3166-1 alpha-2 country codes; Keap REST needs alpha-3.
*/
function tgbl_keap_iso3( $alpha2 ) {
static $map = array(
'AF' => 'AFG', 'AX' => 'ALA', 'AL' => 'ALB', 'DZ' => 'DZA', 'AS' => 'ASM', 'AD' => 'AND', 'AO' => 'AGO', 'AI' => 'AIA',
'AQ' => 'ATA', 'AG' => 'ATG', 'AR' => 'ARG', 'AM' => 'ARM', 'AW' => 'ABW', 'AU' => 'AUS', 'AT' => 'AUT', 'AZ' => 'AZE',
'BS' => 'BHS', 'BH' => 'BHR', 'BD' => 'BGD', 'BB' => 'BRB', 'BY' => 'BLR', 'BE' => 'BEL', 'BZ' => 'BLZ', 'BJ' => 'BEN',
'BM' => 'BMU', 'BT' => 'BTN', 'BO' => 'BOL', 'BQ' => 'BES', 'BA' => 'BIH', 'BW' => 'BWA', 'BV' => 'BVT', 'BR' => 'BRA',
'IO' => 'IOT', 'BN' => 'BRN', 'BG' => 'BGR', 'BF' => 'BFA', 'BI' => 'BDI', 'CV' => 'CPV', 'KH' => 'KHM', 'CM' => 'CMR',
'CA' => 'CAN', 'KY' => 'CYM', 'CF' => 'CAF', 'TD' => 'TCD', 'CL' => 'CHL', 'CN' => 'CHN', 'CX' => 'CXR', 'CC' => 'CCK',
'CO' => 'COL', 'KM' => 'COM', 'CG' => 'COG', 'CD' => 'COD', 'CK' => 'COK', 'CR' => 'CRI', 'CI' => 'CIV', 'HR' => 'HRV',
'CU' => 'CUB', 'CW' => 'CUW', 'CY' => 'CYP', 'CZ' => 'CZE', 'DK' => 'DNK', 'DJ' => 'DJI', 'DM' => 'DMA', 'DO' => 'DOM',
'EC' => 'ECU', 'EG' => 'EGY', 'SV' => 'SLV', 'GQ' => 'GNQ', 'ER' => 'ERI', 'EE' => 'EST', 'SZ' => 'SWZ', 'ET' => 'ETH',
'FK' => 'FLK', 'FO' => 'FRO', 'FJ' => 'FJI', 'FI' => 'FIN', 'FR' => 'FRA', 'GF' => 'GUF', 'PF' => 'PYF', 'TF' => 'ATF',
'GA' => 'GAB', 'GM' => 'GMB', 'GE' => 'GEO', 'DE' => 'DEU', 'GH' => 'GHA', 'GI' => 'GIB', 'GR' => 'GRC', 'GL' => 'GRL',
'GD' => 'GRD', 'GP' => 'GLP', 'GU' => 'GUM', 'GT' => 'GTM', 'GG' => 'GGY', 'GN' => 'GIN', 'GW' => 'GNB', 'GY' => 'GUY',
'HT' => 'HTI', 'HM' => 'HMD', 'VA' => 'VAT', 'HN' => 'HND', 'HK' => 'HKG', 'HU' => 'HUN', 'IS' => 'ISL', 'IN' => 'IND',
'ID' => 'IDN', 'IR' => 'IRN', 'IQ' => 'IRQ', 'IE' => 'IRL', 'IM' => 'IMN', 'IL' => 'ISR', 'IT' => 'ITA', 'JM' => 'JAM',
'JP' => 'JPN', 'JE' => 'JEY', 'JO' => 'JOR', 'KZ' => 'KAZ', 'KE' => 'KEN', 'KI' => 'KIR', 'KP' => 'PRK', 'KR' => 'KOR',
'KW' => 'KWT', 'KG' => 'KGZ', 'LA' => 'LAO', 'LV' => 'LVA', 'LB' => 'LBN', 'LS' => 'LSO', 'LR' => 'LBR', 'LY' => 'LBY',
'LI' => 'LIE', 'LT' => 'LTU', 'LU' => 'LUX', 'MO' => 'MAC', 'MG' => 'MDG', 'MW' => 'MWI', 'MY' => 'MYS', 'MV' => 'MDV',
'ML' => 'MLI', 'MT' => 'MLT', 'MH' => 'MHL', 'MQ' => 'MTQ', 'MR' => 'MRT', 'MU' => 'MUS', 'YT' => 'MYT', 'MX' => 'MEX',
'FM' => 'FSM', 'MD' => 'MDA', 'MC' => 'MCO', 'MN' => 'MNG', 'ME' => 'MNE', 'MS' => 'MSR', 'MA' => 'MAR', 'MZ' => 'MOZ',
'MM' => 'MMR', 'NA' => 'NAM', 'NR' => 'NRU', 'NP' => 'NPL', 'NL' => 'NLD', 'NC' => 'NCL', 'NZ' => 'NZL', 'NI' => 'NIC',
'NE' => 'NER', 'NG' => 'NGA', 'NU' => 'NIU', 'NF' => 'NFK', 'MK' => 'MKD', 'MP' => 'MNP', 'NO' => 'NOR', 'OM' => 'OMN',
'PK' => 'PAK', 'PW' => 'PLW', 'PS' => 'PSE', 'PA' => 'PAN', 'PG' => 'PNG', 'PY' => 'PRY', 'PE' => 'PER', 'PH' => 'PHL',
'PN' => 'PCN', 'PL' => 'POL', 'PT' => 'PRT', 'PR' => 'PRI', 'QA' => 'QAT', 'RE' => 'REU', 'RO' => 'ROU', 'RU' => 'RUS',
'RW' => 'RWA', 'BL' => 'BLM', 'SH' => 'SHN', 'KN' => 'KNA', 'LC' => 'LCA', 'MF' => 'MAF', 'PM' => 'SPM', 'VC' => 'VCT',
'WS' => 'WSM', 'SM' => 'SMR', 'ST' => 'STP', 'SA' => 'SAU', 'SN' => 'SEN', 'RS' => 'SRB', 'SC' => 'SYC', 'SL' => 'SLE',
'SG' => 'SGP', 'SX' => 'SXM', 'SK' => 'SVK', 'SI' => 'SVN', 'SB' => 'SLB', 'SO' => 'SOM', 'ZA' => 'ZAF', 'GS' => 'SGS',
'SS' => 'SSD', 'ES' => 'ESP', 'LK' => 'LKA', 'SD' => 'SDN', 'SR' => 'SUR', 'SJ' => 'SJM', 'SE' => 'SWE', 'CH' => 'CHE',
'SY' => 'SYR', 'TW' => 'TWN', 'TJ' => 'TJK', 'TZ' => 'TZA', 'TH' => 'THA', 'TL' => 'TLS', 'TG' => 'TGO', 'TK' => 'TKL',
'TO' => 'TON', 'TT' => 'TTO', 'TN' => 'TUN', 'TR' => 'TUR', 'TM' => 'TKM', 'TC' => 'TCA', 'TV' => 'TUV', 'UG' => 'UGA',
'UA' => 'UKR', 'AE' => 'ARE', 'GB' => 'GBR', 'US' => 'USA', 'UM' => 'UMI', 'UY' => 'URY', 'UZ' => 'UZB', 'VU' => 'VUT',
'VE' => 'VEN', 'VN' => 'VNM', 'VG' => 'VGB', 'VI' => 'VIR', 'WF' => 'WLF', 'EH' => 'ESH', 'YE' => 'YEM', 'ZM' => 'ZMB',
'ZW' => 'ZWE',
);
$alpha2 = strtoupper( trim( (string) $alpha2 ) );
return $map[ $alpha2 ] ?? '';
}
/**
* One-shot diagnostic: as a logged-in admin, open any wp-admin URL with
* ?tgbl_keap_diag=1 appended, then read the keap-billing-sync log.
*/
add_action( 'admin_init', function () {
if ( empty( $_GET['tgbl_keap_diag'] ) || ! current_user_can( 'manage_options' ) ) {
return;
}
delete_transient( 'tgbl_keap_working_key' );
$candidates = tgbl_i4w_key_candidates();
tgbl_i4w_sync_log( 'info', 'DIAG: ' . count( $candidates ) . ' candidate key(s) found.' );
foreach ( $candidates as $i => $c ) {
$masked = substr( $c['value'], 0, 9 ) . '... (len ' . strlen( $c['value'] ) . ')';
$valid = tgbl_i4w_validate_key( $c['value'] ) ? 'VALID' : 'invalid';
tgbl_i4w_sync_log( 'info', "DIAG: candidate #{$i} {$masked} from [{$c['source']}] => {$valid}" );
}
} );
