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

작성자

카테고리:

← 피드로
DEV Community · Anand Rathnas · 2026-07-11 개발(SW)

Anand Rathnas

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 1×1 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 1×1 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다