← Back to product·Simulmedia Switchboard Docs · v1 draft·Get started / Sandbox tutorial: run the demo yourself
Get started

Sandbox tutorial: run the demo yourself

The quickstart proves the contract works. This is the longer walk: eight sections, every one a request you can paste, ending with a placed order and a working mental model of how linear trades on Switchboard. Nobody from Simulmedia needs to be in the room. Budget half an hour.

The sandbox seller is Acme Broadcasting. It sells four packages across one quarter, and it answers differently to different questions: a week that is nearly gone costs more than a week that is wide open, one week is dark, and one package does not exist except on two dates. That variety is the point. You are not checking that an endpoint returns 200; you are learning what the model is for.

Every response block on this page is real sandbox output, not an illustration. If a block and your terminal disagree, the block is the bug: tell us.

1. Get set up

You need two bearer secrets, because the sandbox has two sides and neither can act for the other. The buyer secret drives the catalog and order calls; the seller secret makes the approval decision in section 6. Ask for both when you request sandbox access.

export SWB=https://api.sandbox.switchboard.simulmedia.com/v1
export SWB_BUYER=<your buyer secret>
export SWB_SELLER=<your seller secret>
export SWB_SESSION=$(uuidgen)

SWB_SESSION is the part worth understanding. The sandbox keeps every run in its own namespace, keyed by the X-Sandbox-Session header, so your orders are yours and nobody else's traffic can move them. Generate the value once and send it on every request in this walkthrough, both sides included: the buyer creates the order and the seller decides it, and they must be looking at the same run. Omit the header and the sandbox mints a fresh session, returns it to you in the response header, and your next call lands somewhere else.

Two conveniences for the rest of the page:

buyer()  { curl -s -H "Authorization: Bearer $SWB_BUYER"  -H "X-Sandbox-Session: $SWB_SESSION" "$@"; }
seller() { curl -s -H "Authorization: Bearer $SWB_SELLER" -H "X-Sandbox-Session: $SWB_SESSION" "$@"; }

Responses below are piped through jq where the full payload is longer than the point being made. Every field shown is real; nothing is added.

2. See what Acme sells

Start with who exists, then with what they publish.

buyer $SWB/sellers

{ "sellers": [ { "seller": "acme", "name": "Acme Broadcasting", "networks": ["ACME"],
                 "level": 2, "catalog_url": "/v1/sellers/acme/catalog" } ] }

level is the seller level: how much of the flow this seller can do, from file drops up to synchronous holds. Acme is a level 2 seller, which is why section 6 exists at all: a person approves.

buyer $SWB/sellers/acme/catalog \
  | jq '[.packages[] | {package_id, type, distribution, buy_types}]'

[
  { "package_id": "acme-prime", "type": "daypart", "distribution": "network",
    "buy_types": ["preemptible","non_preemptible","fixed_position","audience_guaranteed"] },
  { "package_id": "acme-morning-syndicated", "type": "sponsorship", "distribution": "syndication",
    "buy_types": ["non_preemptible","fixed_position","audience_guaranteed"] },
  { "package_id": "acme-daytime", "type": "daypart", "distribution": "network",
    "buy_types": ["preemptible","non_preemptible"] },
  { "package_id": "acme-gameday", "type": "program", "distribution": "network",
    "buy_types": ["fixed_position","audience_guaranteed"] }
]

Three fields, three ideas, and they are the vocabulary the rest of the walkthrough runs on.

  • type is the shape of the inventory. A daypart recurs weekly. A program exists on the dates it airs and nowhere else. A sponsorship attaches to a program rather than filling its breaks, which is why this one carries a billboard. See packages.
  • distribution is which layer the inventory sits in: network, syndication, or spot. It answers "whose clearance is this?", which is a different question from geography.
  • buy_types is what the seller will sell you on that package, and the whole reason the catalog is worth filtering. acme-daytime sells two of the four; acme-gameday will not sell you a preemptible spot at any price. Read the four in the deal model.

Filter on either field and the sets differ, which is the useful behavior:

buyer "$SWB/sellers/acme/catalog?buy_type=fixed_position" | jq '[.packages[].package_id]'

[ "acme-prime", "acme-morning-syndicated", "acme-gameday" ]

buyer "$SWB/sellers/acme/catalog?distribution=syndication" | jq '[.packages[].package_id]'

[ "acme-morning-syndicated" ]

3. Compare two weeks

Acme's quarter is thirteen ISO weeks, 2026-W40 through 2026-W52. Ask about two of them at once. package_id and weeks are both repeatable, so one call covers a whole flight.

buyer "$SWB/sellers/acme/avails?package_id=acme-prime&weeks=2026-W40&weeks=2026-W47" \
  | jq '[.avails[] | {week, state, sellout_level, spots,
         preemptible: ([.rates[] | select(.buy_type=="preemptible") | .gross_rate_unit] | first)}]'

[
  { "week": "2026-W40", "state": "available", "sellout_level": 0.2,
    "spots": { "available": 64, "total": 80 }, "preemptible": 320 },
  { "week": "2026-W47", "state": "available", "sellout_level": 0.88,
    "spots": { "available": 10, "total": 80 }, "preemptible": 545 }
]

Same package, same spot length, same market class. One costs 320 a spot and one costs 545, and the reason is sitting right next to the price: 2026-W40 is the quarter opening with 64 of 80 spots left, and 2026-W47 is the pre-holiday peak with 10. This is why the model has weeks at all. Inventory is perishable and priced by scarcity, so a rate without the week it applies to is not a rate.

Try the whole quarter and read the curve: cheap at the open, level through the middle, peak at 2026-W47, and cheapest of all at 2026-W52 as the quarter closes out.

buyer "$SWB/sellers/acme/avails?package_id=acme-prime$(printf '&weeks=2026-W%d' $(seq 40 52))" \
  | jq -r '.avails[] | "\(.week) \(.state) sellout=\(.sellout_level) preemptible=\(
           [.rates[] | select(.buy_type=="preemptible") | .gross_rate_unit] | first)"'

2026-W40 available sellout=0.2 preemptible=320
2026-W41 available sellout=0.62 preemptible=405
2026-W42 available sellout=0.94 preemptible=470
2026-W43 unavailable sellout=1 preemptible=405
2026-W44 available sellout=0.55 preemptible=405
2026-W45 available sellout=0.55 preemptible=405
2026-W46 available sellout=0.55 preemptible=405
2026-W47 available sellout=0.88 preemptible=545
2026-W48 available sellout=0.55 preemptible=405
2026-W49 available sellout=0.55 preemptible=405
2026-W50 available sellout=0.55 preemptible=405
2026-W51 projected sellout=0.7 preemptible=355
2026-W52 available sellout=0.15 preemptible=250

Two of those states are not available, and both matter. 2026-W43 is unavailable: the seller is telling you there is nothing to sell, before you write an order. 2026-W51 is projected: quotable, not yet firmly clearable, because the week has not been scheduled down to the break. A seller that only ever says "available" is not telling you anything; the tri-state answer is what makes the yes mean something.

4. Compare two buy types

Now hold the week still and change what you are buying. This is the same 2026-W41 cell four different ways.

buyer "$SWB/sellers/acme/avails?package_id=acme-prime&weeks=2026-W41" | jq '.avails[0].rates'

[
  { "class": "scatter", "advertiser_class": "general", "spot_length": 30,
    "buy_type": "preemptible", "clearance_tier": "P2", "rate_basis": "per_unit",
    "gross_rate_unit": 405, "gross_rate_weekly": 4050,
    "locked_rate_id": "lr_88c1", "currency": "USD" },
  { "class": "scatter", "advertiser_class": "general", "spot_length": 30,
    "buy_type": "non_preemptible", "rate_basis": "per_unit",
    "gross_rate_unit": 520, "locked_rate_id": "lr_88c2", "currency": "USD" },
  { "class": "scatter", "advertiser_class": "general", "spot_length": 30,
    "buy_type": "fixed_position", "rate_basis": "per_unit",
    "gross_rate_unit": 640, "locked_rate_id": "lr_88c4", "currency": "USD" },
  { "class": "scatter", "advertiser_class": "general", "spot_length": 30,
    "buy_type": "audience_guaranteed", "rate_basis": "cpm",
    "gross_rate_cpm": 3.4, "audience_code": "hh",
    "locked_rate_id": "lr_88c3", "currency": "USD" }
]

405, 520, 640. The ladder is what each successive certainty costs, and it is not a Switchboard invention: these are classes of time the trade and the FCC already recognize.

  • preemptible at 405. Cheapest, and the seller may bump it for a better-paying buyer. clearance_tier: "P2" says how much notice you get; the tier only appears on this buy type, because it only means anything here.
  • non_preemptible at 520. A 28% premium, and what it buys is that the spot airs. Nobody outbids you out of the schedule.
  • fixed_position at 640. A further step, and what the extra 120 buys is where: a named position, not just a slot somewhere in the week.
  • audience_guaranteed at a 3.40 HH CPM. A different axis, not a rung. Here you buy delivered households and the seller decides how many spots it takes to get there. Note rate_basis: "cpm" and audience_code: "hh": a CPM without the audience it is priced in is not a number. The cell reconciles to the spot cell on the same package, 405 against 128,000 households a spot being a $3.16 CPM, plus about 7% for the seller carrying the delivery risk.

Every cell carries a locked_rate_id. That handle is what you put on the order line, and it is the whole mechanism behind the price-clearance invariant: you transact the price you were shown, or the order fails loudly rather than clearing at a surprise.

5. Place an order and watch it move

Six preemptible spots in 2026-W41, at the rate you just read. The line quotes unit_cost and locked_rate_id together, and rate_version names the catalog commit you priced against.

buyer -X POST $SWB/orders -H "Content-Type: application/json" -d '{
  "seller": "acme", "workflow": "catalog",
  "external_order_id": "tutorial-flight-01",
  "rate_version": "acme-2026q4-v1",
  "line_items": [
    { "package_id": "acme-prime", "provider": "National ACME", "network": "ACME",
      "week": "2026-W41", "units": 6, "spot_length": 30,
      "rate": { "class": "scatter", "advertiser_class": "general",
                "buy_type": "preemptible", "clearance_tier": "P2",
                "rate_basis": "per_unit", "unit_cost": 405,
                "locked_rate_id": "lr_88c1", "currency": "USD" } }],
  "buyer_metadata": { "advertiser": "YourClient", "campaign": "Tutorial" }
}'

{ "order_id": "ord_608cd9a032c5", "external_order_id": "tutorial-flight-01",
  "status": "submitted", "created_at": "2026-10-01T14:02:11Z" }

external_order_id is your key, and it is the idempotency key too: repeat the call with the same bytes and you get the same order back with X-Idempotent-Replay: true, not a duplicate buy. Change the bytes under the same id and you get a 409. Save the order id:

export SWB_ORDER=ord_608cd9a032c5

The snapshot is always authoritative. Ask it where the order is:

buyer $SWB/orders/$SWB_ORDER \
  | jq '{order_id, status, last_seq, totals,
         line_items: [.line_items[] | {unit_id, week, units, status, num_requested, num_accepted}]}'

{
  "order_id": "ord_608cd9a032c5",
  "status": "seller_review",
  "last_seq": 4,
  "totals": { "gross": 2430, "currency": "USD" },
  "line_items": [
    { "unit_id": "u-0001", "week": "2026-W41", "units": 6,
      "status": "pending", "num_requested": 6, "num_accepted": 0 }
  ]
}

It stopped at seller_review. Nothing is booked yet, and num_accepted is 0. The event stream shows how it got there, one sequenced step at a time:

buyer "$SWB/events?order=$SWB_ORDER" | jq '[.events[] | {seq, status, occurred_at}]'

[
  { "seq": 1, "status": "submitted",     "occurred_at": "2026-10-01T14:02:11Z" },
  { "seq": 2, "status": "validating",    "occurred_at": "2026-10-01T14:02:12Z" },
  { "seq": 3, "status": "confirmed",     "occurred_at": "2026-10-01T14:02:13Z" },
  { "seq": 4, "status": "seller_review", "occurred_at": "2026-10-01T14:02:14Z" }
]

seq is monotonic per order, so you can replay from any point with &after_seq= and never miss or double-count a transition. In production the same events arrive at your notify_url; the stream is the catch-up path, not a second source of truth. The full rail is in the state machine.

6. Be the seller

This is the section that is different from every programmatic system you have used. The order is waiting on a person at the seller, and no amount of buyer-side retrying will move it. Switch secrets and be that person.

seller -X POST $SWB/me/orders/$SWB_ORDER/decide -H "Content-Type: application/json" -d '{
  "decision": "approve",
  "actor": "sales@acmebroadcasting.example",
  "units": [ { "unit_id": "u-0001", "action": "accept", "num_accepted": 6 } ]
}'

{ "order_id": "ord_608cd9a032c5", "status": "placed",
  "units_accepted": 6, "units_countered": 0, "units_rejected": 0 }

Note that the decision is per unit, not per order. A seller accepts, rejects, or counters each line on its own, which is how real linear approvals work and why units_accepted, units_countered, and units_rejected are three separate counts. Now read the order back:

buyer $SWB/orders/$SWB_ORDER \
  | jq '{status, line_items: [.line_items[] | {status, num_accepted, allocation}], artifacts}'

{
  "status": "placed",
  "line_items": [
    { "status": "accepted", "num_accepted": 6,
      "allocation": [ { "date": "2026-10-05", "units": 2 },
                      { "date": "2026-10-07", "units": 2 },
                      { "date": "2026-10-09", "units": 2 } ] }
  ],
  "artifacts": [
    { "seq": 5, "name": "placement_receipt", "seller_order_id": "ACME-77120",
      "daylocks": [ { "unit_id": "u-0001", "date": "2026-10-05", "units": 2 },
                    { "unit_id": "u-0001", "date": "2026-10-07", "units": 2 },
                    { "unit_id": "u-0001", "date": "2026-10-09", "units": 2 } ],
      "placed_at": "2026-10-01T14:02:15Z" }
  ]
}

placed means booked. The allocation is the seller telling you which dates your six spots actually landed on, and the placement_receipt carries the seller's own order id, so the buy is reconcilable in their system and yours. That is the whole loop: discovered, priced, ordered, approved, booked. No email, and no spreadsheet.

Try the same order through the MCP surface and an agent does exactly this, tool call for tool call, against the same mock. The approval gate does not move: an agent cannot approve its own order any more than a buyer can.

7. Now make it interesting

A sandbox that only shows the happy path teaches you nothing about the real one. Three outcomes you should trigger on purpose, because you will meet all three in production.

Partial acceptance. Ask for 10 spots and have the seller take 6. Create the order the same way as section 5, changing external_order_id to tutorial-partial-01 and units to 10; the sandbox returns ord_4bbfb549b3a6. Then decide with num_accepted: 6:

seller -X POST $SWB/me/orders/ord_4bbfb549b3a6/decide -H "Content-Type: application/json" -d '{
  "decision": "approve", "actor": "sales@acmebroadcasting.example",
  "units": [ { "unit_id": "u-0001", "action": "accept", "num_accepted": 6 } ]
}'

{ "order_id": "ord_4bbfb549b3a6", "status": "placed",
  "units_accepted": 6, "units_countered": 0, "units_rejected": 4 }

buyer $SWB/orders/ord_4bbfb549b3a6 \
  | jq '{status, line_items: [.line_items[] | {unit_id, status, num_requested, num_accepted}]}'

{ "status": "placed",
  "line_items": [ { "unit_id": "u-0001", "status": "partial",
                    "num_requested": 10, "num_accepted": 6 } ] }

The order is placed and the unit is partial. Both are true, and the distinction is the point: an order is not all-or-nothing, so plan against num_accepted, never against what you asked for.

Inventory unavailable. Order into 2026-W43, the dark week. The request is perfectly well formed; the answer is still no. Use external_order_id: "tutorial-w43-01", "week": "2026-W43", "units": 4, and that week's own rate handle lr_pr43p. Create still returns 201 and ord_433e6f626dba, because intake accepted a valid request; the rejection lands on the stream:

buyer "$SWB/events?order=ord_433e6f626dba&after_seq=2" | jq '.events'

[
  { "event_type": "order.status_changed", "seq": 3, "status": "rejected",
    "substatus": null, "artifact": null,
    "error": { "error_code": "INVENTORY_UNAVAILABLE",
               "error_message": "Requested week or slot lacks availability",
               "stage": "availability", "recoverable": true, "details": {} },
    "occurred_at": "2026-10-01T14:02:13Z" }
]

Availability is the only business reason an order gets rejected outright, and the check runs at create as the last defense against the gap between reading avails and writing the order. recoverable: true is the actionable part: refetch avails, pick a week that exists, order again.

Stale rate card. Send a rate_version behind the current one and the platform refuses to guess which price you meant. Repeat the section 5 create with external_order_id: "tutorial-stale-01" and "rate_version": "acme-2026q4-v0". You get ord_5a9ec49da4bf, and then:

buyer "$SWB/events?order=ord_5a9ec49da4bf&after_seq=2" | jq '.events'

[
  { "event_type": "order.status_changed", "seq": 3, "status": "failed",
    "substatus": null, "artifact": null,
    "error": { "error_code": "STALE_RATE_CARD",
               "error_message": "rate_version acme-2026q4-v0 is behind acme-2026q4-v1",
               "stage": "validation", "recoverable": true,
               "details": { "rate_drift": [ { "unit_id": "u-0001",
                                              "locked_rate_id": "lr_88c1",
                                              "your_cost": 405,
                                              "current_cost": 405 } ] } },
    "occurred_at": "2026-10-01T14:02:13Z" }
]

rate_drift is the useful half of the failure: per unit, what you locked against what the card says now. Here the numbers match, so re-submitting at the current version costs you nothing. When they do not match, you learn the difference before you own it rather than after. That is the invariant doing its job, and it is why the failure is failed rather than a silently reclearing order. Full list of codes on the errors page.

Repeat any sequence on this page in a fresh X-Sandbox-Session and you get byte-identical answers. The sandbox is deterministic on purpose, so a demo you rehearse is the demo you give, and a failing integration test means your code changed.

8. Where to go next

  • Point an agent at it. The MCP surface exposes the same calls as tools, so a buying agent can run this walkthrough itself. Buy with MCP is the working session.
  • Learn the objects properly. The object model, then the deal model for the four buy types and guarantees for the tri-state avail.
  • Widen the flight. One line per week is the grain, so a four-week buy is four lines in one order. Try 2026-W44 through 2026-W47 and watch the totals cross the price curve.
  • Buy the other packages. acme-gameday only exists on 2026-11-22 and 2026-12-20, and acme-morning-syndicated reconciles under a tighter posting policy than Prime. One order may carry lines from both, each under its own package's terms.
  • Read the reference. API overview for the surface, objects for every field, conventions for idempotency and errors.