Orders
Create, track, and decide orders. Buyers write through /orders; sellers decide through /me/orders. The order shape is defined once, at order; the lifecycle at the state machine.
At a glance
- Scopes:
orders:write(buyers),orders:approve(sellers) - Idempotency:
external_order_id; byte-identical resubmission replays the stored response, 409 only on a changed payload - Price locks at order time via
locked_rate_id; see the price-clearance invariant - Track by webhook or event replay; the snapshot is always authoritative
Create an order
POST/v1/ordersorders:write
Creates an order from line items, a proposal request, or a document reference. Returns immediately; everything after intake arrives as events.
Request body
| Field | Type | Description |
|---|---|---|
seller required | string | The counterparty, from GET /sellers. |
external_order_id required | string | Your idempotency key, echoed on every event. Identical duplicates replay the original response with X-Idempotent-Replay: true; that replay is how you learn the outcome of a request that timed out on you. |
rate_version required | string | The card commit you priced against. Version-current cards pass rate match by construction. |
line_items | array of object | A catalog order: one line item books one period; a two-week buy is two line items. Sending this member is what makes the order workflow: "catalog". |
request | object | A proposal order: a proposal_request, no product IDs required. Sending this member makes the order workflow: "proposal". |
document | object | A document order: {upload_id, format}; the parse resumes at needs_confirmation. Sending this member makes the order workflow: "document". |
attachments | array of object | Optional paper riding beside line_items on a catalog order: the buysheet your desk works from, traffic instructions, a make-good notice. Never parsed, never a source of order fields, and never a reason for an extra gate. Not one of the three inputs below. See attachments[]. |
issued_at | string | When your system issued the order, as an ISO datetime. An offset is fine: 2026-09-10T09:00:00-04:00 is kept as sent rather than rewritten to UTC. Defaults to the platform's created_at, so every order snapshot carries one. On a revision it is also the clock the prior order's notice window is measured against. |
flight | object | {start, end} as ISO dates. Optional, and when you state one, every date your lines can land on has to fall inside it or the create returns 422 INVALID_INPUT with details.field: "flight" naming the offending line and date. A day-grain line is checked on its own date; a week or month line is checked on the dates that period books, which are the Monday, Wednesday, and Friday of every week it touches. Stating a flight is how a mismatch between your header window and your line dates becomes an error you see instead of a call from the seller. |
revision | object | {supersedes}: the order_id this order revises. See revisions. version is assigned by the platform; sending one is ignored. |
settlement_mode | enum | Not an input you choose: the mode is the agreement's property, resolved server-side from agreement_id (direct when none is presented) and echoed on every read. Sending a value that matches the resolution is a no-op; one that contradicts it returns 422 INVALID_INPUT with details.field: "settlement_mode". See the order reference. |
notify_url | string | Webhook target for this order; must be registered and allowlisted. |
agreement_id | string | Presents an agreement, which attaches its terms to this order: the cancellation_terms snapshot and any posting policy override. Omitting it attaches nothing. Must resolve to an agreement with the same seller, or the create returns 422 INVALID_INPUT with details.agreement_id. |
buyer_entity | string | The acting buyer this order is for. Server-derived from your credential and echoed on every read, so you never need to send it. Sending it asserts which buyer you believe you are acting as: matching the resolved tenant is a no-op, disagreeing returns 403 TENANT_MISMATCH and creates nothing. See acting-buyer identity. |
buyer_metadata required | object | advertiser_id is required: name the advertiser this buy is for, by reference into your own registry, or the create returns 422 INVALID_INPUT with details.missing: ["buyer_metadata.advertiser_id"]. An empty string counts as missing. Without it a seller cannot clear competitive separation, category conflict, or political and LUR compliance, which is work it has to do before approving. The key list is closed at the top level and defined once, at the order reference: an unknown key is refused 422 INVALID_INPUT with details.field naming it, because these values are echoed on EDI delivery, where an unrecognised key is silent data loss rather than a harmless extra. Nested objects keep their own contracts: an unrecognised field inside buyer_contact stays valid and is carried verbatim. There is no advertiser_class key: class prices, and it lives on the line's rate. There is no key here for the advertiser's category either: it is a property of the advertiser record, which is where competitive separation reads it, so one advertiser cannot have two spellings of it across two orders. |
buyer_metadata.buyer_contact | object | A human your desk can reach when something on the order needs a conversation: a copy question, a make-good, a preempted unit. Three optional string fields, all of them independent. |
buyer_requests | object | What you are asking the seller for, as against what the seller states it enforces. {min_separation_seconds, scope}. scope takes one value, campaign; the seller-side values break, pod, and program return 422 INVALID_INPUT with details.field: "buyer_requests.scope". Recorded and surfaced at the approval gate, never scheduled around. See competitive separation. |
buyer_contact carries three optional strings. The block is optional and so is every field in it: a name and an email with no phone is a normal contact, not a malformed one.
| Field | Type | What it is |
|---|---|---|
name | string | The person to ask for. |
email | string | Where to write. Not format-checked: an internal alias address is as valid as a public one. |
phone | string | Where to call. Not format-checked, so an international number stays as you sent it. |
Nothing is emailed or called on your behalf. VAMOS carries the contact so the person holding the order can reach the person who placed it.
Send exactly one of line_items, request, or document. The member you send is the workflow: catalog, proposal, or document, derived by the platform and echoed on every read. There is no workflow input to keep consistent with the body; a value sent anyway is ignored, and sending zero or two of the three members is 422 INVALID_INPUT.
attachments is not a fourth option. It rides beside line_items, so the rule above is unchanged: send one of the three, and add attachments if you also have paper.
attachments[]
Your lines are authoritative and the file rides along. Nothing books on a parse, nothing waits for a confirmation round-trip, and the seller's traffic desk gets the paper it already works from.
Valid only alongside line_items under workflow: "catalog". On a proposal or document order it returns 422 INVALID_INPUT. For the case where the file is the order, see document orders.
| Field | Type | Description |
|---|---|---|
upload_id required | string | From POST /v1/uploads, in status: "uploaded". Must belong to your buyer_entity. |
format required | enum | pdf, xlsx, or csv. |
role required | enum | buysheet, traffic_instructions, makegood_notice, or other. A label for the seller's desk; nothing on the platform branches on it. |
filename required | string | What the desk should see the file called. |
sha256 required | string | The upload's stored hash. A value that does not match returns 422 INVALID_INPUT, so an attachment always points at specific bytes rather than at whatever now sits behind an id. |
{
"seller": "acme",
"external_order_id": "q4-campaign-042",
"rate_version": "acme-2026q4-v1",
"buyer_metadata": { "advertiser_id": "adv_4c1f7a2b9e03" },
"line_items": [ /* your authoritative lines */ ],
"attachments": [
{
"upload_id": "upl_7f3a9c21b0d4",
"format": "csv",
"role": "buysheet",
"filename": "q4-buysheet.csv",
"sha256": "ac1f0ea9b25d426c4a16f359265a04dedf88ac0da95a7adf935361929c227d1f"
}
]
}
Every entry is refused with 422 INVALID_INPUT and creates nothing when its upload_id does not resolve, belongs to another buyer, is not yet uploaded, was rejected, or has expired. Those four causes return the same response on purpose: a create is not a way to discover which upload IDs exist. A sha256 mismatch is reported separately, because reaching that check means the upload is already yours.
Attaching pins the upload: an upload an order references stops expiring, for the life of that order. See the upload lifecycle.
Attachments are never interpreted. No parse, no extraction, no field on your order comes from a file. If a buysheet and your lines disagree, your lines win and the disagreement is a warning, never a rejection: a slightly stale PDF cannot block a correct booking.
Both order reads echo attachments verbatim, sha256 included. The event that moves the order to seller_review carries the upload_id list, so a traffic desk knows paper exists before it opens the order: that event is order.status_changed with status: "seller_review", the same to both sides.
line_items[]
| Field | Type | Description |
|---|---|---|
product_id required | string | From the seller's catalog. |
period required | object | The period this line item books: {grain, start}. A week or month period books across the period; the seller distributes units over its dates and reports them in allocation. A day period pins the air date and is daylocked by construction: one date, one allocation entry. The grain has to be one the product's grains offers and one the buyer's relationship permits, or the create is refused 422 GRAIN_NOT_OFFERED. Two causes, one code: the buyer's next move is the same either way, and the refusal carries details.permitted_grains only when the product does sell the grain and an agreement's permitted_grains is what excluded it, so an agent can tell "never sold that way" from "not sold that way to you". See the day-locked buys walkthrough; a day-grain line whose date has no day-grain avail behind it still books, and the response says so: see warnings. The line's provider and network are not sent: both are the product's facts, derived from product_id and echoed on reads. |
units required | integer | Every catalog line books in units, audience_guaranteed included: a guaranteed unit is a fixed-price, seller-published guarantee block, not a buyer-named impression count. See the line reference. |
impressions_goal | integer | Never on a catalog line, any buy type: a line carrying it is refused 422 INVALID_INPUT. It belongs to the proposal and document workflows, where a buyer genuinely names an uncapped impression count. See Proposals. |
spot_length required | integer | Seconds; a length the cited rate entry prices. |
rate required | object | The priced cell you are locking: {market_class, advertiser_class, buy_type, clearance_tier?, unit_cost or cpm, locked_rate_id, currency}. Exactly one of unit_cost or cpm, or the create returns 422 INVALID_INPUT. rate_basis is not part of the write: the platform derives it from which cost field is present and emits it on every read, so a value you send is ignored rather than validated. Take locked_rate_id from avails. |
include_mirror | boolean | Books the product's paired mirror airing. Default: false. |
rc_impressions | object | Impression estimates at order time, per audience code, for buysheet parity. |
creative_id | string | The creative this line airs, by reference into your own registry. Optional at create and at every state after it: orders are placed without a creative in many real trades, and the creative follows later as a traffic instruction (out of scope of this platform for now). An id that does not resolve inside your own registry is 422 UNKNOWN_CREATIVE; an archived one is 422 REFERENCE_ARCHIVED; and when the record states a length_seconds that differs from this line's spot_length, the create is 422 CREATIVE_LENGTH_MISMATCH. |
creative_snapshot | object | Read only. The creative_code, name, and length_seconds that were true of the referenced creative at the moment this line's reference was persisted. It is what a traffic desk reads, and it never moves again: renaming the record reaches future orders and changes nothing about this one. |
do_not_air | array of ISO date | Dates inside this line's window you are asking the seller not to air on. Recorded and surfaced, not enforced: see below. |
traffic_instructions | object | How this line's creatives rotate: {rotation, creative_splits}. See traffic_instructions. |
traffic_instructions
More than one creative on a line is normal, and for direct response it is the default: two or three variants rotating inside a daypart, weighted deliberately. traffic_instructions is where that goes, so the rotation is part of the booking rather than an email the traffic desk has to reconcile against one.
rotation decides the shape of creative_splits. That is what keeps the two from contradicting each other:
rotation | creative_splits[].pct | Meaning |
|---|---|---|
equal | omitted on every entry | The variants rotate in equal shares. We divide evenly and echo the computed shares on read, so you never do the arithmetic. |
weighted | required on every entry, summing to exactly 100 | The variants rotate in the shares you state. |
Any other combination is 422 INVALID_INPUT: equal with any pct present, weighted with any pct missing, weighted whose values do not sum to 100, or rotation with no creative_splits at all. Percentages are compared as exact decimals to two places, so 33.33 and 66.67 sum to exactly 100 and are accepted.
equal means omit the percentages rather than state identical ones, and that is deliberate: three variants cannot be three identical percentages summing to 100, so the strict reading would have made equal unusable for the commonest case beyond two. Three variants read back as 33.34, 33.33, 33.33.
Every line with creative_splits still names its primary creative_id, and that creative_id has to be one of the splits. See the creative_id row above for the rule and why it is checked at create. Every split entry resolves in your own registry on the same terms as the primary, and its stated length has to match the line too, so a rotation cannot smuggle in a creative the line could not otherwise run.
"traffic_instructions": {
"rotation": "weighted",
"creative_splits": [
{ "creative_id": "crt_9c2e5b71f048", "pct": 60 },
{ "creative_id": "crt_08b6f3a9d541", "pct": 40 }
]
}
Recorded and surfaced, never scheduled. We carry the rotation to the seller; we do not place spots within a break, so we do not enforce a split, check delivered shares against what you asked for, or reconcile them on a delivery_post. Same posture as the seller's own separation_policy.
There is no sequential rotation. It was considered and left out rather than given invented semantics: whether it cycles per break, per day, or per flight, and what happens after a preemption, are all things nobody has asked for yet.
do_not_air is a request, not a block. VAMOS stores these dates and shows them to the seller at the approval gate. It does not schedule around them, and a line carrying a do_not_air date can still be allocated on that date. The platform does not place spots inside a break, so what happens on a date you have asked to skip is the seller's decision, the same way the seller's own separation_policy is. Nothing validates these dates against the order's flight either: a date outside the flight excludes nothing, so it is stored as sent rather than refused.
Example request
{
"seller": "acme",
"external_order_id": "q4-campaign-042",
"rate_version": "acme-2026q4-v1",
"line_items": [
{
"product_id": "acme-prime",
"period": { "grain": "week", "start": "2026-W41" },
"units": 10,
"spot_length": 30,
"rate": {
"market_class": "scatter",
"advertiser_class": "general",
"buy_type": "preemptible",
"clearance_tier": "P2",
"unit_cost": 405.00,
"locked_rate_id": "lr_88c1",
"currency": "USD"
},
"rc_impressions": {
"hh": 128000,
"p2plus": 195000,
"a25_54": 82000
},
"creative_id": "crt_9c2e5b71f048"
},
{
"product_id": "acme-prime",
"period": { "grain": "week", "start": "2026-W41" },
"units": 10,
"spot_length": 30,
"rate": {
"market_class": "scatter",
"advertiser_class": "general",
"buy_type": "audience_guaranteed",
"unit_cost": 435.20,
"guaranteed_impressions": 128000,
"audience_code": "hh",
"locked_rate_id": "lr_88c3",
"currency": "USD"
}
}
],
"agreement_id": "agr_71c0d2",
"buyer_metadata": {
"advertiser_id": "adv_4c1f7a2b9e03",
"campaign_id": "cmp_6b2f81d40a9e",
"estimate_id": "EST-4402"
}
}
Response
201 Response{ "order_id": "ord_9f3a12c4b7e1", "external_order_id": "q4-campaign-042", "buyer_entity": "yourco", "status": "submitted", "created_at": "2026-10-01T14:02:11Z" }Timestamps in these examples are illustrative. The sandbox stamps every event, receipt, and
as_ofat the moment of the call, so a response you get back carries today's instant rather than the one printed here. What the examples fix is the shape and the ordering: stamps on one order are strictly increasing, and two calls a minute apart are a minute apart in the log.
Errors
| Status | Code | When |
|---|---|---|
| 409 | CONFLICT | Same external_order_id, different payload. Identical duplicates replay instead. |
| 422 | INVALID_INPUT | Malformed input, unknown product_id, an agreement_id that does not resolve to an agreement with this seller (details.agreement_id), or a non-allowlisted notify_url. |
| 403 | TENANT_MISMATCH | A buyer_entity in the body that disagrees with the acting buyer your credential resolves to. Nothing is created. |
| 403 | FORBIDDEN | The seller has suspended this acting buyer, or runs an allowlist and has not allowlisted it. Nothing is created; see the tenant roster. |
| 422 | CREDIT_LIMIT | A cleared order that would breach your credit cap fails fast at create. |
| async | STALE_RATE_CARD | Surfaces as a failed event with a rate_drift diff per line. Refresh the card and resubmit; see the price-clearance invariant. |
| async | INVENTORY_UNAVAILABLE | Availability rejection, the one legitimate validation failure. |
Error shape and recovery: Errors.
warnings[]
A warning is not an error. It appears on a successful 201, names something worth knowing, and changes nothing: the order is created, it reads back normally, and no transition on it is blocked or altered. The key is absent entirely when there is nothing to say.
| Code | When |
|---|---|
DATE_GRAIN_AVAILS_MISSING | A day-grain line names a date the seller has published no day-grain avail for. Carries product_id and the dates at issue, one entry per product. Day-grain avails is a stated precondition of day-locked ordering, softly enforced: your order books, and the gap is named rather than hidden. See day-locked buys. |
{
"order_id": "ord_c19a44e2f0b1",
"status": "submitted",
"warnings": [
{ "code": "DATE_GRAIN_AVAILS_MISSING", "product_id": "late-night", "dates": ["2026-09-16"] }
]
}
Read an order
GET/v1/orders/{id}orders:write
The authoritative snapshot, with a real last_seq. Webhooks are hints; this is truth, because it is the platform's own fold of the order's complete event log: not a second record maintained beside the events, but the projection of all of them, so no missed delivery can make it wrong. Full field reference, including where each field folds from: order.
Response
200 Response
{
"order_id": "ord_9f3a12c4b7e1",
"external_order_id": "q4-campaign-042",
"seller": "acme",
"workflow": "catalog",
"status": "placed",
"substatus": null,
"last_seq": 5,
"rate_version": "acme-2026q4-v1",
"taxonomy_version": "acme-2026q4-v1",
"buyer_metadata": { "advertiser_id": "adv_4c1f7a2b9e03" },
"issued_at": "2026-09-10T09:00:00Z",
"flight": { "start": "2026-10-05", "end": "2026-10-11" },
"revision": { "version": 1, "supersedes": null },
"settlement_mode": "direct",
"created_at": "2026-10-01T14:02:11Z",
"updated_at": "2026-10-02T14:41:09Z",
"line_items": [
{
"unit_id": "u-0001",
"product_id": "acme-prime",
"provider": "National ACME",
"network": "ACME",
"period": { "grain": "week", "start": "2026-W41" },
"allocation": [
{
"date": "2026-10-05",
"units": 4
},
{
"date": "2026-10-07",
"units": 3
},
{
"date": "2026-10-09",
"units": 3
}
],
"spot_length": 30,
"units": 10,
"num_accepted": 10,
"status": "accepted",
"reason": null,
"rate": {
"market_class": "scatter",
"advertiser_class": "general",
"buy_type": "preemptible",
"clearance_tier": "P2",
"rate_basis": "per_unit",
"unit_cost": 405.00,
"locked_rate_id": "lr_88c1",
"currency": "USD"
},
"rc_impressions": {
"hh": 128000,
"p2plus": 195000,
"a25_54": 82000
},
"availability": {
"state": "available",
"basis": "measured",
"sellout_level": 0.62
}
},
{
"unit_id": "u-0002",
"product_id": "acme-prime",
"provider": "National ACME",
"network": "ACME",
"period": { "grain": "week", "start": "2026-W41" },
"allocation": [
{
"date": "2026-10-06",
"units": 10
}
],
"spot_length": 30,
"units": 10,
"num_accepted": 10,
"status": "accepted",
"reason": null,
"rate": {
"market_class": "scatter",
"advertiser_class": "general",
"buy_type": "audience_guaranteed",
"rate_basis": "per_unit",
"unit_cost": 435.20,
"guaranteed_impressions": 128000,
"audience_code": "hh",
"locked_rate_id": "lr_88c3",
"currency": "USD"
}
}
],
"totals": {
"gross": 8402.00,
"currency": "USD",
"impressions": {
"hh": 1280000
}
},
"artifacts": [
{
"name": "placement_receipt",
"seq": 5,
"seller_order_id": "ACME-77120",
"daylocks": [
{
"unit_id": "u-0001",
"date": "2026-10-05",
"units": 4
},
{
"unit_id": "u-0001",
"date": "2026-10-07",
"units": 3
},
{
"unit_id": "u-0001",
"date": "2026-10-09",
"units": 3
},
{
"unit_id": "u-0002",
"date": "2026-10-06",
"units": 10
}
],
"placed_at": "2026-10-02T14:41:09Z"
}
],
"events_url": "/v1/events?order=ord_9f3a12c4b7e1"
}
The allocation on each line is authoritative: a week or month line's units spread across the product's weekday pattern, one entry per air date, summing to num_accepted. A day line has a single entry. The placement_receipt restates nothing: its own facts are seller_order_id and placed_at, and its daylocks are the same allocations flattened to {unit_id, date, units} rows (ordered by unit_id, then date) for desks that read the buy as one schedule. The air dates are recorded once, on the deciding event's units[].allocation; a receipt could never disagree with the lines. See day resolution.
Confirm an order at needs_confirmation
POST/v1/orders/{id}/confirmorders:write
Resumes an order parked at needs_confirmation, the gate where you confirm an artifact before it books. Two workflows park here: a document order (confirm the parsed interpretation) and a proposal (confirm the seller's returned plan_proposal). Catalog orders never park here. On confirm, a document order proceeds to seller_review, and a proposal books its plan in place, on this same order: the plan's line_items become the order's, exactly one order exists after the confirm, and a mixed plan books as one order however many pay bases its lines carry. On reject, the order goes to canceled. To counter a plan instead of confirming it, use respond. A satisfied buyer acceptance band issues this confirm automatically.
Request body
| Field | Type | Description |
|---|---|---|
action required | enum | confirm or reject. |
actor required | string | Who decided, as you assert it: a display value recorded verbatim onto the approval record, never an authenticated identity; the authenticated fact is your credential's side, which no actor value can reassign. For an auto-accepted plan, the recorded actor is the acceptance band. |
plan_id | string | On a proposal confirm: the plan being accepted, so a plan superseded by a fresh one cannot be confirmed by mistake. |
reason | string | Required on reject; persisted and surfaced to the seller. |
Response
202 Response : document order { "order_id": "ord_9f3a12c4b7e1", "status": "confirmed" } 202 Response : proposal, plan booked { "order_id": "ord_c19a44e2f0b1", "status": "seller_review", "booked_orders": ["ord_c19a44e2f0b1"] }
booked_orders names the same order, because the plan books in place and exactly one order exists; the array shape is retained because it is published. See what books on confirm.
Respond in a negotiation
POST/v1/orders/{id}/respondorders:write
Answers a seller counter on an order parked in negotiating. Rounds live on the negotiation object. See Negotiation.
The sandbox serves accept and withdraw. A counter action is refused with 422 INVALID_INPUT and details.supported: ["accept", "withdraw"]: the buyer counter-round is published and not yet served, so the sandbox says so rather than answering with a round shape it does not have.
Request body
| Field | Type | Description |
|---|---|---|
action required | enum | accept, counter, or withdraw. The sandbox serves the first and the last. |
actor | string | Who answered, as you assert it: a display value recorded verbatim onto the approval record at the negotiating gate, never an authenticated identity. Omit it and the platform records its own default. |
reason | string | Carried onto the cancellation block on a withdraw, which defaults it when you send none. Ignored on an accept. |
counter | object | Required on counter: revised line_items (unit_id, week, units, rate) and an expires_at. |
as_of | ISO datetime | The instant a quote's window is measured against, defaulting to the moment of the call. The determinism hook, as on cancel: send it to reach the expired-offer branch on demand rather than waiting out the window. |
What an accept books
The round the seller offered, whole. Every standing countered line becomes accepted at its own quote commit, with num_accepted equal to the counter's units and an allocation across the product's weekday pattern; a line the seller accepted in the same decision keeps the result it already had; each answered ask resolves rejected, naming the counter that closed it. The order transitions to placed with a placement_receipt, and its totals.gross is recomputed from the accepted results at their cited locked rates, so a booked order reports what it books rather than what it asked for.
Acceptance is where a quote's window is enforced: an accept whose counter cites a commit past its quote_expires_at returns 409 STALE_RATE_CARD naming the lapsed locked_rate_id, and the order stays in negotiating. Ask the seller to counter again, or revise the order.
| Situation | Behavior |
|---|---|
Order is not in negotiating, or carries no standing counter | 409 INVALID_STATE with the authoritative status and last_seq. |
A second accept of a round already booked, or a second withdraw | Replays the stored outcome. No second event, no last_seq advance. |
Unknown order_id | 404, as on GET /orders/{id}. |
Example request
{
"action": "accept",
"actor": "buyer@yourco.com"
}
202 Response
{
"order_id": "ord_9f3a12c4b7e1",
"status": "placed",
"units_accepted": 4,
"units_rejected": 4
}
Example request: the buyer counter (not served by the sandbox)
{
"action": "counter",
"actor": "buyer@yourco.com",
"counter": {
"line_items": [
{
"unit_id": "u-0001",
"period": { "grain": "week", "start": "2026-W41" },
"units": 8,
"rate": {
"unit_cost": 392.00
}
}
],
"expires_at": "2026-09-30T20:00:00Z"
}
}
Response to a counter round
202 Response
{
"order_id": "ord_9f3a12c4b7e1",
"status": "negotiating",
"negotiation_id": "neg_33a7",
"round": 2
}
A withdraw ends the negotiation: the order transitions to canceled through the same path cancel takes, carrying the identical cancellation block, and the response returns that block.
Cancel an order
POST/v1/orders/{id}/cancelorders:write
Idempotent. Canceling a terminal order returns the terminal snapshot unchanged, so a cancel is always safe to retry. Cancellation is a buyer action; a seller declines an order through decide.
Request body
| Field | Type | Description |
|---|---|---|
reason required | string | Persisted; canceled snapshots keep their structured reason on cancellation. A retry never overwrites it, because the second call appends no event. |
actor required | string | Who decided; persisted alongside the reason. |
as_of | ISO date | The day the notice window is measured from. In the sandbox it defaults to the seeded day 2026-10-05, derived from the catalog's effective start: a fixed value that does not advance with real time, so every notice-window branch is reproducible on demand. Send it to pick the branch you want to see. |
Which states cancel
| Order state | Behavior |
|---|---|
Any non-terminal state, including placed | Transitions to canceled and emits one order.status_changed event at the next seq, with status: "canceled", the prior_status it left, and the cancellation block. |
completed, canceled, rejected, failed | Returns the terminal status with a note. No transition, no event, no last_seq advance. |
Unknown order_id | 404, as on GET /orders/{id}. |
placed is not terminal, and it is the state cancellation matters most in: a placed order has committed air dates, and how close those dates are is what decides whether the cancellation is late.
The notice window, and the one cancellation block
When the order snapshotted cancellation_terms from a presented agreement, the cancellation says whether it landed inside the notice period the two of you negotiated. The window is measured to the next airing still ahead of the clock: dates that have already aired are discarded, because a notice period protects the airings a cancellation would actually stop. On a flight that has fully aired, days_remaining is negative and measured to the last date, stating how long ago the flight ended. An order with no committed allocation has no anchor, so no window applies.
One fact, one shape, one place: the whole cancellation is a single cancellation block, recorded once on the cancellation event (order.status_changed to canceled) and carried identically on all three surfaces: the cancel response returns it, the event carries it, and the snapshot folds it. There is no per-surface table to learn, because there are no per-surface shapes.
"cancellation": {
"reason": "budget cut",
"actor": "buyer@yourco.com",
"late_notice": true,
"notice": {
"notice_days": 14,
"days_remaining": 13
}
}
| Field | Type | Description |
|---|---|---|
reason, actor required | string | From the cancel request; persisted, never overwritten by a retry. |
late_notice required | boolean | The typed verdict: true when the cancellation landed inside the negotiated notice period. false when it cleared the window, or when no terms were attached. This is the durable signal; nothing about a cancellation is recoverable only from a substatus string. |
notice | object | The window figures, present whenever terms were attached and an anchor existed, late or not: notice_days (from the snapshotted terms) and days_remaining (to the next protected airing). Absent when no terms were attached or no window applied; the verdict is late_notice, so there is no within_notice_window boolean restating it. |
The edge is not late: days_remaining equal to notice_days is inside the terms and reads late_notice: false. substatus may still show "late_notice" as a human-readable step marker on the event and snapshot, but it is informational projection, never contract: switch on cancellation.late_notice.
VAMOS records and routes. It does not refuse the cancellation and it does not price a penalty: late_notice is how it tells the seller there is something to decide.
Response
202 Response : order was non-terminal { "order_id": "ord_9f3a12c4b7e1", "status": "canceled", "cancellation": { "reason": "budget cut", "actor": "buyer@yourco.com", "late_notice": false } } 202 Response : inside the notice window { "order_id": "ord_9f3a12c4b7e1", "status": "canceled", "cancellation": { "reason": "budget cut", "actor": "buyer@yourco.com", "late_notice": true, "notice": { "notice_days": 14, "days_remaining": 13 } } } 202 Response : order was already terminal { "order_id": "ord_9f3a12c4b7e1", "status": "completed", "note": "order already in terminal state; no change made" }
The snapshot at GET /orders/{id} carries the terminal status and this same cancellation block, notice included: the snapshot is a fold of the event that recorded it, so it can never disagree with what the response returned.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | No order with that ID under your credentials. An order belonging to a different acting buyer reads as 404 rather than 403, so a known order ID never confirms whose it is. |
| 422 | INVALID_INPUT | A missing reason or actor (details.missing), a body that is not an object, or an as_of that is not an ISO date (details.as_of). stage is intake. |
Revisions
Real buys get revised. A revision is a new order with its own external_order_id, naming the order it replaces:
{
"seller": "acme",
"external_order_id": "q4-campaign-042-r2",
"rate_version": "acme-2026q4-v1",
"issued_at": "2026-09-18T11:30:00Z",
"buyer_metadata": { "advertiser_id": "adv_4c1f7a2b9e03" },
"revision": { "supersedes": "ord_9f3a12c4b7e1" },
"line_items": [ ... ]
}
Do not reuse the original external_order_id to revise: a changed payload under the same key is the 409 CONFLICT it has always been, and a new key with no revision block is an unrelated second order. The lineage travels in revision and nowhere else.
The new order and the prior order's cancellation commit together, so there is never a moment when both are bookable:
| Order | After the revision commits |
|---|---|
| The revision | revision.version is the prior order's plus one, and revision.supersedes names it. Otherwise an ordinary new order: it enters at submitted and runs the same lifecycle. |
| The prior order | canceled, with cancellation.reason: "superseded_by_revision" and revision.superseded_by naming the revision. Its external_order_id stays claimed, so a byte-identical resubmission of the original still replays the original response. |
The prior order is canceled by the ordinary cancel path, which means the notice window applies exactly as it would to a cancel you sent yourself: a revision landing inside the window reads cancellation.late_notice: true with the same notice figures on the cancellation event. The clock is your issued_at when you state one. There is no separate substatus for a supersede: the lineage is in revision.superseded_by, and the cancellation reads like any other.
You can supersede an order in any live state: submitted, validating, needs_confirmation, confirmed, negotiating, seller_review, or placed. A finished order cannot be revised.
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | supersedes names an order outside your credentials. As everywhere else, not a 403: a known order ID never confirms whose it is. |
| 422 | INVALID_INPUT | The prior order is in a state that cannot be superseded, with details.status naming it. |
| 409 | CONFLICT | The prior order was already superseded, with details.superseded_by naming the revision that won. Two revisions of one order leave exactly one winner. |
Nothing is created in any of the three cases, so the revision's own external_order_id is still free once you have fixed the problem.
List orders
GET/v1/ordersorders:write
Your order book: the reconciliation sweep. Page with the cursor until exhausted.
Query parameters
| Field | Type | Description |
|---|---|---|
status | enum | One or more order statuses; repeatable or comma-separated. |
seller | string | Filter to one counterparty. |
buy_type | enum | Filter orders whose lines contain one of the buy types. |
created_after | ISO datetime | Lower bound on creation time. |
after | string | Opaque cursor from the prior response. |
Response
200 Response
{
"orders": [
{
"order_id": "ord_9f3a12c4b7e1",
"status": "placed",
"seller": "acme",
"external_order_id": "q4-campaign-042"
}
],
"cursor": "eyJpZCI6Im...",
"total": 142
}
cursor is null when exhausted; pass it as ?after= on the next call.
Approval queue
GET/v1/me/ordersorders:approve
The seller's order book. With the default filter it is your approval queue: everything waiting at seller_review, your gate on every order. Also available in the console. See Approve orders.
Query parameters
| Field | Type | Description |
|---|---|---|
status | enum | Any order status. Default: seller_review. |
after | string | Opaque cursor from the prior response. |
Response
200 Response
{
"orders": [
{
"order_id": "ord_9f3a12c4b7e1",
"status": "seller_review",
"buyer": "yourco",
"buyer_entity": "yourco",
"totals": {
"gross": 4050.00,
"currency": "USD"
},
"received_at": "2026-10-01T14:02:14Z"
}
],
"cursor": null,
"total": 1
}
Read an order (seller)
GET/v1/me/orders/{id}orders:approve
The seller's view of one order: the same authoritative snapshot the buyer sees, plus the buyer identity and buyer_metadata you decide against, plus your own audit records. Shape: order.
| Field | Type | Description |
|---|---|---|
buyer | string | The buyer that placed the order. |
advertiser | string | Who the buy is for, lifted to the top level beside buyer so your desk reads it without unpacking advertiser_snapshot. It is the snapshotted name: the value that was true of the advertiser record when the order booked, never a live read, so a rename on the buyer's side never rewrites what you approved. Present on every order; null only on an order booked before the advertiser became required. The buyer's own GET /v1/orders/{id} does not carry it: the echo is for your decision view. |
buyer_entity | string | The acting buyer, so an order is attributable when one connection carries several buyer organisations. Also on every row of the approval queue, and it is the value the tenant roster is keyed by. |
approvals | array of object | One approval per decision taken at your gate, in seq order: gate, actor, decision, reason when the decide request sent one, seq, and occurred_at. This array is a projection: each record lives on the order event its decision produced (seq and occurred_at are that event's own), gathered here so your desk reads the gate history without walking the stream. Empty before any decision. The buyer's GET /v1/orders/{id} does not carry the array; the events its replay returns do carry each approval record, so the decision, like every recorded fact, reads the same from the stream on both sides. |
Tenant roster
GET/v1/me/tenantsorders:approve
Every acting buyer this connection has carried, so one integration can serve several buyer organisations and you can still tell them apart. One entry per distinct buyer_entity, created the first time an order arrives from it, or the first time you allowlist it.
Response
200 Response
{
"tenants": [
{
"buyer_entity": "yourco",
"status": "active",
"allowlisted": false,
"first_seen_at": "2026-10-01"
}
],
"total": 1
}
| Field | Type | Description |
|---|---|---|
buyer_entity | string | The acting buyer, as it appears on every order and event from it. |
status | enum | active or suspended. A suspended tenant's orders are refused at intake. |
allowlisted | boolean | Whether you have admitted this tenant explicitly. Only consulted under tenant_policy.mode: "allowlist". |
reason | string | The note you attached when suspending. Absent on an active tenant. |
first_seen_at | date | When this tenant first appeared on your connection. |
status and allowlisted are independent, and all four combinations are legal. Suspension refuses regardless of the allowlist, so allowlisting a suspended tenant sets the flag and leaves the refusal in place until you unsuspend. Under tenant_policy.mode: "allowlist" a tenant that is not allowlisted is refused regardless of status.
Suspend and unsuspend a tenant
POST/v1/me/tenants/suspendorders:approve
POST/v1/me/tenants/unsuspendorders:approve
Stop taking orders from one acting buyer without touching the others on the same connection, and start again later. Both touch status only: a tenant's allowlisted flag survives a suspension untouched, because stopping a buyer is not the same as un-inviting it.
Request body
| Field | Type | Description |
|---|---|---|
buyer_entity required | string | The acting buyer, from the roster. It travels in the body and never in the path, so a tenant identifier never lands in a URL, an access log, or a cache key. |
reason | string | suspend only: your note, persisted on the entry and dropped when you unsuspend. |
Response
200 Response
{
"buyer_entity": "yourco",
"status": "suspended",
"allowlisted": false,
"reason": "credit hold",
"first_seen_at": "2026-10-01"
}
Both are idempotent and neither has a precondition: unsuspending an active tenant returns its current state rather than an error. A suspended tenant's POST /v1/orders returns 403 and creates nothing.
Allowlist a tenant
POST/v1/me/tenants/alloworders:approve
Admit one acting buyer explicitly. Touches allowlisted only, never status. Permitted for a tenant that has never sent an order, which is the point under tenant_policy.mode: "allowlist": you have to be able to open the door before the first knock.
Request body
| Field | Type | Description |
|---|---|---|
buyer_entity required | string | The acting buyer to admit. In the body, never in the path. |
Response
200 Response
{
"buyer_entity": "yourco",
"status": "active",
"allowlisted": true,
"first_seen_at": "2026-10-01"
}
Idempotent: allowlisting an already-allowlisted tenant returns its current state. There is no de-allowlisting operation; suspend instead.
Return a plan for a proposal
POST/v1/me/orders/{id}/planorders:approve
Answer a proposal order: attach the assembled plan_proposal and park the order at needs_confirmation for the buyer. Precondition: the order has workflow: "proposal" and is in submitted, validating, or seller_review; a non-proposal order or a terminal one is rejected. Mint per-campaign prices as quote commits first, then reference their locked_rate_ids here. Posting a new plan supersedes any prior one on the same order.
Request body
| Field | Type | Description |
|---|---|---|
plan required | object | The plan_proposal: line_items (each citing a locked_rate_id; minted: true marks products minted for this request), plan_totals (including projected_delivery), unmet, and an expires_at. |
actor required | string | Who assembled the plan; persisted. |
Response
202 Response
{
"order_id": "ord_c19a44e2f0b1",
"status": "needs_confirmation",
"plan_id": "plan_5b2e"
}
Errors
| Status | Code | When |
|---|---|---|
| 409 | INVALID_STATE | The order is not a proposal, or not in a state that accepts a plan. |
| 422 | INVALID_INPUT | A line cites an unknown or expired locked_rate_id, the plan omits a required field, or the plan's expires_at is later than the earliest quote_expires_at among the quote commits its lines cite (details.field: "expires_at"). One expiry governs a plan: an offer cannot outlive the prices it is made of, so the bound is enforced here rather than discovered as a STALE_RATE_CARD at confirm. |
Decide an order
POST/v1/me/orders/{id}/decideorders:approve
Your decision on an order in seller_review: approve, reject, or counter, per unit. Nothing airs without it.
Request body
| Field | Type | Description |
|---|---|---|
decision required | enum | approve, reject, or counter: the headline. Per-unit actions below may mix. |
actor required | string | Who decided, as your organization asserts it: recorded verbatim onto the approval record the decision produces, never an authenticated identity. The authenticated fact is your credential's grant at this gate, which no actor value can reassign. |
reason | string | Your note on the decision as a whole, persisted on the approval and returned by GET /v1/me/orders/{id}. Distinct from a per-unit reason, and never derived from one. |
units required | array of object | One entry per unit. Fields below. |
units[]
| Field | Type | Description |
|---|---|---|
unit_id required | string | The line being decided. |
action required | enum | accept, counter, or reject. |
num_accepted | integer | On accept: may be fewer than requested (partial acceptance). |
counter | object | Required on counter: {week, unit_cost, num_offered}. Requires the counter_proposals capability. |
reason | string | Required on reject and counter; persisted and surfaced to the buyer. |
Example request
{
"decision": "approve",
"actor": "planner@seller.com",
"units": [
{
"unit_id": "u-0001",
"action": "accept",
"num_accepted": 10
},
{
"unit_id": "u-0002",
"action": "counter",
"counter": {
"week": "2026-W42",
"unit_cost": 392.00,
"num_offered": 8
},
"reason": "W41 sold out; offering W42 at adjusted rate"
},
{
"unit_id": "u-0003",
"action": "reject",
"reason": "daypart unavailable this week"
}
]
}
Response
202 Response
{
"order_id": "ord_9f3a12c4b7e1",
"status": "placed",
"units_accepted": 10,
"units_countered": 8,
"units_rejected": 6
}
status is negotiating when counters were issued. Counters surface on the order as additional line items with status: "countered" and an in_response_to naming the unit they answer; the original line keeps its own result. Consumers treat countered lines as additive: a seller that only approves and rejects today can add counters later without breaking anyone. See Negotiation.