# OfferOS by MomentScience — The Moments Exchange > OfferOS is the Moments Exchange by MomentScience: a closed programmatic exchange where approved 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. Auctions are live first- or second-price (per-partner), floor-protected, and complete inside a 300ms window. Base URL is your deployment host. Health check: GET /health. Currency is USD throughout; money is integer cents and RPM is cents per thousand impressions. OfferOS is not an open exchange. There is no OAuth, no ads.txt, and no sellers.json — the seat credential is the authorization. The wire protocol is **OpenRTB 2.6** carrying a **Native 1.2** request: one BidRequest per Moment, and a BidResponse whose `adm` is native markup. A DSP already buying native inventory can point its existing bidder at OfferOS. Richer offer fields ride on custom native data types 500 and above, which Native 1.2 reserves for exchange use; anything without a standard home travels in `ext.moments` and comes back intact. ## Docs - [Bidder API Reference (HTML)](https://offeros.com/docs.html): Complete integration guide — authentication, bid request, bid response, native creative, no-bid, notices & conversions, auction mechanics, async bidding, offer registry, endpoint reference. - [Bidder API Reference (Markdown)](https://offeros.com/docs.html.md): Same reference in markdown for agents. ## Overview - [Overview](https://offeros.com/docs.html#overview): Closed programmatic exchange. Advertisers hold a seat, receive a bid request on every Moment, respond with a price and a creative. A Moment is a premium, high-impact ad placement implemented by a publisher, usually after a user takes action on the publisher site, giving relatively higher CTR and engagement. - Protocol: OpenRTB 2.6 BidRequest carrying a Native 1.2 request. Standard native data types 1 (sponsored), 2 (desc), 10 (desc2), 12 (ctatext); custom types 500, 501, 502, 504 are MomentScience-specific. Type 503 and asset id 10 are retired. ## Getting a Seat - [Getting a Seat](https://offeros.com/docs.html#getting-a-seat): The exchange is closed. Seats are provisioned by MomentScience. Request one at https://momentscience.com/contact-us/. - You supply: **Bid endpoint URL** (required, HTTPS POST accepting application/json, speaking OpenRTB 2.6 with a Native 1.2 request, responding inside your seat timeout clamped to `tmax`, 300ms default); **advertiser name, domain (`adomain`), contact email**. - Win and billing notices need nothing configured on your seat — put `nurl` and `burl` on each bid and the exchange fires them. - We provision: **seat code** (e.g. `xdsp`, `agency-dsp`, sent as `X-Moments-Seat`); **outbound key** (we sign requests to you with `Authorization: Bearer `); **API key** (you send `Authorization: Bearer ` when calling us); **status and mode** (`approved` + `active` before we call you; `sync` default, `async` optional). - Only `approved` + `active` seats are called. That is what makes it a closed network. ## Authentication - [Authentication](https://offeros.com/docs.html#authentication): The seat credential is the authorization. No OAuth, no ads.txt, no sellers.json. - Outbound (exchange → your bidder): `Authorization: Bearer `, `X-Moments-Seat: `, `User-Agent: MomentsSSP/1.0`. Verify before answering. - Inbound (your bidder → exchange, for offer registration and async bids): `Authorization: Bearer `. Unknown key 401; inactive seat 403. Routes scoped to your seat. - Notices (`nurl` / `burl`): fired to your URL with no authorization header. The substituted macros identify the auction. ```http POST Content-Type: application/json Authorization: Bearer X-Moments-Seat: User-Agent: MomentsSSP/1.0 ``` ## Bid Request - [Bid Request](https://offeros.com/docs.html#bid-request): One OpenRTB 2.6 BidRequest per Moment to the endpoint configured on your seat. Respond inside your seat timeout, 300ms by default. The floor is deliberately absent — you bid blind, and no rejection ever names it. Fields: - `id` (string, required) — the auction identifier. Echo it back on your conversion postback; it is the only key tying a conversion to the bid that paid for it. - `at` (1|2, required) — 1 first price, 2 second price. Reflects the partner's real setting. - `tmax` (integer, required) — time budget in ms, the lesser of your seat timeout and the auction budget. Default 300. Late responses are never served. - `cur` (string[], required) — always `["USD"]`. - `badv` (string[], optional) — blocked advertiser domains; only domain-shaped entries appear. Omitted when empty. Canonical lists are in `ext.moments.blocked`. - `imp[0].id` (string, required) — always `"1"`. Exactly one impression per request. - `imp[0].tagid` (string, required) — placement slot, default `moments_cou_v1`. - `imp[0].bidfloorcur` (string, required) — `"USD"`. There is no `bidfloor`: it is omitted, not zero. Under first price the floor is the cheapest price at which you can win, so publishing it would invite convergence on floor plus a cent. - `imp[0].native` (object, required) — `{ ver: "1.2", request }` where `request` is the Native 1.2 markup request as a JSON-encoded string. - `imp[0].ext.moments.momentType` (string, required) — kind of Moment, default `post_transaction`. - `site.publisher.id` (string, required) — partner code. No `site.domain` or `site.page`; the merchant domain is never exposed. - `site.publisher.ext.moments.vertical` (string, required) — partner category axis, e.g. `Telecom`. - `ext.moments.blocked` (object, required) — `{ categories, advertisers }`, lowercased. This is how the auction matches: by name, case-insensitively. `bcat` is NOT populated — our categories are not an IAB taxonomy and sending them as one would cause mis-filtering. - `ext.moments.context` (object, optional) — extra request context when the partner supplies it. Not present, by design: `bidfloor`, `bcat`, `app`, `device`, `user`, `site.domain`, `site.page`. ```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": "" }, "ext": { "moments": { "momentType": "post_transaction" } } }], "site": { "publisher": { "id": "partner-1-telecom", "ext": { "moments": { "vertical": "Telecom" } } } }, "ext": { "moments": { "blocked": { "categories": ["gambling"], "advertisers": ["luckystar casino"] }, "context": {} } } } ``` `imp[0].native.request` is a JSON-encoded string per the Native spec. Decoded, it is generated from the same field table the validator enforces, so it cannot drift. Asset order follows that table: mandatory 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 - [Bid Response](https://offeros.com/docs.html#bid-response): Standard OpenRTB. `price` is CPM as a float in `cur`; the creative is Native 1.2 markup in `adm`. Decline with 204, an empty `seatbid`, or a top-level `nbr`. Fields: - `seatbid[0].bid[0]` — only the first bid of the first seatbid is read. Additional seatbids and bids are ignored. - `price` (float, required) — CPM in the request currency, e.g. `42.50`. Must be > 0 and finite. No readable price is recorded as `no_bid`, not a bid of zero. Response-level `cur` is not read; the price is assumed to be in the requested currency. - `adm` (string, required) — Native 1.2 markup response, JSON-encoded. An already-parsed object is also accepted. - `adomain` (string[], required) — advertiser domain. Only `adomain[0]` is read, and only when the native markup did not already carry `advertiser_domain`. - `cat` (string[], optional) — advertiser taxonomy; `cat[0]` becomes the primary category. - `nurl` (url, optional) — win notice, fired when the auction resolves you as the winner. Supports `${AUCTION_*}` macros. - `burl` (url, optional) — billing notice, fired when the impression is confirmed. Same macros. - `crid` (string, optional) — your own creative identifier. Stored for audit and deliberately NOT used for attribution: buyers set `crid` freely, and reading it as an offer id would file spend against an offer that does not exist. Use `ext.moments.offerId`. - `ext.moments.offerId` (string, optional) — names a registered offer. This is what attribution and Target CPA measurement key on. - `ext.moments.bidMode` (string, optional) — how you priced the bid, e.g. `target_cpa`. - `id`, `impid`, `seat` — standard OpenRTB, recorded but not validated. - `lurl` — 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": "", "ext": { "moments": { "offerId": "off_7875", "bidMode": "target_cpa" } } }] }] } ``` Migrating from the flat shape: the MomentScience field vocabulary is unchanged (`short_headline`, `cta_yes`, `click_url`, etc.). What changed is where the fields 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 - [Native Creative](https://offeros.com/docs.html#native-creative): `adm` decodes to the Native Markup Response, conventionally wrapped as `{ "native": { ... } }`. A bare native object is accepted, as is an `adm` that arrives already parsed. - The parser keys off the **data type, not the asset id**. Markup built for another exchange with different ids still parses correctly; the asset id is only a tiebreak. Field map (Moments field — asset id / kind — data type — max length — required): - `short_headline` — asset 1, title — ≤60 — required. Falls back from `title`. - `short_description` — asset 3, data type 2 (desc) — ≤140 — required. Falls back from `description`. - `cta_yes` — asset 4, data type 12 (ctatext) — ≤30 — required. - `image` — asset 2, img type 3 — square, min 250×250, ≤512KB — required. `img.w`/`img.h` optional. - `advertiser_name` — asset 5, data type 1 (sponsored) — ≤80 — required. - `advertiser_domain` — `bid.adomain[0]`, not an asset — ≤120 — required. - `click_url` — `link.url`, not an asset — ≤500 — required. - `title` — asset 6, data type 500 (custom) — ≤90 — optional. - `cta_no` — asset 7, data type 501 (custom) — ≤30 — optional. - `description` — asset 8, data type 10 (desc2) — ≤220 — optional. - `mini_text` — asset 9, data type 502 (custom) — ≤160 — optional. - `terms_and_conditions` — asset 11, data type 504 (custom) — ≤2000 — optional. Not assets: `link.url` (click destination, carries click macros); `eventtrackers[]` (an entry with `event: 1` and a `url` becomes the impression pixel, fired client-side; deprecated `imptrackers[0]` read as fallback); `ext.moments.beacons` (`{ close, no_thanks_click }`, passed through untouched); `ext.moments.*` (merged into the creative wholesale); `img.w`/`img.h` (when both present, recorded as `campaign_images` — declared, not measured). An unrecognised data type is KEPT, not dropped — it lands under `ext_native_data` keyed by type. **503 and asset id 10 are retired.** They carried `mini_description`, a second 220-character description indistinguishable from type 10. A retired type is never reissued: a buyer that had 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. Image dimensions are optional — declare `img.w`/`img.h` and the shape is checked at auction time; omit them and the file is measured instead. The exchange never fetches an image during an auction (measuring one cold took 900–1100ms against a 300ms window). Files are measured at offer registration and verified again after an auction. A declared size that does not match is recorded against your seat; 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 - [No-bid](https://offeros.com/docs.html#no-bid): Four accepted forms, all resolving 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; the signal is accepted but the reason code is not currently recorded. - Legacy declaratives, still honoured: `{ "status": "no_bid" }`, `{ "status": "nobid" }`, `{ "noBid": true }`. - 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`. ## Notices & Conversions - [Notices & Conversions](https://offeros.com/docs.html#notices): Four complementary signals — two server-to-server notices off the bid, your client-side impression tracker, click macros in the landing URL, and the conversion postback. **1. Win and billing notices.** Put `nurl` and `burl` on your bid and the exchange fires them server to server. Nothing is configured on your seat; the URLs travel with each bid, so they can vary per campaign. - `nurl` fires as soon as the auction resolves you as the winner. - `burl` waits for a **confirmed impression**, because the 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)`, 2s timeout, **no retry**. Each fires **exactly once**. - The exchange does not read your response. A non-2xx or slow handler does not change the auction result or your billing. Notice macros: `${AUCTION_ID}` (auction identifier); `${AUCTION_BID_ID}` (your winning `bid.id`); `${AUCTION_IMP_ID}` (always `1` in this phase); `${AUCTION_SEAT_ID}` (your seat code, same as `X-Moments-Seat`); `${AUCTION_PRICE}` (the **clearing** price in `cur`, in dollars not cents — what you are actually charged, reflecting second-price settlement correctly; 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 the exchange does 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 fires client-side from your `adm` — 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. A creative with no tracker but a published `notice_urls.impression` has that lifted into the pixel slot. **3. Click-URL macros.** Put these in `link.url` and they are substituted in place, keeping your parameter names (e.g. `?subId1={click_id}&sharedid={affiliate_id}`). Declare no macros and 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; click id and auction id are one-to-one. - `{affiliate_id}` — affiliate identifier for the placement. - `{session_id}` — 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. It is the only thing tying a conversion to the bid that paid for it, which is what makes Target CPA measurement and dynamic bidding possible. ```http POST /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: }` — "outbid, and by how much." The floor is never returned, in a rejection or anywhere else. ## Auction Mechanics - [Auction Mechanics](https://offeros.com/docs.html#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 rather than a price rejection. - Timeout: `tmax`, 300ms by default. An ad request may lower it but never raise it; a seat cannot extend the auction. Bid statuses (every seat gets a record on every auction, including timeouts and declines): - `won` — cleared and served in position one. - `lost` — valid, eligible bid that another bid beat. - `below_floor` — priced under the never-disclosed floor. - `invalid_creative` — a creative field failed validation, or the `adm` would not parse. Reported before price. - `blocked` — category or advertiser on a block list. - `timeout` — no response inside the window. - `no_bid` — declined (204, empty seatbid, nbr, or no readable price). - `error` — transport error, non-204 non-2xx, or a late async bid. ## Async Bidding - [Async Bidding](https://offeros.com/docs.html#async-bidding): Optionally set your seat to async mode — acknowledge the 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 /api/bid/ Authorization: Bearer Content-Type: application/json { "bidRpmCents": 4250, "creative": { ... } } ``` ## Offer Registry - [Offer Registry](https://offeros.com/docs.html#offers): 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. - 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`. - `GET /api/offers/spec` returns the live field table and image limits for your seat. `GET`/`PATCH`/`DELETE` on `/api/offers/:id` work as expected. Every route is scoped to your seat. Sending a full creative inline in `adm` stays supported, and every field is checked on every bid when you do. ```http POST /api/offers Authorization: Bearer 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}" } } ``` ## Endpoint Reference - [Endpoints](https://offeros.com/docs.html#endpoints): - `POST ` — 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 ` + `X-Moments-Seat`. - `GET ` — exchange → you. Win notice, fired once when the auction resolves you as the winner. Taken from `bid.nurl`; nothing is configured on your seat. No auth header — the macros identify the auction. - `GET ` — exchange → you. Billing notice, fired once when the impression is confirmed. Taken from `bid.burl`. Same fire-and-forget GET, same macros. - `POST /api/impression` — public, unauthenticated, idempotent. Fired by the client when the unit comes into view; this is what triggers your `burl`. - `POST /api/bid/:auctionId` — you → exchange. Async bid. Auth seat API key. 201 in-window, 202 late. - `POST /api/conversion` — server → server. Conversion postback keyed on auction_id. 201 {recorded, conversionId, event}. - `POST /api/offers` — you → exchange. Register offer (also GET /api/offers/spec, GET/PATCH/DELETE /api/offers/:id). Auth seat API key. - `GET /api/cou-config/:code` — public. COU config for partner, excludes floor. - `POST /api/ad-request` — partner → exchange. Runs auction for a Moment. Returns whether bid cleared and creative to render — never price or floor. ## Optional - [MomentScience](https://momentscience.com): Parent site. - [Contact — Request a Seat](https://momentscience.com/contact-us/): Seat provisioning. - [Sitemap](https://offeros.com/sitemap.xml) - [Robots](https://offeros.com/robots.txt)