> AffiliateOS Help Center. Index of every article for AI tools: https://www.affiliateos.ai/help/llms.txt

# Send events server to server (S2S)

Advertiser campaigns · For advertisers · Updated 30 October 2026
Web version: https://www.affiliateos.ai/help/campaigns/send-server-events

When a user does something you pay for, your server sends one `POST` to `https://affiliateos.xyz/v1/advertiser/events` with your `ak_` key and the click ID you stored for that user. We match it to the tap, charge the event's price if it's live and answer with a status.

## Before you start

- The user's stored `afos_click_id`: see [Capture afos_click_id on your site or app](https://www.affiliateos.ai/help/campaigns/capture-afos-click-id).
- Your `appId` and `campaignId`: open the campaign and choose **Show developer setup**.

## Steps

### Mint API key

Under **Show developer setup**, choose **Mint API key**, or use **Mint new key** on **API keys**. The key starts with `ak_` and is shown once, so copy it into your server's settings straight away. It goes in your server only: never in your app or a web page.

### Send the event

Send it as soon as it happens, from your server:

```bash
curl -X POST "https://affiliateos.xyz/v1/advertiser/events" \
  -H "Authorization: Bearer $AFOS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "clickId": "<stored afos_click_id>",
    "advertiserUserId": "tp_7731",
    "eventKey": "level_25",
    "eventId": "level_25_tp_7731",
    "timestamp": "2026-10-30T19:06:00.000Z",
    "appId": "<your appId>",
    "campaignId": "<your campaignId>"
  }'
```

Example: Tidewater Putt player `tp_7731` reaches level 25 at 2:06 PM in Texas. `x-api-key: ak_...` works in place of the `Authorization` header.

| Field | Required | What it is |
|---|---|---|
| `clickId` | On live events | The `afos_click_id` you stored. `attributionId` is accepted as the same thing. |
| `advertiserUserId` | Yes | Your own ID for the user. Each event is paid once per user. |
| `eventKey` | Yes | The event's key, or its name in your attribution tool. Case doesn't matter. |
| `eventId` | Yes | Your unique ID for this one occurrence. A retry sends the same one. |
| `timestamp` | Yes | When it happened, in ISO 8601. It must be within 48 hours of when we receive it. |
| `appId` | Yes | Your product's ID |
| `campaignId` | No | Recommended, so we don't have to find the campaign from the `appId` and key |
| `transactionId` | For purchases and deposits | Your order ID. Required when the key contains `purchase` or `deposit`, and it stops the same order being charged twice. |
| `revenue`, `currency` | No | What the user spent, for your return on ad spend reporting. It doesn't change the price you pay. |
| `platform` | No | `ios`, `android` or `web` |
| `test` | No | `true` checks the event without charging it |

Send only these fields. A field we don't know gets the whole request refused with HTTP 400.

### Read the answer

A request we accept gets a 2xx response with a JSON body such as `{"status": "approved", "logId": "...", "payoutUsd": 1.5}`. The `status` decides what happened:

| `status` | What it means | Charged? |
|---|---|---|
| `approved` | Accepted. A live event matched its tap. | Yes, its price. A test isn't charged. |
| `duplicate` | We already have this `eventId` or `transactionId`, or this user already had this event | No. Treat it as success. |
| `missing_attribution` | No click ID, or one we never issued | No |
| `invalid_event` | No campaign for this `appId` has this event key | No |
| `outside_window` | `timestamp` is more than 48 hours from now, or past the event's attribution window if it has one | No |
| `campaign_paused` | The campaign is paused | No |
| `insufficient_balance` | Your balance can't cover the price | No, and your live campaigns pause |
| `rejected` | Something else is wrong. **Event logs** gives the reason, such as a missing `transactionId` or a click ID used for another user. | No |

A bad or revoked key gets HTTP 401. A request with a missing or unknown field gets HTTP 400: fix it before you send it again. A burst above our rate limit also gets HTTP 400, with a rate-limit message: wait, then retry with the same `eventId`.

### Retry safely

If a request times out, gets a 5xx or hits the rate limit, send it again with the same `eventId`. A repeat of an event we already have comes back as `duplicate` and is never charged twice. Fix a 400 for a bad field or a 401 before you retry: sending it again unchanged gets the same answer.

### Check it worked

Open the campaign, then **Event logs**. Each event shows its time, key, user, status and price, with the reason under it when there is one.

> **Only an approved live event costs money.** It's charged at the event's price, from your balance, at the moment we accept it. [How spend is drawn](https://www.affiliateos.ai/help/payouts/advertiser-funds-and-spend)

Before your campaign goes live, send each event with `"test": true` instead: [Send test events without being charged](https://www.affiliateos.ai/help/campaigns/send-test-events). The full reference is in the [developer docs](https://www.affiliateos.ai/help/developers#advertiser-events).
