Skip to content

Revenue

Money is where vendors differ most. Each one counts revenue its own way, through its own API or its own parameter. So Herald has no single revenue event that suits every vendor. Instead, each vendor module has its own revenue type, in that vendor's terms, and you map your purchase to it. Philosophy explains why.

What each vendor needs

Vendor Purchase Ad revenue
Firebase your event as it is, named GA4's purchase with value and currency parameters your event as it is
Mixpanel your event as it is; a charge on the person's profile needs a tracker of your own your event as it is
Adjust AdjustRevenueEvent, sent by RevenueEventTracker: the event under its token, with the amount and a transaction id AdjustAdRevenueEvent, sent by AdRevenueEventTracker
AppsFlyer AppsFlyerPurchaseEvent, sent by PurchaseEventTracker as af_purchase with af_revenue; for subscriptions, AppsFlyerSubscribeEvent and SubscribeEventTracker AppsFlyerAdRevenueEvent, sent by AdRevenueEventTracker
Amplitude AmplitudeRevenueEvent, sent by RevenueEventTracker through Amplitude's revenue API your event as it is
Meta MetaPurchaseEvent, sent by PurchaseEventTracker as fb_mobile_purchase —

In Dart, trackers carry their vendor's name, such as RevenueAdjustEventTracker, PurchaseAppsFlyerEventTracker, AdRevenueAppsFlyerEventTracker and PurchaseMetaEventTracker.

The event

The event describes the purchase and knows nothing about the vendors:

data class SubscriptionPurchased(
    val plan: String,
    val price: Double,
    val currency: String,
    val orderId: String,
) : Event {
    // Firebase and Mixpanel log it as it is: GA4's recommended purchase, with typed parameters.
    override val name = FirebaseAnalytics.Event.PURCHASE
    override val parameters = parameters {
        put(FirebaseAnalytics.Param.VALUE, price)
        put(FirebaseAnalytics.Param.CURRENCY, currency)
        put("plan", plan)
    }
}
struct SubscriptionPurchased: Event {
    let plan: String
    let price: Double
    let currency: String
    let orderId: String

    // Firebase and Mixpanel log it as it is: GA4's recommended purchase, with typed parameters.
    var name: String { "purchase" }
    var parameters: [String: AnalyticsValue] {
        ["value": .double(price), "currency": .string(currency), "plan": .string(plan)]
    }
}
final class const SubscriptionPurchased({
  required final String plan,
  required final double price,
  required final String currency,
  required final String orderId,
}) extends Event {
  // Firebase and Mixpanel log it as it is: GA4's recommended purchase, with typed parameters.
  @override
  String get name => 'purchase';

  @override
  Map<String, AnalyticsValue> get parameters => {
    'value': .double(price),
    'currency': .string(currency),
    'plan': .string(plan),
  };
}
export class SubscriptionPurchased implements Event {
  // Firebase and Mixpanel log it as it is: GA4's recommended purchase, with typed parameters.
  readonly name = 'purchase';

  constructor(
    readonly plan: string,
    readonly price: number,
    readonly currency: string,
    readonly orderId: string,
  ) {}

  get parameters(): EventParameters {
    return { value: double(this.price), currency: this.currency, plan: this.plan };
  }
}

Firebase and Mixpanel send it through their generic factories, with no extra code.

Mapping it for each vendor

Next to each vendor's factory, a small extension turns your event into that vendor's revenue type. Each type is a plain class with the vendor's own field names, so the mapping is a single constructor call.

name = name and parameters = parameters pass your event's name and parameters along. Adjust sends the parameters as callback parameters, AppsFlyer as extra event values and Amplitude as revenue properties. Leave parameters out to send none. AppsFlyer refuses a parameter with a key its revenue type sets itself, such as af_revenue, so the value you meant is never quietly replaced.

// Adjust: the tokened event, plus revenue, counted once per transaction.
fun SubscriptionPurchased.toAdjustRevenueEvent() = AdjustRevenueEvent(
    name = name,
    revenue = price,
    currency = currency,
    deduplicationId = orderId,
    parameters = parameters,
)

// AppsFlyer: af_purchase, with the amount under af_revenue.
fun SubscriptionPurchased.toAppsFlyerPurchaseEvent() = AppsFlyerPurchaseEvent(
    name = name,
    revenue = price,
    currency = currency,
    contentId = plan,
    orderId = orderId,
    parameters = parameters,
)

// Amplitude: the revenue API, deduplicated by the insert id.
fun SubscriptionPurchased.toAmplitudeRevenueEvent() = AmplitudeRevenueEvent(
    name = name,
    price = price,
    productId = plan,
    currency = currency,
    insertId = orderId,
    parameters = parameters,
)
extension SubscriptionPurchased {
    // Adjust: the tokened event, plus revenue, counted once per transaction.
    func toAdjustRevenueEvent() -> AdjustRevenueEvent {
        AdjustRevenueEvent(
            name: name, revenue: price, currency: currency, deduplicationId: orderId,
            parameters: parameters)
    }

    // AppsFlyer: af_purchase, with the amount under af_revenue.
    func toAppsFlyerPurchaseEvent() -> AppsFlyerPurchaseEvent {
        AppsFlyerPurchaseEvent(
            name: name, revenue: price, currency: currency, contentId: plan, orderId: orderId,
            parameters: parameters)
    }

    // Amplitude: the revenue API, deduplicated by the insert id.
    func toAmplitudeRevenueEvent() -> AmplitudeRevenueEvent {
        AmplitudeRevenueEvent(
            name: name, price: price, productId: plan, currency: currency, insertId: orderId,
            parameters: parameters)
    }
}
extension SubscriptionPurchasedMappings on SubscriptionPurchased {
  // Adjust: the tokened event, plus revenue, counted once per transaction.
  AdjustRevenueEvent toAdjustRevenueEvent() => AdjustRevenueEvent(
    name: name,
    revenue: price,
    currency: currency,
    deduplicationId: orderId,
    parameters: parameters,
  );

  // AppsFlyer: af_purchase, with the amount under af_revenue.
  AppsFlyerPurchaseEvent toAppsFlyerPurchaseEvent() => AppsFlyerPurchaseEvent(
    name: name,
    revenue: price,
    currency: currency,
    contentId: plan,
    orderId: orderId,
    parameters: parameters,
  );

  // Amplitude: the revenue API, deduplicated by the insert id.
  AmplitudeRevenueEvent toAmplitudeRevenueEvent() => AmplitudeRevenueEvent(
    name: name,
    price: price,
    productId: plan,
    currency: currency,
    insertId: orderId,
    parameters: parameters,
  );
}
// Adjust: the tokened event, plus revenue, counted once per transaction.
function toAdjustRevenueEvent(event: SubscriptionPurchased): AdjustRevenueEvent {
  return new AdjustRevenueEvent({
    name: event.name,
    revenue: event.price,
    currency: event.currency,
    deduplicationId: event.orderId,
    parameters: event.parameters,
  });
}

// AppsFlyer: af_purchase, with the amount under af_revenue.
function toAppsFlyerPurchaseEvent(event: SubscriptionPurchased): AppsFlyerPurchaseEvent {
  return new AppsFlyerPurchaseEvent({
    name: event.name,
    revenue: event.price,
    currency: event.currency,
    contentId: event.plan,
    orderId: event.orderId,
    parameters: event.parameters,
  });
}

// Amplitude: the revenue API, deduplicated by the insert id.
function toAmplitudeRevenueEvent(event: SubscriptionPurchased): AmplitudeRevenueEvent {
  return new AmplitudeRevenueEvent({
    name: event.name,
    price: event.price,
    productId: event.plan,
    currency: event.currency,
    insertId: event.orderId,
    parameters: event.parameters,
  });
}

Your event can't implement a vendor type, because those types are final classes. So only the mapping knows the vendor exists.

Counting a purchase once. Pass your transaction id as Adjust's deduplicationId, AppsFlyer's orderId and Amplitude's insertId. Then a purchase reported twice, for example by a retried callback or a restored receipt, still counts once.

The factories

Each vendor's factory takes your event, maps it, and hands the result to that vendor module's tracker, which knows the vendor's API:

class BillingAdjustEventTrackerFactory(private val adjust: AdjustInstance) : AdjustEventTrackerFactory {
    override fun create(event: Event): Resolution<AdjustEventTracker> = when (event) {
        is SubscriptionPurchased -> Resolution.Claimed(
            AdjustRevenueEventTracker(event.toAdjustRevenueEvent(), eventToken = "abc123", adjust),
        )
        else -> Resolution.Declined
    }
}

class BillingAppsFlyerEventTrackerFactory(
    private val appsFlyer: AppsFlyerLib,
    private val context: Context,
) : AppsFlyerEventTrackerFactory {
    override fun create(event: Event): Resolution<AppsFlyerEventTracker> = when (event) {
        is SubscriptionPurchased -> Resolution.Claimed(
            PurchaseEventTracker(event.toAppsFlyerPurchaseEvent(), appsFlyer, context),
        )
        else -> Resolution.Declined
    }
}

class BillingAmplitudeEventTrackerFactory(private val amplitude: Amplitude) : AmplitudeEventTrackerFactory {
    override fun create(event: Event): Resolution<AmplitudeEventTracker> = when (event) {
        is SubscriptionPurchased -> Resolution.Claimed(
            RevenueEventTracker(event.toAmplitudeRevenueEvent(), amplitude),
        )
        else -> Resolution.Declined
    }
}
struct BillingAdjustEventTrackerFactory: AdjustEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any AdjustEventTracker> {
        guard let purchase = event as? SubscriptionPurchased else {
            return .declined
        }
        return .claimed([
            RevenueAdjustEventTracker(event: purchase.toAdjustRevenueEvent(), eventToken: "abc123")
        ])
    }
}

struct BillingAppsFlyerEventTrackerFactory: AppsFlyerEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any AppsFlyerEventTracker> {
        guard let purchase = event as? SubscriptionPurchased else {
            return .declined
        }
        return .claimed([PurchaseAppsFlyerEventTracker(event: purchase.toAppsFlyerPurchaseEvent())])
    }
}

// Amplitude isn't marked Sendable, so a factory that stores it needs
// `@preconcurrency import AmplitudeSwift`.
struct BillingAmplitudeEventTrackerFactory: AmplitudeEventTrackerFactory {
    let amplitude: Amplitude

    func create(_ event: any Event) -> Resolution<any AmplitudeEventTracker> {
        guard let purchase = event as? SubscriptionPurchased else {
            return .declined
        }
        return .claimed([
            RevenueAmplitudeEventTracker(
                event: purchase.toAmplitudeRevenueEvent(), amplitude: amplitude)
        ])
    }
}
final class const BillingAdjustEventTrackerFactory() implements AdjustEventTrackerFactory {
  @override
  Resolution<AdjustEventTracker> create(Event event) => switch (event) {
    SubscriptionPurchased() => .claimed([
      RevenueAdjustEventTracker(event.toAdjustRevenueEvent(), 'abc123'),
    ]),
    _ => .declined(),
  };
}

final class const BillingAppsFlyerEventTrackerFactory(final AppsFlyerSdk appsFlyer)
    implements AppsFlyerEventTrackerFactory {
  @override
  Resolution<AppsFlyerEventTracker> create(Event event) => switch (event) {
    SubscriptionPurchased() => .claimed([
      PurchaseAppsFlyerEventTracker(event.toAppsFlyerPurchaseEvent(), appsFlyer),
    ]),
    _ => .declined(),
  };
}

final class const BillingAmplitudeEventTrackerFactory(final Amplitude amplitude)
    implements AmplitudeEventTrackerFactory {
  @override
  Resolution<AmplitudeEventTracker> create(Event event) => switch (event) {
    SubscriptionPurchased() => .claimed([
      RevenueAmplitudeEventTracker(event.toAmplitudeRevenueEvent(), amplitude),
    ]),
    _ => .declined(),
  };
}
export class BillingAdjustEventTrackerFactory implements AdjustEventTrackerFactory {
  create(event: Event): Resolution<AdjustEventTracker> {
    if (event instanceof SubscriptionPurchased) {
      return Resolution.claimed([
        new RevenueAdjustEventTracker(toAdjustRevenueEvent(event), 'abc123'),
      ]);
    }
    return Resolution.declined();
  }
}

export class BillingAppsFlyerEventTrackerFactory implements AppsFlyerEventTrackerFactory {
  constructor(private readonly appsFlyer: AppsFlyerClient) {}

  create(event: Event): Resolution<AppsFlyerEventTracker> {
    if (event instanceof SubscriptionPurchased) {
      return Resolution.claimed([
        new PurchaseAppsFlyerEventTracker(toAppsFlyerPurchaseEvent(event), this.appsFlyer),
      ]);
    }
    return Resolution.declined();
  }
}

export class BillingAmplitudeEventTrackerFactory implements AmplitudeEventTrackerFactory {
  constructor(private readonly amplitude: AmplitudeClient) {}

  create(event: Event): Resolution<AmplitudeEventTracker> {
    if (event instanceof SubscriptionPurchased) {
      return Resolution.claimed([
        new RevenueAmplitudeEventTracker(toAmplitudeRevenueEvent(event), this.amplitude),
      ]);
    }
    return Resolution.declined();
  }
}

Put them before the generic factory in each vendor's chain. Otherwise Amplitude's generic factory would take the purchase first and send it as an ordinary event. On Adjust, the factory passes the token to RevenueEventTracker itself, so the event needs no entry in TokenAdjustEventTrackerFactory.

Ad revenue

An ad impression reported by your ad mediation SDK reaches Adjust and AppsFlyer through their ad-revenue APIs, the same way:

data class AdImpression(val revenue: Double, val network: String) : Event {
    override val name = "ad_impression"
    override val parameters = parameters { put("network", network) }
}

fun AdImpression.toAdjustAdRevenueEvent() = AdjustAdRevenueEvent(
    name = name,
    source = "applovin_max_sdk",
    revenue = revenue,
    currency = "USD",
    adRevenueNetwork = network,
    parameters = parameters,
)

fun AdImpression.toAppsFlyerAdRevenueEvent() = AppsFlyerAdRevenueEvent(
    name = name,
    monetizationNetwork = network,
    mediationNetwork = MediationNetwork.APPLOVIN_MAX,
    revenue = revenue,
    currency = "USD",
    parameters = parameters,
)

class AdsAdjustEventTrackerFactory(private val adjust: AdjustInstance) : AdjustEventTrackerFactory {
    override fun create(event: Event): Resolution<AdjustEventTracker> = when (event) {
        is AdImpression -> Resolution.Claimed(AdjustAdRevenueEventTracker(event.toAdjustAdRevenueEvent(), adjust))
        else -> Resolution.Declined
    }
}

class AdsAppsFlyerEventTrackerFactory(private val appsFlyer: AppsFlyerLib) : AppsFlyerEventTrackerFactory {
    override fun create(event: Event): Resolution<AppsFlyerEventTracker> = when (event) {
        is AdImpression -> Resolution.Claimed(AppsFlyerAdRevenueEventTracker(event.toAppsFlyerAdRevenueEvent(), appsFlyer))
        else -> Resolution.Declined
    }
}
struct AdImpression: Event {
    let revenue: Double
    let network: String

    var name: String { "ad_impression" }
    var parameters: [String: AnalyticsValue] { ["network": .string(network)] }

    func toAdjustAdRevenueEvent() -> AdjustAdRevenueEvent {
        AdjustAdRevenueEvent(
            name: name, source: "applovin_max_sdk", revenue: revenue, currency: "USD",
            adRevenueNetwork: network, parameters: parameters)
    }

    func toAppsFlyerAdRevenueEvent() -> AppsFlyerAdRevenueEvent {
        AppsFlyerAdRevenueEvent(
            name: name, monetizationNetwork: network, mediationNetwork: .applovinMax,
            revenue: revenue, currency: "USD", parameters: parameters)
    }
}

struct AdsAdjustEventTrackerFactory: AdjustEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any AdjustEventTracker> {
        guard let impression = event as? AdImpression else {
            return .declined
        }
        return .claimed([AdRevenueAdjustEventTracker(event: impression.toAdjustAdRevenueEvent())])
    }
}

struct AdsAppsFlyerEventTrackerFactory: AppsFlyerEventTrackerFactory {
    func create(_ event: any Event) -> Resolution<any AppsFlyerEventTracker> {
        guard let impression = event as? AdImpression else {
            return .declined
        }
        return .claimed([
            AdRevenueAppsFlyerEventTracker(event: impression.toAppsFlyerAdRevenueEvent())
        ])
    }
}
final class const AdImpression(final double revenue, final String network) extends Event {
  @override
  String get name => 'ad_impression';

  @override
  Map<String, AnalyticsValue> get parameters => {'network': .string(network)};

  AdjustAdRevenueEvent toAdjustAdRevenueEvent() => AdjustAdRevenueEvent(
    name: name,
    source: 'applovin_max_sdk',
    revenue: revenue,
    currency: 'USD',
    adRevenueNetwork: network,
    parameters: parameters,
  );

  AppsFlyerAdRevenueEvent toAppsFlyerAdRevenueEvent() => AppsFlyerAdRevenueEvent(
    name: name,
    monetizationNetwork: network,
    mediationNetwork: AFMediationNetwork.applovinMax,
    revenue: revenue,
    currency: 'USD',
    parameters: parameters,
  );
}

final class const AdsAdjustEventTrackerFactory() implements AdjustEventTrackerFactory {
  @override
  Resolution<AdjustEventTracker> create(Event event) => switch (event) {
    AdImpression() => .claimed([AdRevenueAdjustEventTracker(event.toAdjustAdRevenueEvent())]),
    _ => .declined(),
  };
}

final class const AdsAppsFlyerEventTrackerFactory(final AppsFlyerSdk appsFlyer)
    implements AppsFlyerEventTrackerFactory {
  @override
  Resolution<AppsFlyerEventTracker> create(Event event) => switch (event) {
    AdImpression() => .claimed([
      AdRevenueAppsFlyerEventTracker(event.toAppsFlyerAdRevenueEvent(), appsFlyer),
    ]),
    _ => .declined(),
  };
}
export class AdImpression implements Event {
  readonly name = 'ad_impression';

  constructor(
    readonly revenue: number,
    readonly network: string,
  ) {}

  get parameters(): EventParameters {
    return { network: this.network };
  }

  toAdjustAdRevenueEvent(): AdjustAdRevenueEvent {
    return new AdjustAdRevenueEvent({
      name: this.name,
      source: 'applovin_max_sdk',
      revenue: this.revenue,
      currency: 'USD',
      adRevenueNetwork: this.network,
      parameters: this.parameters,
    });
  }

  toAppsFlyerAdRevenueEvent(): AppsFlyerAdRevenueEvent {
    return new AppsFlyerAdRevenueEvent({
      name: this.name,
      monetizationNetwork: this.network,
      mediationNetwork: MEDIATION_NETWORK.APPLOVIN_MAX,
      revenue: this.revenue,
      currency: 'USD',
      parameters: this.parameters,
    });
  }
}

export class AdsAdjustEventTrackerFactory implements AdjustEventTrackerFactory {
  create(event: Event): Resolution<AdjustEventTracker> {
    if (event instanceof AdImpression) {
      return Resolution.claimed([new AdRevenueAdjustEventTracker(event.toAdjustAdRevenueEvent())]);
    }
    return Resolution.declined();
  }
}

export class AdsAppsFlyerEventTrackerFactory implements AppsFlyerEventTrackerFactory {
  constructor(private readonly appsFlyer: AppsFlyerClient) {}

  create(event: Event): Resolution<AppsFlyerEventTracker> {
    if (event instanceof AdImpression) {
      return Resolution.claimed([
        new AdRevenueAppsFlyerEventTracker(event.toAppsFlyerAdRevenueEvent(), this.appsFlyer),
      ]);
    }
    return Resolution.declined();
  }
}

Android

Both vendor modules call their tracker AdRevenueEventTracker, so a file that uses both imports one under another name. Usually each lives in its own vendor's factory file, so it doesn't come up.

Moove's TicketPurchased reaches all five vendors this way, and its simulated ad impression reaches both ad-revenue APIs.