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.