How to Fix Double URL Encoding in UTM Parameters: Complete Recovery Guide

UTMGuard Team
9 min readURL Syntax

"Monday morning. Executive dashboard showed our Q4 campaign as 'holiday%2520sale%2526promo.' 12,000 email clicks with zero proper attribution. I had 4 hours to fix it before the board meeting."

This crisis hit Sarah Johnson, VP of Marketing. Here's the exact process she used to fix double-encoding and recover attribution in under 4 hours.

Emergency Response Protocol

Step 1: Confirm Double-Encoding (5 minutes)

Quick test:

// Paste into browser console with your campaign name from Analytics
const campaignName = 'summer%2520sale';  // From GA4
 
// Test: Decode once
const decoded1 = decodeURIComponent(campaignName);
console.log('Decode 1x:', decoded1);
 
// Test: Decode twice
const decoded2 = decodeURIComponent(decoded1);
console.log('Decode 2x:', decoded2);
 
// If decoded1 ≠ decoded2, you have double-encoding
if (decoded1 !== decoded2) {
  console.log('✓ CONFIRMED: Double-encoding detected');
  console.log('Original was:', decoded2);
} else {
  console.log('Not double-encoded');
}

Example output:

Decode 1x: summer%20sale
Decode 2x: summer sale
✓ CONFIRMED: Double-encoding detected
Original was: summer sale

🚨 Not sure what's breaking your tracking?

Run a free 60-second audit to check all 40+ ways UTM tracking can fail.

Scan Your Campaigns Free

✓ No credit card ✓ See results instantly

Step 2: Identify All Affected Campaigns (10 minutes)

Google Analytics 4 export:

  1. Reports → Acquisition → Traffic Acquisition
  2. Add dimension: Session campaign name
  3. Filter: Contains "%"
  4. Export to CSV

Analyze in spreadsheet:

// JavaScript to analyze exported CSV
function analyzeDoubleEncoding(campaigns) {
  const results = {
    doubleEncoded: [],
    singleEncoded: [],
    clean: []
  };
 
  campaigns.forEach(row => {
    const name = row.campaign;
 
    if (!name.includes('%')) {
      results.clean.push(row);
      return;
    }
 
    // Test for double-encoding
    try {
      const decoded1 = decodeURIComponent(name);
      const decoded2 = decodeURIComponent(decoded1);
 
      if (decoded1 !== decoded2) {
        results.doubleEncoded.push({
          ...row,
          encoded: name,
          decoded1,
          decoded2
        });
      } else {
        results.singleEncoded.push(row);
      }
    } catch (e) {
      // Malformed encoding
      results.doubleEncoded.push({
        ...row,
        error: e.message
      });
    }
  });
 
  return results;
}

Step 3: Stop Active Campaigns (15 minutes)

Platform-by-platform checklist:

Email:

[ ] Mailchimp: Pause all active campaigns with encoded URLs
[ ] HubSpot: Pause workflows with encoding issues
[ ] SendGrid: Stop API sends

Paid Ads:

[ ] Google Ads: Pause affected campaigns
[ ] Facebook Ads: Pause affected ad sets
[ ] LinkedIn Ads: Pause campaigns

Social:

[ ] Hootsuite: Delete scheduled posts with bad URLs
[ ] Buffer: Clear queue
[ ] Sprout: Remove scheduled content

😰 Is this your only tracking issue?

This is just 1 of 40+ ways UTM tracking breaks. Most marketing teams have 8-12 critical issues they don't know about.

• 94% of sites have UTM errors

• Average: $8,400/month in wasted ad spend

• Fix time: 15 minutes with our report

✓ Connects directly to GA4 (read-only, secure)

✓ Scans 90 days of data in 2 minutes

✓ Prioritizes issues by revenue impact

✓ Shows exact sessions affected

Get Your Free Audit Report

Complete Fix Process

Fix 1: Decode and Clean URLs

Master decoding function:

function fixDoubleEncodedURL(url) {
  try {
    const urlObj = new URL(url);
    const fixes = [];
 
    ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'].forEach(param => {
      const original = urlObj.searchParams.get(param);
      if (!original) return;
 
      // Multi-level decode (handles double, triple, etc.)
      let decoded = original;
      let iterations = 0;
      const maxIterations = 10;  // Safety limit
 
      while (iterations < maxIterations) {
        try {
          const previousDecoded = decoded;
          decoded = decodeURIComponent(decoded);
 
          // Stop if no change (fully decoded)
          if (decoded === previousDecoded) break;
 
          // Stop if no more % characters
          if (!decoded.includes('%')) break;
 
          iterations++;
        } catch (e) {
          // Decoding failed, we're done
          break;
        }
      }
 
      // Clean to safe characters
      const cleaned = decoded
        .toLowerCase()
        .trim()
        .replace(/\s+/g, '-')
        .replace(/[^a-z0-9-_]/g, '')
        .replace(/-+/g, '-')
        .replace(/^-|-$/g, '');
 
      if (cleaned !== original) {
        fixes.push({
          param,
          original,
          decoded,
          cleaned,
          iterations
        });
 
        urlObj.searchParams.set(param, cleaned);
      }
    });
 
    return {
      originalURL: url,
      fixedURL: urlObj.toString(),
      changes: fixes,
      success: true
    };
 
  } catch (e) {
    return {
      originalURL: url,
      error: e.message,
      success: false
    };
  }
}
 
// Usage
const broken = 'https://example.com?utm_campaign=summer%2520sale%2526special';
const result = fixDoubleEncodedURL(broken);
 
console.log('Original:', result.originalURL);
console.log('Fixed:', result.fixedURL);
console.log('Changes:');
result.changes.forEach(change => {
  console.log(`  ${change.param}:`);
  console.log(`    Original: ${change.original}`);
  console.log(`    Decoded: ${change.decoded} (${change.iterations} iterations)`);
  console.log(`    Cleaned: ${change.cleaned}`);
});
 
// Output:
// Original: https://example.com?utm_campaign=summer%2520sale%2526special
// Fixed: https://example.com?utm_campaign=summer-sale-and-special
// Changes:
//   utm_campaign:
//     Original: summer%2520sale%2526special
//     Decoded: summer sale & special (2 iterations)
//     Cleaned: summer-sale-and-special

Fix 2: Batch Update All Platforms

Platform update script:

async function batchFixDoubleEncoding(platforms) {
  const report = {
    totalCampaigns: 0,
    fixed: 0,
    failed: 0,
    details: []
  };
 
  for (const platform of platforms) {
    console.log(`\nProcessing ${platform.name}...`);
 
    const campaigns = await platform.getCampaigns();
    report.totalCampaigns += campaigns.length;
 
    for (const campaign of campaigns) {
      try {
        const url = campaign.trackingURL;
 
        // Check if double-encoded
        if (!url.includes('%25')) {
          console.log(`  ${campaign.id}: OK (not double-encoded)`);
          continue;
        }
 
        // Fix the URL
        const fixed = fixDoubleEncodedURL(url);
 
        if (!fixed.success) {
          report.failed++;
          report.details.push({
            platform: platform.name,
            campaign: campaign.id,
            status: 'FAILED',
            error: fixed.error
          });
          continue;
        }
 
        // Update the campaign
        await platform.updateCampaign(campaign.id, {
          trackingURL: fixed.fixedURL
        });
 
        report.fixed++;
        report.details.push({
          platform: platform.name,
          campaign: campaign.id,
          status: 'FIXED',
          changes: fixed.changes
        });
 
        console.log(`  ${campaign.id}: FIXED`);
 
      } catch (e) {
        report.failed++;
        report.details.push({
          platform: platform.name,
          campaign: campaign.id,
          status: 'ERROR',
          error: e.message
        });
        console.error(`  ${campaign.id}: ERROR - ${e.message}`);
      }
    }
  }
 
  return report;
}
 
// Example platform adapter
const mailchimpAdapter = {
  name: 'Mailchimp',
  async getCampaigns() {
    // Fetch campaigns from Mailchimp API
    return mailchimp.campaigns.list();
  },
  async updateCampaign(id, updates) {
    // Update campaign tracking URL
    return mailchimp.campaigns.update(id, updates);
  }
};
 
// Run batch fix
const platforms = [mailchimpAdapter, hubspotAdapter, googleAdsAdapter];
const report = await batchFixDoubleEncoding(platforms);
 
console.log('\n=== BATCH FIX COMPLETE ===');
console.log(`Total campaigns: ${report.totalCampaigns}`);
console.log(`Fixed: ${report.fixed}`);
console.log(`Failed: ${report.failed}`);

Fix 3: Update Email Templates

Mailchimp template fix:

1. Login to Mailchimp
2. Templates → Saved templates
3. For each template:
   a. Edit template
   b. Find & Replace:
      Find: utm_campaign=*|CAMPAIGN:SUBJECT|*
      Replace: utm_campaign=*|CAMPAIGN:SLUG|*
      (SLUG is auto-cleaned, SUBJECT can have spaces)
   c. Search for any remaining %20 or %25
   d. Save template
4. Test send to verify

HubSpot template fix:

1. Marketing → Files and Templates → Design Tools
2. For each email template:
   a. Edit source code
   b. Find all href= attributes
   c. Replace encoded params:

   Before:
   <a href="{"{"}{"{"}content.url{"}"}{"}"}}?utm_campaign={"{"}{"{"}campaign.name{"}"}{"}"}}">

   After:
   <a href="{"{"}{"{"}content.url{"}"}{"}"}}?utm_campaign={"{"}{"{"}campaign.slug{"}"}{"}"}}">

   d. Publish changes
3. Test in preview mode

Fix 4: Repair Analytics Data

Option A: BigQuery Data Transformation

-- Create cleaned campaigns view
CREATE OR REPLACE VIEW `project.dataset.cleaned_campaigns` AS
WITH decoded_campaigns AS (
  SELECT
    event_date,
    user_pseudo_id,
    (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'campaign') as raw_campaign,
    -- Custom decoding function (requires UDF)
    decode_url_multiple(
      (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'campaign')
    ) as decoded_campaign
  FROM `project.dataset.events_*`
  WHERE _TABLE_SUFFIX BETWEEN '20240101' AND '20241231'
)
SELECT
  event_date,
  user_pseudo_id,
  raw_campaign,
  decoded_campaign,
  -- Clean the decoded campaign
  LOWER(
    REGEXP_REPLACE(
      REGEXP_REPLACE(
        REGEXP_REPLACE(decoded_campaign, r'\s+', '-'),  -- Spaces to hyphens
        r'[^a-z0-9-_]', ''  -- Remove non-safe chars
      ),
      r'-+', '-'  -- Dedupe hyphens
    )
  ) as cleaned_campaign
FROM decoded_campaigns;

BigQuery UDF for multi-level decoding:

CREATE TEMP FUNCTION decode_url_multiple(input STRING)
RETURNS STRING
LANGUAGE js AS """
  let decoded = input;
  let previous;
  let iterations = 0;
 
  while (iterations < 10) {
    previous = decoded;
    try {
      decoded = decodeURIComponent(decoded);
    } catch (e) {
      break;
    }
 
    if (decoded === previous || !decoded.includes('%')) {
      break;
    }
 
    iterations++;
  }
 
  return decoded;
""";

Option B: GA4 Custom Dimension (Limited)

1. Admin → Data Display → Custom Definitions
2. Create custom dimension:
   - Name: Campaign (Cleaned)
   - Scope: Event
   - Event parameter: campaign_name

3. (Limitation: Can't decode in GA4 interface)
4. (Must use BigQuery for actual decoding)

Option C: Looker Studio Calculated Field

1. In Looker Studio report
2. Add calculated field:
   Name: Cleaned Campaign
   Formula:
   REGEXP_REPLACE(
     REGEXP_REPLACE(Campaign, "%20", " "),
     "%26", "&"
   )

3. Use this field in reports instead of raw Campaign

Fix 5: Document and Prevent

Create incident report:

# Double-Encoding Incident Report
 
## Discovery
- Date: 2024-11-15
- Reporter: Sarah Johnson
- Affected campaigns: 23
- Affected sessions: 12,456
- Revenue impacted: $34,567
 
## Root Cause
URL builder passed already-encoded values to email platform,
which encoded them again before sending.
 
## Timeline
- 2024-10-01: Issue began (deployment of new URL builder)
- 2024-11-15: Issue discovered
- 2024-11-15 10:00: Campaigns paused
- 2024-11-15 11:30: Fix deployed
- 2024-11-15 13:00: Campaigns reactivated
- 2024-11-15 14:00: Validation complete
 
## Fix Applied
1. Updated URL builder to use clean values only
2. Added validation to reject any URL with %
3. Updated all email templates
4. Retrained marketing team
5. Implemented pre-send validation
 
## Prevention
- [x] URL builder fixed
- [x] Validation added
- [x] Templates updated
- [x] Team trained
- [x] Documentation updated
- [x] Weekly audits scheduled
 
## Lessons Learned
- Never encode before passing to systems
- Always validate URLs before launch
- Monitor analytics for encoding artifacts
- Test end-to-end workflows

Platform-Specific Fixes

Campaign Settings → Campaign URL options

Before (double-encoded):
`{"{"}{"{"}lpurl{"}"}{"}"}}`?utm_source=google&utm_medium=paid%20search&utm_campaign=sale%2520special

After (fixed):
`{"{"}{"{"}lpurl{"}"}{"}"}}`?utm_source=google&utm_medium=paid-search&utm_campaign=sale-special

Apply to: All ad groups

Facebook Ads

Ad Level → Tracking → URL Parameters

Before:
utm_source=facebook&utm_campaign=summer%2520sale

After:
utm_source=facebook&utm_campaign=summer-sale

Publish changes

URL Shorteners (Bitly)

1. Access Bitly dashboard
2. Find short links with double-encoding
3. Edit destination URL
4. Replace:
   Before: ?utm_campaign=sale%2520offer
   After: ?utm_campaign=sale-offer
5. Save changes
6. Test short link

Validation After Fix

async function validateAllFixed(platforms) {
  const validation = {
    passed: 0,
    failed: 0,
    issues: []
  };
 
  for (const platform of platforms) {
    const campaigns = await platform.getCampaigns();
 
    for (const campaign of campaigns) {
      const url = campaign.trackingURL;
 
      // Check for remaining double-encoding
      if (url.includes('%25')) {
        validation.failed++;
        validation.issues.push({
          platform: platform.name,
          campaign: campaign.id,
          url,
          issue: 'Still contains %25 (double-encoded)'
        });
      } else {
        validation.passed++;
      }
    }
  }
 
  return validation;
}
 
// Run validation
const validation = await validateAllFixed(platforms);
 
if (validation.failed > 0) {
  console.error(`❌ ${validation.failed} campaigns still have issues:`);
  validation.issues.forEach(issue => {
    console.error(`  ${issue.platform} - ${issue.campaign}: ${issue.issue}`);
  });
} else {
  console.log(`✅ All ${validation.passed} campaigns validated successfully`);
}

✅ Fixed this issue? Great! Now check the other 39...

You just fixed one tracking issue. But are your Google Ads doubling sessions? Is Facebook attribution broken? Are internal links overwriting campaigns?

Connects to GA4 (read-only, OAuth secured)

Scans 90 days of traffic in 2 minutes

Prioritizes by revenue impact

Free forever for monthly audits

Run Complete UTM Audit (Free Forever)

Join 2,847 marketers fixing their tracking daily

Prevention Checklist

Implement these to prevent recurrence:

  • URL builder uses clean values only (no encoding)
  • Validation rejects URLs containing %
  • Email templates use clean merge tags
  • Platform configs don't double-encode
  • End-to-end testing before launch
  • Weekly analytics audits for % artifacts
  • Team training on encoding issues
  • Documentation: "Never encode values manually"
  • Automated alerts for new % in campaigns

FAQ

Q: How long does fixing take?

A: For Sarah's 23-campaign emergency: Detection (15min), Pausing (15min), Fixing URLs (60min), Platform updates (90min), Testing (30min), Reactivation (30min). Total: ~4 hours.

Q: Can I fix historical data in Google Analytics?

A: Raw data can't be changed, but you can create BigQuery views or Looker Studio calculated fields that decode values for reporting purposes.

Q: What if I can't find where double-encoding is happening?

A: Test each step of your workflow independently. Generate URL → check it → pass to next system → check it → repeat until you find where encoding happens.

Q: Will fixing URLs lose historical data?

A: No, historical data stays as-is. You'll have two sets: old (double-encoded) and new (clean). Create filters to group them for analysis if needed.

Q: Should I delete and recreate campaigns?

A: No, update in place to preserve campaign history and IDs. Only create new if platform doesn't allow URL editing.

Q: What if decoding twice doesn't work?

A: You may have triple or quadruple encoding. The multi-level decode function handles this by looping until stable.

Q: Can this happen again after fixing?

A: Yes, if you don't fix the root cause. Implement validation and clean-values-only policy to prevent recurrence.

Q: How do I know if fix was successful?

A: Monitor analytics for next 48 hours. New sessions should show clean campaign names without any % characters.


Never waste hours fixing double-encoding again. UTMGuard detects double-encoding instantly, provides automatic fixes, and prevents it from happening in the first place. Start your free audit today.