DEV Community

Anand Rathnas
Anand Rathnas

Posted on • Originally published at jo4.io

Why We Silently Drop Clicks for Paused Campaigns (Instead of Returning 404)

This article was originally published on Jo4 Blog.

A brand pauses their affiliate campaign. What should happen when clicks and conversions still come in?

My first instinct was "return 404 for everything." Campaign is paused, resource doesn't exist, done. That instinct was wrong.

TL;DR

Two endpoints handle inactive campaigns differently — by design. Pixel tracking returns HTTP 200 with a transparent 1x1 GIF. Postback endpoints return HTTP 400 with "Campaign is not active." The asymmetry exists because the callers are fundamentally different.

Pixel Tracking: Return 200, Always

Affiliate pixels fire from third-party ad networks. A <img> tag buried in an ad creative, loaded by a browser on some publisher's website. The caller is a dumb HTML tag. It cannot:

  • Read the HTTP status code
  • Retry on failure
  • Alert anyone that something went wrong
  • Make decisions based on the response

If we return 404 for a paused campaign's pixel, nothing useful happens. The ad network doesn't know. The publisher doesn't know. The browser silently logs a failed image load in the console. Nobody acts on it.

Worse, some ad networks flag domains that return too many 4xx responses. They interpret it as a dead endpoint and may stop firing the pixel entirely — even after the campaign is reactivated.

So we return 200 and a transparent 1x1 GIF:

public ResponseEntity<byte[]> trackPixel(String campaignSlug, String publisherRef) {
    CampaignEntity campaign = campaignRepository.findBySlug(campaignSlug)
        .orElse(null);

    if (campaign == null || campaign.getStatus() != CampaignStatus.ACTIVE) {
        // Silent drop — return valid GIF, record nothing
        return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_GIF)
            .body(TRANSPARENT_1X1_GIF);
    }

    // Active campaign — record the click
    clickService.recordClick(campaign, publisherRef);
    return ResponseEntity.ok()
        .contentType(MediaType.IMAGE_GIF)
        .body(TRANSPARENT_1X1_GIF);
}
Enter fullscreen mode Exit fullscreen mode

Same response either way. The only difference is whether we call recordClick(). The pixel caller can't tell.

Postback Endpoints: Return 400, Loudly

Postbacks are server-to-server. A brand's backend calls our API when a conversion happens. The caller is software. It can:

  • Read the HTTP status code
  • Log errors
  • Alert developers
  • Stop sending conversions for paused campaigns

If we silently accept conversions for a paused campaign, the brand thinks commissions are accruing. They're not — we're dropping them. That's worse than an error. That's silent data loss that shows up as a billing discrepancy 30 days later during settlement.

public ResponseEntity<ConversionResponse> recordPostback(
        String campaignSlug, ConversionRequest request) {

    CampaignEntity campaign = campaignRepository.findBySlug(campaignSlug)
        .orElseThrow(() -> new NotFoundException("Campaign not found"));

    if (campaign.getStatus() != CampaignStatus.ACTIVE) {
        throw new BadRequestException("Campaign is not active");
    }

    ConversionEntity conversion = conversionService.record(campaign, request);
    return ResponseEntity.ok(mapper.toResponse(conversion));
}
Enter fullscreen mode Exit fullscreen mode

400 with a clear message. The brand's integration logs it. Their developer sees it. They stop sending conversions or reactivate the campaign. The system is honest.

The Enum Fix

While working on this, I noticed conversion statuses were stringly typed:

// BEFORE
conversion.setStatus("APPROVED");
conversion.setStatus("REJECTED");
Enter fullscreen mode Exit fullscreen mode

Two strings, no validation, no autocomplete, no compile-time safety. One typo — "APROVED" — and you have a conversion in a status that nothing in the system recognizes.

// AFTER
public enum ConversionStatus {
    PENDING,
    APPROVED,
    REJECTED,
    FLAGGED
}

conversion.setStatus(ConversionStatus.APPROVED);
Enter fullscreen mode Exit fullscreen mode

Small fix. Eliminated an entire class of bugs. This is the kind of cleanup you do while you're already in the file — not worth a separate PR, but absolutely worth doing.

The Principle

The asymmetry comes down to one question: can the caller act on an error?

Caller Can read status? Can act on error? Correct behavior
Pixel (img tag) No No 200 + silent drop
Postback (API) Yes Yes 400 + clear message

This applies beyond affiliate tracking. Any time you have multiple integration points for the same underlying operation, ask: who's calling, and what can they do with an error?

  • Webhook receivers that can retry? Return errors.
  • Fire-and-forget tracking pixels? Return 200.
  • Mobile SDKs that show error UI? Return errors.
  • Third-party script tags? Fail silently.

Do you have endpoints that intentionally return different status codes for the same underlying state? I'd be curious if others have hit this same design tension.

Building jo4.io — where even the error responses are a design decision.

Top comments (0)