Deep dive
Affinity CPA: the long version
The full reasoning behind the case study, kept in one scrolling page.
Context
Affinity CPA runs a cost-per-action affiliate network: publishers drive traffic, advertisers pay per sale or lead. Before this portal, publishers requested tracking links by emailing an Ad Ops rep, who manually built the link, applied sub-ID macros by hand, and replied — often a day or more later.
With 20+ live offers across Health & Wellness, Finance, Software & SaaS, and Fashion, that manual queue was the single biggest bottleneck between a publisher wanting to promote something and money moving.
Constraints. One designer, working across Engineering and Ad Ops — the team whose manual process this replaced. The portal moves real money, so every payout figure had to be trustworthy at a glance. And with 20+ programs across four verticals, comparing programs had to work without opening them one by one.
The reframe
The framing on the table: A better analytics suite for publishers.
The framing that held: No human between a publisher and their first live link.
A full analytics and reporting build was on the table for this first release. But the queue — publishers emailing an Ad Ops rep and waiting a day or more — sat between every publisher’s intent and their first tracked revenue. Reporting depth could wait; that wait could not. So the release was scoped around the path from browsing a program to a copied tracking link, and reporting stayed at two purpose-built reports.
Principles that settled the trade-offs
1. No human in the critical path. Get Link sits in the programs row, and the working tracking link is already generated and copyable when the drawer opens. Nothing waits on Ad Ops.
2. The simple path is never taxed by the power path. Click ID, deep linking and eight Sub-IDs sit below the link as additions. A publisher who wants only the link never has to understand them.
3. Money states its own change. Payout events read before → after with a % delta, and restated values sit beside the originals — so no number is ever a surprise.
Link generation
Decision 1. One programs table instead of per-category micro-sites
- The tension
- Advertisers wanted their programs to feel distinct (own branding, own terms); publishers needed to compare payout, geo, and EPC across the whole catalog in one glance to decide where to put their traffic.
- What I rejected, and why
- A branded landing page per advertiser — the pattern most CPA networks default to — was rejected because it forces publishers to open a dozen tabs to comparison-shop, which is exactly the friction the portal was meant to remove.
- What I did instead
- A single, dense, sortable programs table with payout, geo coverage, EPC, category and live/available/paused status as first-class columns, and Get Link in the row itself — so a tracking link is three clicks from arrival. Brand and category stay filters rather than separate destinations, and programs with no EPC history show an explicit dash instead of a zero that would read as poor performance.
- What it moved
- Discovery-to-link collapses to three clicks — open Browse Programs, Get Link, copy — with no request, no queue and no Ad Ops step. This is the mechanism behind the < 60 sec headline.
Decision 2. Sub-ID macros as structured fields, not a raw query-string builder
- The tension
- Power publishers wanted granular attribution (which ad, which placement, which creative drove a sale) without hand-building URL parameters; casual publishers needed to be able to ignore that complexity entirely and just grab a link.
- What I rejected, and why
- Exposing a raw macro/query-string editor (copy-paste ?subid1=&subid2= ) was rejected — it's the developer-facing pattern most ad networks ship, and it punishes the publishers who don't already know the syntax.
- What I did instead
- A "Get link" drawer that opens with the working tracking link already generated and copyable. Below it, progressively: a Click ID toggle, a deep-link field labelled "Send traffic to a specific product page", and eight labelled Sub-ID macros under "Track placements, campaigns and sources" — each with its own tooltip. The simple path and the power path live in one component instead of two tools, and a publisher who wants nothing more than the link never has to understand the rest.
- What it moved
- The common case — grab a link — stays a single drawer with the link already generated, while the eight Sub-ID macros serve publishers who need attribution without a second tool.
Decision 3. Push payout changes into a filterable activity panel, not a changelog page
- The tension
- Payouts and program availability change often (rate increases, rate cuts, pauses) and publishers who don't notice a rate drop keep sending traffic to a now-worse program — but a page they have to remember to check doesn't solve that.
- What I rejected, and why
- A static "program changelog" page was rejected because it relies on the publisher remembering to look, which is the same failure mode as the email queue it replaced.
- What I did instead
- A "Recent activity" slide-over, reachable from any screen and filterable by All / Programs / Payouts. Every payout-affecting event states its own before → after and percentage delta inline — $24.00 → $18.00, −25.0% — rather than saying "a rate changed" and making the publisher go look. Program availability, invoices and payments share the same feed, and unread items hold a tint until read so a rate cut cannot be scrolled past unnoticed.
- What it moved
- A rate cut such as $24.00 → $18.00 (−25.0%) is legible in the feed without opening the program, so publishers see it before they send more traffic to a now-worse program.

Reports
Reporting shipped as two purpose-built reports rather than a general analytics suite: Daily Stats to answer "which program deserves my traffic", and Conversion Reports to answer "what happened to this specific conversion, and have I been paid for it". Two questions, two surfaces, one shared date range.
Decision 4. Daily Stats — a drill path, not four separate reports
- The tension
- A publisher's real question is nested, not flat: earnings are down — on which day, then which program, then which country, then which Sub ID. A network's instinct is to ship one report per dimension and make the publisher rebuild the filter each time.
- What I rejected, and why
- Four separate reports in the nav, and the full column set shown by default. Separate reports repeat the per-category micro-site mistake inside reporting — each dimension change throws away the context just established. Showing every column makes the ones that settle a payment argument compete with the ones almost nobody needs.
- What I did instead
- One table with a visible drill path — Daily › Program › Geo › Sub ID — that accumulates as you go, with Clear path always present so no step is a dead end. Each level narrows the one below it, so context is never rebuilt. A pinned totals row states its own row count, because a publisher reconciling a payment needs the subtotal they are arguing about on screen.
- What it moved
- A publisher can go from “earnings are down” to the offending Sub-ID without rebuilding a filter, and can reconcile a payment against a subtotal that is on screen.
Decision 5. Conversion Reports — 21 columns with two frozen, not a chart
- The tension
- A conversion is not a single fact. It carries a conversion date and a last-updated date, a sale value and an original sale value, current earnings and original earnings, a program ID, a brand and a publisher click ID. A publisher chasing one payment needs all of it on the same row — and 21 columns will not fit on a screen.
- What I rejected, and why
- Leading with charts, and overwriting restated values in place. A chart cannot answer "where is this specific payment"; and if a restated payout silently replaced the original, the publisher would have no way to see that the number changed — which is the exact moment trust in a network breaks.
- What I did instead
- A row-level ledger that scrolls horizontally with Conversion Date and Program frozen, so the two identifiers a publisher scans by never leave the screen. Original earnings and original sale value sit as their own columns beside the current values, so a restatement is visible by comparison rather than announced. Customize Columns carries a count badge — 21 here against Daily Stats' 7 — so the reader knows how much table exists beyond the viewport, and the totals row sums only the money column it can legitimately sum.
- What it moved
- A restated payout is visible by comparison instead of silently overwritten — $312.30 original against $297.43 now — so trust in the network survives a restatement.
Dashboard KPIs
Publisher dashboard: every KPI carries its approved, pending and declined split, so no earnings number is read without knowing how much can still be reversed.

Strategic considerations: what I deliberately did not build
We considered building a full analytics/reporting suite alongside the portal in this first release but ruled it out — the actual bottleneck was link generation, not reporting depth. Instead, this release traded a richer analytics dashboard for shipping the program-browse-to-link flow completely, on the bet that removing the biggest daily friction point mattered more, in this release, than dashboards nobody would open until the core workflow worked.
Supporting evidence
Data
Prior process: publisher link requests routed through Ad Ops email, no SLA, average multi-day turnaround per request. Post-launch: link generation is self-serve and instant, removing Ad Ops from the request path entirely for the 21-program catalog.
Design system reuse
Status pills (live / available / paused), payout deltas, and the notification feed all share one visual language with the dashboard's balance and activity summary — a publisher never has to relearn what a color or icon means moving between screens.
Financial trust signals
Because publishers are watching real money move, every payout-affecting change (rate increase, rate decrease, pause) is written in the same explicit "before → after, %change" format everywhere it appears, so trust in the number is never in question.