DEV Community

Pumpz
Pumpz

Posted on Edited on AI-assisted

Why my eBay affiliate links were untagged for five months (and the one-header fix)

I made CresvaMent to track and snipe retro games on ebay. But all of my users just use it for inflated used graphics cards. The stack is .NET 10, Aspire, Blazor Server, a Worker service hitting eBay's Browse API, Postgres and Redis.

Every eBay link I send out is supposed to carry eBay Partner Network (EPN) tracking. Affiliate commission is the only revenue the project has.

From launch until October, zero links carried it. No exceptions, no warnings, no log lines. I shipped untagged links for months and had no idea.

The original code

The Browse API returns itemAffiliateWebUrl on each item when it knows your campaign. I asked for it with query parameters:

private string BuildAffiliateParams(string path)
{
    if (string.IsNullOrWhiteSpace(_options.AffiliateNetworkId))
        return path;

    var sb = new StringBuilder(path);
    sb.Append(path.Contains('?') ? '&' : '?');
    sb.Append("affiliate.networkId=")
      .Append(Uri.EscapeDataString(_options.AffiliateNetworkId));
    sb.Append("&affiliate.campaignId=")
      .Append(Uri.EscapeDataString(_options.AffiliateCampaignId));
    return sb.ToString();
}
Enter fullscreen mode Exit fullscreen mode

Older eBay affiliate docs use parameters like these. The Browse API ignores them. You get 200 OK, normal results, and itemAffiliateWebUrl: null on every item.

Then my mapping code covered for it:

var link = item.ItemAffiliateWebUrl ?? item.ItemWebUrl;
Enter fullscreen mode Exit fullscreen mode

Every page, alert email and Discord message got a working link. Anyone who clicked through and bought something earned me nothing. EPN saw none of it.

You would think this would have been quickly noticed by me simply checking my EPN dashboard but since I had no users I never bothered opening it. I got distracted by other projects and it wasn't until months later when I had a few users that I thought to actually sign in and check.

The fix: one header

The Browse API takes affiliate context in a request header:

X-EBAY-C-ENDUSERCTX: affiliateCampaignId=<your campaign id>
Enter fullscreen mode Exit fullscreen mode

Send that and itemAffiliateWebUrl comes back populated. I add it where the HttpClient is built:

client.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", _options.MarketplaceId);

// EPN: with this header the Browse API returns itemAffiliateWebUrl on every item.
// Query parameters (affiliate.campaignId etc.) are silently ignored.
if (!string.IsNullOrWhiteSpace(_options.AffiliateCampaignId))
    client.DefaultRequestHeaders.TryAddWithoutValidation(
        "X-EBAY-C-ENDUSERCTX",
        $"affiliateCampaignId={_options.AffiliateCampaignId.Trim()}");
Enter fullscreen mode Exit fullscreen mode

That was the easy part. Two other problems took longer.

Problem 1: months of stored rows with no affiliate URL

Listings live in Postgres, and one row is shared by every saved search that matched it. Every row written before the fix had AffiliateUrl = null, and those rows still render on price pages, in recent matches and in alert digests.

I skipped the backfill. I wrote one small class, EbayAffiliateLinker, and routed every outbound link through it:

public sealed class EbayAffiliateLinker(string? campaignId)
{
    public const string UsRotationId = "711-53200-19255-0";

    // eBay's affiliate URL when there is one, otherwise the plain URL with EPN params.
    public string Resolve(string? affiliateUrl, string listingUrl) =>
        !string.IsNullOrWhiteSpace(affiliateUrl) ? affiliateUrl : Tag(listingUrl, CampaignId);

    public static string Tag(string url, string? campaignId)
    {
        if (string.IsNullOrWhiteSpace(campaignId) || !IsEbayComUrl(url) || IsTagged(url))
            return url;

        var sep = url.Contains('?') ? '&' : '?';
        return $"{url}{sep}mkcid=1&mkrid={UsRotationId}&siteid=0" +
               $"&campid={Uri.EscapeDataString(campaignId.Trim())}&toolid=10001&mkevt=1";
    }
    // IsTagged, IsEbayComUrl ...
}
Enter fullscreen mode Exit fullscreen mode

Three rules in there matter:

  • Only ebay.com gets tagged. Each eBay site has its own rotation id, and a wrong one drops attribution silently, which is the exact bug I was fixing. All my listings come from EBAY_US. Any other eBay domain passes through untouched.
  • Already-tagged URLs pass through, so running a link through twice is harmless.
  • Tagging happens at the exits: the API DTOs, the Worker's alert digest, the preview endpoint. If a rule says "every outbound link is tagged," enforce it where links leave the system. Enforce it anywhere earlier and the next feature you build walks around it.

Problem 2: nothing would catch it next time

This is the real lesson. The code had no way to report whether tagging worked. eBay returned 200 and the links opened. Every signal I had said things were fine.

So the client checks once per process, on the first search that returns items:

// 0 = not checked yet, 1 = eBay returned affiliate URLs, 2 = warned that it didn't.
private int _affiliateCheckState;

private void CheckAffiliateUrls(IReadOnlyCollection<eBayItemSummary>? items)
{
    if (!_affiliateLinks.IsConfigured || items is not { Count: > 0 }) return;
    if (Volatile.Read(ref _affiliateCheckState) != 0) return;

    if (items.Any(i => !string.IsNullOrWhiteSpace(i.ItemAffiliateWebUrl)))
    {
        if (Interlocked.CompareExchange(ref _affiliateCheckState, 1, 0) == 0)
            _logger.LogInformation("eBay Partner Network: Browse API is returning affiliate item URLs.");
    }
    else if (Interlocked.CompareExchange(ref _affiliateCheckState, 2, 0) == 0)
    {
        _logger.LogWarning(
            "eBay Partner Network: a campaign id is configured but the Browse API returned no " +
            "itemAffiliateWebUrl on {Count} items. Falling back to locally built EPN links.",
            items.Count);
    }
}
Enter fullscreen mode Exit fullscreen mode

The poller and API requests share this service, so the state flip uses Interlocked. After the first call it costs nothing. Every deploy now logs one line: Information if tagging works, Warning if it doesn't.

I also wrote tests for each thing that broke: the header is on Browse requests, untagged stored rows come out tagged, non-ebay.com URLs stay untouched, and the warning fires when eBay returns no affiliate URLs.

Takeaways

  • Log your fallbacks. affiliateUrl ?? plainUrl kept the product working and hid the bug for months. If a value should always be present, log when it isn't.
  • A 200 says nothing about your query parameters. Most REST APIs drop parameters they don't recognize. If one matters, check the response for proof it was applied.
  • Verify the outcome at startup. One log line on the first search would have caught this on day one.

The price pages this pipeline feeds are at cresvament.com/used-gpu-prices. Disclosure: those are affiliate links, which is the whole point of this post.

Top comments (0)