Webhook protocol
asakit forwards what it resolves to any HTTP endpoint you control. Three independent channels, configured per app in the console under Dashboard → Webhooks.
| Channel | Fires | Carries |
|---|---|---|
| Attribution events | every completed AdServices resolution, attributed or not | which campaign / keyword an install came from, keyed by app_user_id |
| Cost | once a day | campaign × country × day spend for the trailing window |
| Revenue events | every purchase, trial, renewal or refund asakit stores | the money, plus the campaign the paying user came from |
The payloads below are the contract. asakit posts JSON and reads nothing about your receiver: any 2xx is success, anything else is retried.
Attribution events use the generic analytics-event shape (type / event / distinct_id / properties / context in a batch), so a collector that already speaks it needs no glue — point the URL at it and set the auth headers. Anyone else parses the same documented JSON.
Channel ①: attribution events
Fires as soon as Apple answers the AdServices lookup for a device.
POST <your URL>
<your configured headers>
Content-Type: application/json
{
"sent_at": 1785730000000, // send moment, epoch ms
"events": [
{ // only when attributed
"type": "user_set",
"distinct_id": "<your app_user_id>",
"event_id": "asa-<app_user_id>-set",
"time": 1785730000000, // resolution moment, epoch ms
"properties": {
"channel": "asa",
"asa_campaign_id": "67890", // user traits are dimensions — ids as strings
"asa_adgroup_id": "111",
"asa_keyword_id": "222",
"asa_conversion_type": "Download" // Download | Redownload
// + your static properties
}
},
{ // always, attributed or not
"type": "track",
"event": "asa_attribution",
"distinct_id": "<your app_user_id>",
"event_id": "asa-<app_user_id>-track",
"time": 1785730000000,
"properties": {
"attributed": true,
"org_id": 12345, // properties keep Apple's original types
"campaign_id": 67890,
"ad_group_id": 111,
"keyword_id": 222,
"ad_id": null,
"conversion_type": "Download",
"click_date": "2026-07-31",
"country_or_region": "US"
// + your static properties
},
"context": {
"platform": "server",
"lib": "asakit",
"utm_source": "apple_search_ads", // string copies of the ids, for UTM-keyed receivers
"utm_campaign": "67890", // = campaign_id
"utm_term": "222", // = keyword_id
"utm_content": "111" // = ad_group_id
}
}
]
}- distinct_id is the app_user_id your app sent with the AdServices token, passed through unchanged — the same id you use for revenue.
- event_id is derived from app_user_id, never random. Retries and repeat resolutions of the same device carry the same ids, so deduplicate on event_id and nothing double-counts.
- Not attributed: no user_set; the track event carries attributed: false plus your static properties, and the attribution fields and utm_* keys are omitted entirely.
Configuration
| Field | Meaning |
|---|---|
| Enabled | on / off |
| URL | POST target |
| Headers | attached verbatim to every request — put the receiver’s auth credentials here; asakit never interprets them |
| Static properties | key/value pairs merged into every event’s properties (e.g. env = release) |
Channel ②: cost
Once per UTC day, after 08:00 UTC (by then the ad platform’s day boundary has settled). The whole window is re-sent every run, not a delta — ad platforms backfill corrections for days after the fact, and a full re-push lets your receiver converge by upserting.
POST <your URL>
<your configured headers>
Content-Type: application/json
{
// ← your configured body fields are merged in at the top level (project, token, …)
"rows": [
{
"day": "2026-08-02",
"media_source": "apple_search_ads",
"campaign": "67890", // campaign_id as a string
"country": "US",
"cost": 38.5,
"currency": "USD",
"installs": 31, // Apple Search Ads installs
"impressions": 4200,
"clicks": 120 // Apple Search Ads taps
}
]
}- Granularity is campaign × country × day. Upsert on (day, campaign, country) — re-pushing the same window must be a no-op apart from corrected numbers.
- campaign is the join key. It is the same value, in the same format, as channel ①’s context.utm_campaign — the numeric campaign id as a string. That match is what makes ROAS line up on your side; never key one channel on the id and the other on the campaign name.
- Send test uses an inert cost row (campaign asakit-test, country ZZ, all metrics zero) so probing an endpoint can never leave plausible-looking spend behind.
Configuration
| Field | Meaning |
|---|---|
| Enabled | on / off |
| URL | POST target |
| Headers | attached verbatim to every request — put the receiver’s auth credentials here; asakit never interprets them |
| Body fields | key/value pairs merged into the request body’s top level (auth token, project id, …) |
| Window (days) | how many trailing days are re-sent each run — default 7 |
Channel ③: revenue events
Fires per stored revenue event, whatever the source — the RevenueCat webhook or the generic ingest (/v1/revenue/:appId). Same batch shape as channel ①.
POST <your URL>
<your configured headers>
Content-Type: application/json
{
"sent_at": 1785730000000,
"events": [
{
"type": "track",
"event": "purchase", // see the event names below
"distinct_id": "<your app_user_id>",
"event_id": "asa-rev-<source event id>",
"time": 1785730000000, // the purchase time, not the send time
"properties": {
"revenue_usd": 9.99, // gross, signed — refunds are negative
"revenue_net_usd": 6.99, // after the store cut
"currency": "USD",
"product_id": "annual_pro",
"store": "APP_STORE",
"environment": "PRODUCTION",
"period_type": "NORMAL",
"is_trial": false,
"is_renewal": false,
"is_refund": false,
"source_type": "INITIAL_PURCHASE", // the source's own classifier, unmapped
"channel": "asa", // attribution dimensions, when the user has them
"campaign_id": "67890",
"ad_group_id": "111",
"keyword_id": "222"
// + your static properties
},
"context": {
"platform": "server",
"lib": "asakit",
"utm_source": "apple_search_ads",
"utm_campaign": "67890",
"utm_term": "222",
"utm_content": "111"
}
}
]
}- Event names. Money events: purchase, renewal, trial_started, refund, refund_reversed. Subscription lifecycle — things that happen to a subscription, always zero-value — get their own names and are never purchase: subscription_expired, subscription_cancelled (auto-renew turned off, not a refund), subscription_uncancelled, billing_issue, subscription_paused, subscription_extended, product_change, transfer. Count conversions on purchase / renewal only; properties.source_type always carries the source's raw classifier.
- event_id is the source's own event id (RevenueCat's, or the event_id you posted to the generic ingest), so a redelivery or a re-push of history collapses on event_id — same rule as channel ①.
- Refunds are sent as negative revenue with is_refund: true, never as a deletion. Subtract; don't delete.
- The attribution dimensions ride along, so LTV splits by campaign without a join. They are the same strings as channel ①'s utm_*. A user with no attribution row simply has no channel / campaign_id keys.
- Only newly stored events are queued: a source redelivering an event asakit already has does not re-send it.
Configuration
| Field | Meaning |
|---|---|
| Enabled | on / off |
| URL | POST target |
| Headers | attached verbatim to every request — put the receiver’s auth credentials here; asakit never interprets them |
| Static properties | key/value pairs merged into every event’s properties (e.g. env = release) |
Delivery, retries, idempotency
- Any 2xx is success. Anything else — including a timeout or a connection error — retries the whole batch at 1m → 10m → 1h → 6h → 24h, then stops.
- Delivery is at-least-once. Channel ① dedupes on event_id; channel ② converges by upsert. Build your receiver so a repeat is harmless.
- Send test posts one sample payload using the values currently in the form, so an endpoint can be probed before it is saved, and shows the response code and body. Test sends appear in the delivery log tagged test and are never retried.
- Recent deliveries shows the last 20 attempts per channel with status, HTTP code and attempt count.
- Request timeout is 15s.
Notes
- Targets must be publicly reachable — private and loopback addresses are refused.
- Headers and body fields hold credentials, so they are encrypted at rest with the service master key (same as the Apple .p8). They remain readable in the console by the account that set them.
- Nothing is queued while a channel is off: switching it on starts forwarding from that moment, and does not backfill past resolutions. Cost, by contrast, starts covering the whole window on its next daily run.