> ## Documentation Index
> Fetch the complete documentation index at: https://docs.semicola.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Event sources

> Register where an advertiser’s conversions come from, send conversion events server-to-server, and check source health.

An **event source** is where an advertiser's conversion events come from: a website, an app or a
server. You register it once, with the event types it sends and the domains it may send from, then
log events to it from your server. Semicola rolls the events up per day and event type and reports the
source's health.

<Note>
  Event sources count conversions and report health. A campaign can name a source in an event
  [optimization goal](/buy/campaign#optimization-goals): when that campaign buys on a seller,
  Semicola registers the source on the seller's account and forwards later events to it. Conversions
  don't feed Semicola's own reporting or attribution.
</Note>

## In the app

Open **Event sources** for an advertiser (or ask Semi, which calls `open_page` with
`page: "event_sources"`). **Add event source** asks for:

| Field               | Notes                                                                                                              |
| ------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Name**            | A label for people.                                                                                                |
| **Source key**      | Your stable id for the source (`eventSourceId`, up to 100 characters). Saving an existing key updates that source. |
| **Allowed domains** | Up to 50 domains the events come from. Empty means **Any domain**.                                                 |
| **Event types**     | At least one of the types below.                                                                                   |

Selecting a source shows **Log an event from your server**, a server-side snippet for that source, and
**Send test event**, which sends one event as test traffic (`testEventCode`): it's validated like any
event but not counted, so the source's health and counts don't change.

### Event types

| `eventType`             | Label             |
| ----------------------- | ----------------- |
| `page_view`             | Page view         |
| `view_content`          | View content      |
| `add_to_cart`           | Add to cart       |
| `initiate_checkout`     | Initiate checkout |
| `purchase`              | Purchase          |
| `lead`                  | Lead              |
| `complete_registration` | Registration      |
| `subscribe`             | Subscribe         |
| `app_install`           | App install       |
| `custom`                | Custom            |

### Health

| `health`          | Shown as        | Meaning                                               |
| ----------------- | --------------- | ----------------------------------------------------- |
| `not_seen`        | No events yet   | Nothing has been logged to the source.                |
| `receiving`       | Receiving       | The latest event is at most 72 hours old.             |
| `needs_attention` | Needs attention | Events arrived before, but none in the last 72 hours. |

Each source also reports `lastEventAt`, `eventsLast24h` and `eventsLast7d`.

## How a source reaches each seller

Register a source once, on the advertiser, never per seller. When a campaign whose
[optimization goal](/buy/campaign#optimization-goals) names the source sends a buy to a seller,
Semicola registers the source on that seller's account with AdCP `sync_event_sources`, as part of
sending the buy. That step waits at most 5 seconds (shared with forwarding the campaign's
[audiences](/buy/property-lists#audiences)) and never holds the buy back. The seller answers with its
own id for the source, and every later buy with that seller sends the seller's id in the goal, not
yours.

If the seller hasn't returned an id, the buy goes ahead without that one goal: Semicola never sends a
source id the seller was never given. The campaign's other goals still go.

Each source lists where it has been registered in `sellerProvisioning`, one entry per seller account:

| `status`        | Meaning                                                                                                                             | What happens next                                                               |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `provisioned`   | The seller returned its own id (`sellerId`).                                                                                        | Buys with that seller send the seller's id, and new events are forwarded to it. |
| `needs_install` | The seller accepted the source but hasn't returned an id yet. Some sellers need a one-time setup step in their own tools first.     | Tried again on the next buy with that seller.                                   |
| `unsupported`   | The seller doesn't take event sources this way: it doesn't declare conversion tracking, or it answered that the task isn't offered. | Tried again on the first buy with that seller once 30 days have passed.         |
| `failed`        | The seller rejected the source or didn't answer in time; `error` says why.                                                          | Tried again on the next buy with that seller.                                   |

An entry also carries `storefrontId` (the seller), `sellerAccountId` (the seller account the source
was registered on: the mapped ads account for a platform seller, otherwise the advertiser's brand
domain) and `lastSyncedAt`. Every source is `managedBy: "buyer"` with `sellerId: null`.

Turning **Events** off for a seller in [Connections](/buy/connections#distribution) stops all of
this for that seller: no registration, no forwarded events, and event goals are left out of its buys.

## From an agent

Over MCP, find sources with `search` (`kind: "measurement_source"`, `filter.advertiserId`, and a
`query` matched against the name, `eventSourceId` and integration platform) and read one with `get`,
passing its `id` and the `advertiserId`. The `id` is `event:` followed by the `eventSourceId`; campaign
goals use the plain `eventSourceId`. Archived sources aren't listed. Create or
update one with `save_measurement_source` (`sourceType: "event"`), and log events with `log_event`
(the same fields as the REST call below). An agent can also send the person to the **Event sources**
page. See the [Set up an event source](/skills/set-up-an-event-source) skill.

## REST

All paths are under `https://api.semicola.com/api/v2/buyer`.

| Method and path                                       | What it does                                                        |
| ----------------------------------------------------- | ------------------------------------------------------------------- |
| `GET /event-sources?advertiserId=…`                   | List sources with health and `sellerProvisioning`.                  |
| `POST /event-sources`                                 | Create or update one source.                                        |
| `POST /advertisers/{advertiserId}/event-sources/sync` | Create or update several sources (AdCP `sync_event_sources` shape). |
| `POST /event-sources/{key}/events`                    | Log up to 10,000 events to one source.                              |

### Create or update a source

```bash theme={null}
curl -X POST "https://api.semicola.com/api/v2/buyer/event-sources" \
  -H "Authorization: Bearer $SEMICOLA_API_KEY" \
  -H "X-Account-Id: $ACCOUNT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "advertiserId": 12,
    "eventSourceId": "glaze-web",
    "name": "Glaze & Co. website",
    "eventTypes": ["purchase", "add_to_cart"],
    "allowedDomains": ["glazeandco.example"]
  }'
```

The response is `{ "created": true, "source": { … } }`; `created` is `false` when the key already
existed. `integrationPlatform` is an optional free-text label.

### Sync several sources

```json theme={null}
{
  "event_sources": [
    { "event_source_id": "glaze-web", "name": "Website", "event_types": ["purchase"] },
    { "event_source_id": "glaze-app", "event_types": ["app_install"] }
  ]
}
```

Each entry reports `action`: `created`, `updated` or `failed` (with an `error`, for example when
`event_types` lists no supported type). `delete_missing` isn't supported and is refused: a sync never
deletes sources.

### Log events

```bash theme={null}
curl -X POST "https://api.semicola.com/api/v2/buyer/event-sources/glaze-web/events" \
  -H "Authorization: Bearer $SEMICOLA_API_KEY" \
  -H "X-Account-Id: $ACCOUNT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "advertiserId": 12,
    "events": [
      { "eventType": "purchase", "eventTime": "2026-11-01T14:30:00Z", "value": 89.99, "currency": "USD", "actionSource": "website" }
    ]
  }'
```

| Per-event field | Notes                                                                      |
| --------------- | -------------------------------------------------------------------------- |
| `eventType`     | Required; must be enabled on the source.                                   |
| `eventTime`     | ISO 8601. Defaults to now. More than an hour in the future is rejected.    |
| `value`         | Optional, not negative.                                                    |
| `currency`      | ISO 4217. Defaults to the advertiser's primary currency.                   |
| `actionSource`  | `website`, `app`, `in_store`, `phone_call`, `system_generated` or `other`. |
| `eventId`       | Optional, up to 200 characters. Deduplicates: see below.                   |

```json theme={null}
{ "data": { "accepted": 1, "rejected": [], "duplicates": 0, "health": "receiving" }, "error": null }
```

`rejected` lists each refused event by `index` with a `reason`, for example "purchase is not enabled on
this source." Logging to a source key that doesn't exist returns `NOT_FOUND`.

**Use `eventId` for idempotency.** An event whose `eventId` was already logged for the same event type
on the same source, earlier in the batch or in an earlier call, is dropped and counted in `duplicates`
instead of `accepted`. Events without an `eventId` are always counted, so a resent one counts twice.
A rejected event (a type that isn't enabled, or a time more than an hour ahead) doesn't use up its id.
`log_event` over MCP behaves the same way.

**Test traffic.** Add `testEventCode` to the call to mark it as test traffic: its events are
validated and reported in `accepted`, `rejected` and `duplicates` like any others, but they aren't
counted in the source's totals or summaries, don't change its health or last event, and don't use up
their `eventId`s. Sellers receive them with `test_event_code`, so they validate them without counting
them either.

**Custom events.** An event with `eventType: "custom"` needs a `customEventName`; it's forwarded to
sellers as `custom_event_name`.

**Forwarding to sellers.** Accepted events that carry an `eventId` are forwarded with AdCP
`log_event` to every seller the source is `provisioned` on whose **Events** switch is on (unless
you've turned the source's syndication to that seller off), under the seller's own id for the
source; `value` and `currency` travel as `custom_data`. Events without an `eventId` are counted here
but never forwarded, because sellers deduplicate on it. Duplicates and rejected events aren't
forwarded. Every seller is sent the batch at once, and the log call waits at most 5 seconds for all
of them together; it doesn't report each seller's result. A seller that doesn't answer in time
misses that batch (resending it is safe: sellers deduplicate on `eventId`).

What Semicola keeps is a daily count and total value, and an hourly count, per event type, plus the
event ids it has seen. It doesn't store individual events or any identity fields, so the log call takes
no user-match data (hashed emails, click ids, device ids).

### Hourly summary

`GET /api/v2/buyer/advertisers/{advertiserId}/events/summary` returns hourly counts for the advertiser's
sources: optional `eventType`, `startHour` and `endHour` (ISO 8601; hours are whole UTC hours, end
exclusive). Without a window it covers the last 24 hours.

```json theme={null}
{
  "data": {
    "periodStart": "2026-11-01T14:00:00.000Z",
    "periodEnd": "2026-11-02T14:00:00.000Z",
    "entries": [{ "eventHour": "2026-11-01T14:00:00.000Z", "eventType": "purchase", "eventCount": 12 }],
    "totalEventCount": 12
  },
  "error": null
}
```

Hourly counts start with events logged after this summary was added; older events exist only as daily
counts.

Not available yet: per-event `custom_data`, match quality, seller-managed sources, and
pre-aggregated measurement data uploads.

## Related

* [Set up an event source](/skills/set-up-an-event-source)
* [Campaign optimization goals](/buy/campaign#optimization-goals)
* [Connections](/buy/connections#distribution)
* [Advertisers](/buy/advertiser)
* [Reporting](/buy/reporting)
