# Bidder Integration — Moments Exchange (Markdown)

> Source: https://offeros.com/docs.html — Bidder integration reference for the OfferOS Moments Exchange.

# Bidder integration

The Moments Exchange is a closed programmatic exchange. Advertisers hold a seat, receive a bid request on every Moment, and respond with a price and a creative. A Moment is a premium ad placement implemented by a publisher that is high impact — usually after someone takes action on the publisher website and as such has a relatively higher click-through rate and engagement.

One **OpenRTB 2.6** BidRequest per Moment, carrying a **Native 1.2** request. If you already buy native inventory elsewhere, point your existing bidder at us. Our richer offer fields ride on custom native data types `500` and above, which Native 1.2 reserves for exchange use, and anything without a standard home travels in `ext.moments` and comes back intact.

## Getting a Seat

The exchange is closed. Seats are provisioned by MomentScience.

To get a seat, reach out via https://momentscience.com/contact-us/. Tell us where to send the bid request. We configure that URL on your seat and issue your credentials.

**What you supply**

- **Bid endpoint URL** — required. An `https` endpoint that accepts `POST` with `application/json`, speaks **OpenRTB 2.6 with a Native 1.2 request**, and responds inside your seat timeout (clamped to `tmax`, 300ms default).
- **Advertiser and contact** — display name, domain (`adomain`), and a contact email so ops can reach you.

Win and billing notices need nothing configured on your seat — you put `nurl` and `burl` on each bid and we fire them.

**What we provision**

- **Seat code** — e.g. `xdsp` or `agency-dsp`. Sent on every request as `X-Moments-Seat`.
- **Outbound key** — we sign every bid request to you with `Authorization: Bearer <seat.outboundKey>`. Verify this before you answer.
- **API key** — you send this as `Authorization: Bearer <seat API key>` when you call us (`/api/bid/:auctionId`, `/api/offers`). Scoped to your seat.
- **Status and mode** — seats start `inactive` and become `approved` + `active` before we call you. `sync` is the default; `async` lets you ack then `POST /api/bid/:auctionId` before the window closes.

Only `approved` + `active` seats are called. A seat that has not been approved is never contacted — that is what makes it a closed network.

## Authentication

The seat credential is the authorization. There is no OAuth, no ads.txt, and no sellers.json.

- **Outbound** (exchange → your bidder): `Authorization: Bearer <seat.outboundKey>`, `X-Moments-Seat: <seat.code>`, `User-Agent: MomentsSSP/1.0`. Verify these before you answer.
- **Inbound** (your bidder → exchange, for offer registration and async bids): `Authorization: Bearer <seat API key>`. Unknown key `401`; inactive seat `403`. Every route is scoped to your seat.
- **Notices** (`nurl` / `burl`): fired to *your* URL with no authorization header. The macros we substitute are what identify the auction.

```http
POST <your seat bid endpoint>
Content-Type: application/json
Authorization: Bearer <seat.outboundKey>
X-Moments-Seat: <seat.code>
User-Agent: MomentsSSP/1.0
```

## Bid Request

We `POST` one OpenRTB 2.6 BidRequest to the endpoint configured on your seat for every Moment. Respond inside the timeout on your seat — **300ms by default**. The floor is **deliberately absent**: you bid blind, and no rejection we return ever names it.

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string | yes | The auction identifier. Echo it back on your conversion postback. |
| `at` | 1 \| 2 | yes | 1 first price, 2 second price. Reflects the partner's real setting. |
| `tmax` | integer | yes | Time budget in ms — lesser of your seat timeout and the auction budget. Default 300. |
| `cur` | string[] | yes | Always `["USD"]`. |
| `badv` | string[] | no | Blocked advertiser domains; only domain-shaped entries. Omitted when empty. |
| `imp[0].id` | string | yes | Always `"1"`. Exactly one impression per request. |
| `imp[0].tagid` | string | yes | Placement slot. Default `moments_cou_v1`. |
| `imp[0].bidfloorcur` | string | yes | `"USD"`. There is no `bidfloor` — omitted, not zero. |
| `imp[0].native` | object | yes | `{ ver: "1.2", request }`; `request` is the Native 1.2 request as a JSON-encoded string. |
| `imp[0].ext.moments.momentType` | string | yes | Kind of Moment. Default `post_transaction`. |
| `site.publisher.id` | string | yes | Partner code. No `site.domain`/`site.page` — merchant domain never exposed. |
| `site.publisher.ext.moments.vertical` | string | yes | Partner category axis, e.g. `Telecom`. |
| `ext.moments.blocked` | object | yes | `{ categories, advertisers }`, lowercased. How the auction actually matches. |
| `ext.moments.context` | object | no | Extra request context when the partner supplies it. |

`bidfloor` is omitted, not zero: under first price the floor is the cheapest price at which you can win, so publishing it would invite every bidder to converge on floor plus a cent. `bcat` is **not** populated — our categories are not an IAB taxonomy and sending them as one would have you mis-filter. There is no `app`, `device`, `user` or `site.domain`.

```json
{
  "id": "b7c1e0f2-...",
  "at": 1,
  "tmax": 300,
  "cur": ["USD"],
  "badv": ["evil.example.com"],
  "imp": [{
    "id": "1",
    "tagid": "moments_cou_v1",
    "bidfloorcur": "USD",
    "native": { "ver": "1.2", "request": "<JSON-encoded Native request, below>" },
    "ext": { "moments": { "momentType": "post_transaction" } }
  }],
  "site": {
    "publisher": { "id": "partner-1-telecom", "ext": { "moments": { "vertical": "Telecom" } } }
  },
  "ext": {
    "moments": {
      "blocked": { "categories": ["gambling"], "advertisers": ["luckystar casino"] },
      "context": {}
    }
  }
}
```

### The Native request inside it

`imp[0].native.request` is a JSON-encoded string, per the Native spec. Decoded, it is generated from the same field table our validator enforces, so it cannot drift from what is actually checked. Asset order follows that table — mandatory fields first, then optional.

```json
{
  "ver": "1.2",
  "context": 1,
  "plcmttype": 4,
  "plcmtcnt": 1,
  "privacy": 1,
  "assets": [
    { "id": 1,  "required": 1, "title": { "len": 60 } },
    { "id": 3,  "required": 1, "data": { "type": 2,   "len": 140 } },
    { "id": 4,  "required": 1, "data": { "type": 12,  "len": 30 } },
    { "id": 2,  "required": 1, "img":  { "type": 3, "wmin": 250, "hmin": 250 } },
    { "id": 5,  "required": 1, "data": { "type": 1,   "len": 80 } },
    { "id": 6,  "required": 0, "data": { "type": 500, "len": 90 } },
    { "id": 7,  "required": 0, "data": { "type": 501, "len": 30 } },
    { "id": 8,  "required": 0, "data": { "type": 10,  "len": 220 } },
    { "id": 9,  "required": 0, "data": { "type": 502, "len": 160 } },
    { "id": 11, "required": 0, "data": { "type": 504, "len": 2000 } }
  ],
  "eventtrackers": [{ "event": 1, "methods": [1, 2] }],
  "ext": { "moments": { "creativeSpec": { "mandatory": [...], "optional": [...], "image": { "square": true, "minWidth": 250, "minHeight": 250, "maxKb": 512 } } } }
}
```

`maxKb` and `square` have no native equivalent, which is why the raw spec is echoed in `ext.moments.creativeSpec`. A seat holding a landscape exception sees `square: false` there.

## Bid Response

Standard OpenRTB. `price` is CPM as a float in `cur`, and the creative is Native 1.2 markup in `adm`. Decline with `204`, an empty `seatbid`, or a top-level `nbr`.

| Field | Type | Required | Description |
|---|---|---|---|
| `seatbid[0].bid[0]` | object | yes | Only the first bid of the first seatbid is read. |
| `price` | float | yes | CPM in the request currency. Must be > 0 and finite. No readable price → `no_bid`, not zero. Response `cur` is not read. |
| `adm` | string | yes | Native 1.2 markup response, JSON-encoded. An already-parsed object is also accepted. |
| `adomain` | string[] | yes | Only `adomain[0]` is read, and only if the markup did not carry `advertiser_domain`. |
| `cat` | string[] | no | Advertiser taxonomy; `cat[0]` becomes the primary category. |
| `nurl` | url | no | Win notice, fired when the auction resolves you as the winner. Supports `${AUCTION_*}` macros. |
| `burl` | url | no | Billing notice, fired when the impression is confirmed. Same macros. |
| `crid` | string | no | Your own creative id. Audit only — **not** used for attribution. Use `ext.moments.offerId`. |
| `ext.moments.offerId` | string | no | Names a registered offer. What attribution and Target CPA key on. |
| `ext.moments.bidMode` | string | no | How you priced the bid, e.g. `target_cpa`. |
| `id`, `impid`, `seat` | string | no | Standard OpenRTB. Recorded, not validated. |
| `lurl` | url | no | Not read. **There is no loss notice.** |

```json
{
  "id": "b7c1e0f2-...",
  "cur": "USD",
  "seatbid": [{
    "seat": "northwind",
    "bid": [{
      "id": "bid-1",
      "impid": "1",
      "price": 42.50,
      "adomain": ["northwindmobile.com"],
      "cat": ["Telecom"],
      "crid": "your-own-creative-id",
      "nurl": "https://you.example/win?a=${AUCTION_ID}&p=${AUCTION_PRICE}",
      "burl": "https://you.example/bill?a=${AUCTION_ID}&p=${AUCTION_PRICE}",
      "adm": "<JSON-encoded Native response, below>",
      "ext": { "moments": { "offerId": "off_7875", "bidMode": "target_cpa" } }
    }]
  }]
}
```

**Migrating from the flat shape.** The MomentScience field vocabulary is unchanged — the same `short_headline`, `cta_yes`, `click_url` and the rest. What changed is where they travel: they are now native assets inside `adm` rather than a flat `creative` object. Legacy camelCase aliases (`shortHeadline`, `clickUrl`, `image_url`, `cta`, `landing_url`) still resolve, but only inside the native markup or `ext.moments`. A flat top-level `{ bidRpmCents, creative }` body is **no longer read on the live bid path** and scores as `no_bid`.

## Native Creative

`adm` decodes to the Native Markup Response, conventionally wrapped as `{ "native": { ... } }`. A bare native object is also accepted, as is an `adm` that arrives already parsed rather than as a string.

We key off the **data type, not the asset id**. If you reuse markup built for another exchange and its ids differ from ours, it still parses correctly. The asset id is only a tiebreak.

### Field map

Types 1, 2, 10 and 12 are the standard registry: sponsored, desc, desc2, ctatext. Types **500 and above are ours**, which Native 1.2 reserves for exchange-specific use.

| Moments field | Location | Data type | Max | Required |
|---|---|---|---|---|
| `short_headline` | asset 1, title | — | 60 | yes |
| `short_description` | asset 3, data | 2 (desc) | 140 | yes |
| `cta_yes` | asset 4, data | 12 (ctatext) | 30 | yes |
| `image` | asset 2, img type 3 | — | square ≥250×250, ≤512KB | yes |
| `advertiser_name` | asset 5, data | 1 (sponsored) | 80 | yes |
| `advertiser_domain` | `bid.adomain[0]` | — | 120 | yes |
| `click_url` | `link.url` | — | 500 | yes |
| `title` | asset 6, data | **500** | 90 | no |
| `cta_no` | asset 7, data | **501** | 30 | no |
| `description` | asset 8, data | 10 (desc2) | 220 | no |
| `mini_text` | asset 9, data | **502** | 160 | no |
| `terms_and_conditions` | asset 11, data | **504** | 2000 | no |

### Everything that is not an asset

- `link.url` — the click destination. Carries your click macros.
- `eventtrackers[]` — an entry with `event: 1` and a `url` becomes your impression pixel, fired client-side. Deprecated `imptrackers[0]` still read as a fallback.
- `ext.moments.beacons` — `{ close, no_thanks_click }`. Passed through untouched.
- `ext.moments.*` — merged into the creative wholesale. Anything without a standard native home rides here and comes back intact.
- `img.w` / `img.h` — when both present, recorded as `campaign_images`. Declared, not measured.

A data type we do not recognise is **kept**, not dropped — it lands under `ext_native_data` keyed by the type.

**503 and asset id 10 are retired.** They carried `mini_description`, a second 220-character description nobody could tell apart from type 10, so it was removed. A retired type is never reissued for something else: a buyer that had already implemented it would go on sending one field and have it read as another.

### Images

A bid with a broken creative is rejected *regardless of price*, because we cannot render it. Image dimensions, though, are optional: declare `img.w` / `img.h` and we check the shape at auction time; omit them and we skip that check and measure the file instead.

We never fetch an image during an auction — measuring one cold took 900 to 1100ms against a 300ms window. The file is measured when you register an offer, where you can still fix it, and verified again after an auction. A declared size that does not match the file is recorded against your seat, and a seat that keeps misreporting gets paused.

```json
{
  "ver": "1.2",
  "assets": [
    { "id": 1,  "title": { "text": "Get 3 months free on any 5G plan" } },
    { "id": 3,  "data": { "type": 2,   "value": "Switch today and we cover your first three bills." } },
    { "id": 4,  "data": { "type": 12,  "value": "Claim offer" } },
    { "id": 2,  "img":  { "type": 3, "url": "https://cdn.example.com/sq.png", "w": 400, "h": 400 } },
    { "id": 5,  "data": { "type": 1,   "value": "Northwind Mobile" } },
    { "id": 6,  "data": { "type": 500, "value": "Northwind 5G Unlimited, three months on us" } },
    { "id": 7,  "data": { "type": 501, "value": "No thanks" } },
    { "id": 8,  "data": { "type": 10,  "value": "Unlimited 5G data, calls and texts." } },
    { "id": 9,  "data": { "type": 502, "value": "New customers only. Port-in required." } },
    { "id": 11, "data": { "type": 504, "value": "Offer valid for new activations only..." } }
  ],
  "link": { "url": "https://example.com/offer?subId1={click_id}&sharedid={affiliate_id}" },
  "eventtrackers": [
    { "event": 1, "method": 1, "url": "https://trk.example.com/impression.png" }
  ],
  "ext": { "moments": { "beacons": { "close": "...", "no_thanks_click": "..." } } }
}
```

## No-bid / Decline

To decline a Moment, use any of four forms. They all resolve to the same `no_bid` record.

- `204 No Content` — the conventional explicit no-bid.
- A `200` with an empty or absent `seatbid`, or a `seatbid[0].bid` that is empty.
- A top-level `nbr`. Any value declines; we accept the signal but do not currently record the reason code.
- The legacy declaratives, still honoured: `{ "status": "no_bid" }`, `{ "status": "nobid" }`, or `{ "noBid": true }`.

```http
HTTP/1.1 204 No Content

# or, with a 200 — any of these:
{ "id": "b7c1e0f2-...", "seatbid": [] }
{ "id": "b7c1e0f2-...", "nbr": 2 }
{ "status": "no_bid" }
```

A non-2xx that is not `204` is recorded as `error`, not `no_bid`. An `adm` that will not parse is neither — it becomes a bid with no creative and is rejected as `invalid_creative`, so you see the fixable fault.

## Notices & Conversions

Four signals, complementary rather than alternatives: two server-to-server notices off the bid, your own client-side impression tracker, the click macros in your landing URL, and the conversion postback.

### 1 · Win and billing notices

Put `nurl` and `burl` on your bid and we fire them server to server. Nothing is configured on your seat — the URLs travel with each bid, so you can vary them per campaign.

- **`nurl` fires as soon as the auction resolves you as the winner.**
- **`burl` waits for a confirmed impression**, because our billable signal is the unit actually coming into view. Winning an auction on a unit nobody looked at is not something you should be billed for.
- Both are a plain `GET` with no body, `User-Agent: MomentsSSP/1.0 (notice)`, a 2s timeout, and **no retry**. Each is fired **exactly once**.
- We do not read your response. A non-2xx or a slow handler does not change the auction result or your billing.

| Macro | Substituted with |
|---|---|
| `${AUCTION_ID}` | The auction identifier. |
| `${AUCTION_BID_ID}` | Your winning `bid.id`. |
| `${AUCTION_IMP_ID}` | Always `1` in this phase. |
| `${AUCTION_SEAT_ID}` | Your seat code — same value as `X-Moments-Seat`. |
| `${AUCTION_PRICE}` | The **clearing** price in `cur`, in **dollars not cents**. What you are actually charged, reflecting second-price settlement. Never the floor. Not encrypted in this phase. |
| `${AUCTION_CURRENCY}` | Always `USD`. |
| `${AUCTION_AD_ID}` | Recognised and reserved, but **currently substitutes to empty**. Do not depend on it. |
| `${AUCTION_MBR}` · `${AUCTION_LOSS}` · `${AUCTION_MIN_TO_WIN}` | Recognised so they are not left as literal text, but always empty. |

Substituted values are **URL-encoded**. A macro we do not recognise is left in place verbatim rather than blanked — a blanked macro turns a working URL into a broken one.

### 2 · Impression tracker (client-side)

Your own impression tracker still fires client-side from your `adm`, exactly as it does on any other exchange — the `eventtrackers` entry with `event: 1`. You get both signals: `nurl` means the auction cleared, the tracker means the offer actually rendered. Beacons in `ext.moments.beacons` are passed through untouched and never rewritten. If a creative carries no tracker but publishes `notice_urls.impression`, that is lifted into the pixel slot.

### 3 · Click-URL macros

Put any of these macros in `link.url` and they are substituted *in place*, keeping your own parameter names (e.g. `?subId1={click_id}&sharedid={affiliate_id}`). If you declare no macros at all, the four values are appended as query parameters instead. A placeholder that cannot be filled is emptied rather than left as literal text.

- `{auction_id}` — the auction identifier.
- `{click_id}` — the auction identifier; the click id and auction id are one-to-one.
- `{affiliate_id}` — affiliate identifier for the placement.
- `{session_id}` — the SDK-generated session identifier.

The two macro syntaxes are deliberately separate: braces `{click_id}` for click URLs, `${AUCTION_PRICE}` for notices. A landing URL can therefore never receive a settlement price.

### 4 · Conversion postback

Echo the auction identifier back on your conversion postback. It is the only thing that ties a conversion to the bid that paid for it — which is what makes Target CPA measurement and dynamic bidding possible.

```http
POST <base>/api/conversion
Content-Type: application/json

{
  "auction_id": "b7c1e0f2-...",
  "event": "signup",
  "payout": 14.00,
  "click_id": "your-click-id"
}
```

Accepts snake_case or camelCase; joins on the winning bid by `auctionId` + `status='won'`. Returns `201 { recorded, conversionId, event }`, or `404` / `409` with an explicit reason. A win with no `ext.moments.offerId` is a `409` — there is nothing to attribute the conversion to.

### Loss information

There is **no loss notice**; `lurl` is not read. Losing bidders are told only `{ code: "outbid", got: <clearingPriceCents> }` — "outbid, and by how much." The floor is never returned, in a rejection or anywhere else.

## Auction Mechanics

- **First price by default, second price per partner** (`at` = 1 or 2). Second price clears one cent above the runner-up, never below the floor or above the winner's bid.
- **Uncontested second price pays your own bid**, not the floor — a deliberate, non-standard choice worth noting if you run bid shading.
- **The floor is never disclosed** — not in the request, the response, the COU config, or any rejection.
- **Check order:** transport → price → blocks → creative → floor. Creative is checked before the floor so you see the fixable fault.
- **Timeout:** `tmax`, 300ms by default. An ad request may lower it but never raise it.

Bid statuses (every seat gets a record on every auction, including timeouts and declines): `won`, `lost`, `below_floor`, `invalid_creative`, `blocked`, `timeout`, `no_bid`, `error`.

## Async Bidding

Optionally, set your seat to async mode: acknowledge our request, then post your bid before the window closes. Late bids are recorded for audit and never served. The async endpoint takes the flat MomentScience shape, not an OpenRTB BidResponse — it is an inbound API call authenticated with your seat API key, not a reply to our request.

```http
POST <base>/api/bid/<auctionId>
Authorization: Bearer <your seat API key>
Content-Type: application/json

{ "bidRpmCents": 4250, "creative": { ... } }
```

## Offer Registry

The recommended path. Register a creative once, then bid with only a price and an offer id — less to send, less to validate under a 300ms clock, and the only place your image is measured where you can act on the result. The image is fetched and measured on save; a declared size that does not match the file is corrected to the measured one and reported as a warning.

```http
POST <base>/api/offers
Authorization: Bearer <your seat API key>
Content-Type: application/json

{
  "name": "Northwind 5G, three months free",
  "category": "Telecom",
  "manualBidRpmCents": 4250,
  "creative": {
    "short_headline": "Get 3 months free on any 5G plan",
    "short_description": "Switch today and we cover your first three bills.",
    "cta_yes": "Claim offer",
    "image": "https://cdn.example.com/sq.png",
    "advertiser_name": "Northwind Mobile",
    "advertiser_domain": "northwindmobile.com",
    "click_url": "https://example.com/offer?subId1={click_id}"
  }
}
```

Registration uses the flat MomentScience field names — it is not an OpenRTB surface. On a **live bid**, name the offer in `bid.ext.moments.offerId` and put the price in `bid.price` as usual:

```json
"ext": { "moments": { "offerId": "off_7875" } }
```

`GET /api/offers/spec` returns the live field table and image limits for your seat, so you build against what is enforced rather than a copy of it. `GET`/`PATCH`/`DELETE` on `/api/offers/:id` work as expected. Sending a full creative inline in `adm` stays supported, and every field is checked on every bid when you do.

## Endpoint Reference

- `POST <seat endpoint>` — exchange → you. The live bid request: one OpenRTB 2.6 BidRequest carrying a Native 1.2 request, per Moment, to the HTTPS URL you supply at seat creation. Auth `Bearer <seat.outboundKey>` + `X-Moments-Seat`.
- `GET <your nurl>` — exchange → you. Win notice, fired once when the auction resolves you as the winner. Taken from `bid.nurl`. No auth header.
- `GET <your burl>` — exchange → you. Billing notice, fired once when the impression is confirmed. Taken from `bid.burl`.
- `POST /api/impression` — public, unauthenticated, idempotent. Fired by the client when the unit comes into view; triggers your `burl`.
- `POST /api/bid/:auctionId` — you → exchange. Async bid.
- `POST /api/conversion` — conversion postback.
- `POST /api/offers` (+ GET /api/offers/spec, GET/PATCH/DELETE /api/offers/:id) — offer registry.
- `GET /api/cou-config/:code` — public COU config (excludes floor).
- `POST /api/ad-request` — partner → exchange. Runs auction.

Base URL is deployment host. Health: `GET /health`. Currency is USD throughout; money is integer cents, and RPM is cents per thousand impressions.
