Traced Agentic Ad Lab
How a Machine Buys Media: 13 Operations, and Which Rules Bind
One CTV campaign traced call by call through AdCP 3.1.13 and AAMP, with both stacks run against hand-built payloads to separate the rules a validator enforces from the rules only described.
20 min read 14 chapters
Every claim below opens a file at a named commit. The shas are in the citations.
AAMP, the IAB Tech Lab’s Agentic Advertising Management Protocols, has one shared wire contract, and it begins at the quote and stops at the deal ID. Creative, delivery reporting and spending authority fall outside it, which puts all three inside your integration budget, per counterparty, forever.
AdCP, the Ad Context Protocol, runs the other way, from who authorised the spend through to what it delivered and who gets billed for the tooling. One connected-TV campaign uses 13 of the 64 operations it publishes in 3.1.13. The procurement decision is settled before the first payload, by where each contract starts and where it gives up.
The second decision gets settled by a parser. Every rule in either corpus is written in one of two registers: a JSON Schema keyword your counterparty’s validator executes, or a sentence in a description field that only a human ever reads. An if is a rule a validator will hold your counterparty to; a description is a rule you will be arguing about over email. Trace one buy call by call and the line between the two turns out to sit in specific, findable places, several of them exactly where the money moves.
The two stacks, step for step
| Step | AdCP 3.1.13 | AAMP wire contract |
|---|---|---|
| Capability discovery | get_adcp_capabilities | /.well-known/agent.json, then /registry/agents/discover |
| Spending authority | sync_plans, then check_governance | None. ConsentContext rides on the quote |
| Inventory discovery | get_products with a prose brief | GET /products, then /products/avails |
| Price agreement | refine with an ask, then finalize | /api/v1/quotes, then a numeric buyer_price |
| Audience attachment | get_signals, activate_signal, sync_audiences | audience_plan, an open object |
| Creative | sync_creatives, build_creative and six more | None. Creative exists as a primitive with no path to send it down |
| Commit | create_media_buy returning media_buy_id | /api/v1/deals returning deal_id |
| Modification | update_media_buy, PATCH semantics | /api/v1/change-requests |
| Delivery reporting | get_media_buy_delivery, get_media_buys | None. /deals/{id}/performance on the seller agent, /reports/{job_id} on the buyer agent, neither a standard |
| Closing the loop | provide_performance_feedback, report_plan_outcome, report_usage | No counterpart |
| Errors | core/error.json, 92 documented codes, open vocabulary, recovery, retry_after, 15 typed detail schemas | ErrorEnvelope, closed 11-code enum, no recovery or retry hint |
The right-hand column comes out of a document that has not shipped. Both IAB Tech Lab reference agents pin iab-agentic-primitives at v0.5.0, the OpenAPI file’s own info.version reads 0.1.0, and the whole published surface is 13 paths, one of them a JSON-RPC transport endpoint. Read the field names below as intent rather than as an integration target.
Four of those eleven rows are empty on the AAMP side, and the two questions a finance team asks first, who authorised this spend and what did it deliver, are two of the four. That is my case for putting AdCP first on an integration roadmap and treating AAMP as the negotiation layer it is good at. The longer version of that argument sits with the version evidence.
The price of the call is the negotiation record. AdCP publishes no buyer_price, no round_number and no concession_pct anywhere, so the concession trail is yours to build; AAMP has one, priced in integer micros, and it is the strongest thing in that corpus. Governance is the second qualification, and it is opt-in by construction: check_governance validates against a plan somebody had to register first.
Who is in the trace
AdCP’s 3.1.13 schema registry publishes 64 operations, invoked by operation name over MCP, A2A or REST rather than at URLs of their own, and the three bindings are not interchangeable: push_notification_config scopes webhooks to “A2A and REST protocols”. AAMP is IAB Tech Lab’s umbrella over eight independently versioned repositories, and the one document the two sides share is an OpenAPI 3.1 file served over plain HTTP, so where AdCP has operation names AAMP has paths.
A buyer agent drives the campaign for the advertiser and four other parties answer it: the sales agent for the publisher, the governance agent that holds spending authority, the signals agent that sells segments, the vendor agent that bills for tooling. A separate creative agent takes sync_creatives when the sales agent hosts no library. AAMP’s wire contract knows about two.
| # | Call | Answered by | Can it come back later? |
|---|---|---|---|
| 1 | get_adcp_capabilities | sales agent | |
| 2 | sync_plans | governance agent | |
| 3, 4 | get_products | sales agent | yes |
| 5 | sync_creatives | sales agent | yes |
| 6 | get_signals | signals agent | yes |
| 7 | activate_signal | signals agent | |
| 8 | check_governance | governance agent | |
| 9 | create_media_buy | sales agent | yes |
| 10 | get_media_buys | sales agent | |
| 11 | get_media_buy_delivery | sales agent | |
| 12 | provide_performance_feedback | sales agent | |
| 13 | report_plan_outcome | governance agent | |
| 14 | report_usage | vendor agent |
Fourteen calls, thirteen operations, because get_products runs twice. That leaves 51 operations a straightforward campaign never touches. A campaign that changes mid-flight adds update_media_buy to the count. Anyone quoting you a per-operation implementation estimate for all 64 is quoting you for a surface you will not use. Note which side of the trade the count binds: required-tasks.mdx makes seven media buy operations mandatory for a sales agent, including the update_media_buy this trace skips, and gives an orchestrator no required tasks at all, only five prose conformance obligations. What each role has to build is the sell side’s bill before it is yours.
The payloads below are the wire view: real field names at the paths the schemas give them, budgets and identifiers invented, every one run against its published schema with Python’s jsonschema, and the accept and reject verdicts in the tables are that library’s output rather than a reading of the file. There is a prose version of the same buy if you want the story without field names.
Before the first call
Pin the version you cannot pin. core/version-envelope.json accepts adcp_version as a major and a minor with an optional prerelease suffix, so "3.1" validates and "3.1.13", the release these schemas come from, does not. The field carries its own instruction for the case that bites SDK authors: values read out of bundle metadata, like a published_version of "3.1.0-beta.1", “MUST normalize to release-precision … meta-field values are NOT valid wire values”. Patches never reach the wire at all; they surface as an advisory build_version on capabilities. Keep emitting the deprecated adcp_major_version through 3.x anyway, for sellers that read only the legacy field.
Then read request_signing off the capabilities response before you send anything that spends. It says whether RFC 9421 signed HTTP requests are required, and finding that out from a failed create_media_buy is finding it out too late. The field also carries a dated obligation worth a clause in a contract rather than a line in a backlog: “Optional in 3.0 … Required for spend-committing operations in 4.0 (the next breaking-changes accumulation window).”
get_adcp_capabilities is the only unconditionally required task in AdCP. Two more fields come back that a client has to read. supported_protocols is a testable commitment rather than a hint: a stable value there commits the agent “to pass the baseline compliance storyboard at /compliance/{version}/protocols/{protocol}/”, and 3.1.13 ships 38 universal storyboards plus 21 graded specialisms to run it against. The adcp block’s supported_versions is the authoritative list of releases that seller speaks.
Request and response disagree about how many protocols exist: five values to query, media_buy, signals, governance, sponsored_intelligence and creative, and seven that can come back. Both spell them snake_case while the directories for those areas are kebab-case, and the mapping lives in that same description, media_buy to /compliance/.../protocols/media-buy/. A generator fed directory names emits strings the seller rejects, and the rule that would have caught it is prose.
Spending authority is a plan a validator can read
The plan gets registered with the governance agent before anything is bought against it.
{
"plan_id": "plan_q4_launch",
"brand": { "domain": "example-advertiser.com" },
"objectives": "Launch awareness across CTV and online video",
"budget": {
"total": 400000,
"currency": "USD",
"per_seller_max_pct": 40,
"reallocation_threshold": 25000
},
"flight": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-15T23:59:59Z" },
"policy_categories": ["fair_housing"],
"human_review_required": true
}
One entry in plans[], alongside a required idempotency_key. The first five members are required; the optional ones carry the teeth. budget is stricter than it looks: a oneOf demands exactly one of reallocation_threshold or reallocation_unlimited, so a plan that names neither is rejected before any policy question arises.
static/registry/policy-categories/ holds ten categories as registry strings rather than as an enum member, so a new category can arrive without a release. Only four of the ten reach a validator, and you can see exactly why by opening governance/sync-plans-request.json: the plan item carries an allOf of two conditionals, and the first one is contains over a four-value enum, fair_housing, fair_lending, fair_employment, pharmaceutical_advertising, with a then of human_review_required: {const: true}. The enum inside the if is the enforcement boundary.
Outside it sit age_restricted, children_directed, firearms_weapons, gambling_advertising, health_wellness and political_advertising. The other six, political_advertising included, are strings no code reads, so the extensibility is currently a place to file categories that do nothing. If your compliance officer is relying on one of those six, tell them today rather than in the incident review.
sync_plans payload | Result |
|---|---|
policy_categories: ["fair_housing"], no human_review_required | rejected: “‘human_review_required’ is a required property” |
same plan, human_review_required: false | rejected: “True was expected” |
same plan, human_review_required: true | accepted |
policy_ids: ["eu_ai_act_annex_iii"], no human_review_required | rejected |
policy_categories: ["political_advertising"], no human_review_required | accepted |
AdCP’s hardest governance rule compiles. It will not take false for an answer either, and the second conditional extends the same treatment to one policy id, eu_ai_act_annex_iii, which is not a category at all.
How AdCP negotiates a price: in English, and in nothing else
get_products is discovery, and buying_mode is required on every 3.x request, taking brief, wholesale or refine.
{
"adcp_version": "3.1",
"buying_mode": "brief",
"brief": "Connected TV and online video for a September product launch. Adults 25-54, US only, brand-safe premium video, no news adjacency.",
"brand": { "domain": "example-advertiser.com" },
"account": { "account_id": "acct_8842" },
"filters": { "channels": ["ctv", "olv"] }
}
Back comes products[] and, when the seller curates rather than filters, proposals[]. A product requires seven fields and carries pricing_options as an array discriminated on pricing_model, with nine models on disk, CPM through flat rate and time. Choosing a pricing_option_id is how an AdCP buyer accepts a rate; the get_products reference has the other 48 of the product’s 49 properties, and what belongs in the brief string rather than in filters is a decision with money on it.
Pushing back happens inside the same operation, on a second call with buying_mode: "refine". Entries are discriminated on scope: request-scoped carries only a prose ask, product-scoped adds more_like_this, and only proposal-scoped takes finalize. Finalize is all-or-nothing by declaration, “if any entry has action: 'finalize', ALL entries in the array MUST be proposal-scoped with action: 'finalize'”, on pain of INVALID_REQUEST. That one is a description, not a keyword, and there is no rollback behind it: proposal_status has exactly two values, so a half-applied finalize is neither visible nor recoverable.
That prose ask is the whole negotiation vocabulary. A trading desk that wants a machine-checkable concession trail out of AdCP is going to build it in its own orchestration or not have one.
Creative, and the two shapes that catch people out
Creative is eight operations in AdCP, third-largest behind governance’s 22 and media buy’s 11, and zero paths in AAMP’s wire contract. Build against AAMP and the creative pipeline is yours to invent.
sync_creatives upserts into a library, capped at 100 creatives per call.
{
"adcp_version": "3.1",
"idempotency_key": "c41f9b02-1e33-4a58-bb70-2f9c8d1e0a45",
"account": { "account_id": "acct_8842" },
"creatives": [
{
"creative_id": "cr_launch_15s_v1",
"name": "Launch 15s cutdown",
"format_kind": "video_vast",
"assets": {
"vast_tag": {
"asset_type": "vast",
"delivery_type": "url",
"url": "https://vast.example-agency.com/tag?cid=launch15&cb=[CACHEBUSTER]",
"vast_version": "4.2",
"duration_ms": 15000
}
}
}
],
"assignments": [
{ "creative_id": "cr_launch_15s_v1", "package_id": "pkg_ctv_primary", "weight": 100 }
],
"dry_run": false,
"validation_mode": "strict"
}
A creative requires creative_id, name and assets, and two of those three have a shape that catches everyone once. assets is an object keyed by the format’s asset_group_id rather than an array, each value carrying an asset_type discriminator that selects the matching asset schema. format_id, optional and marked in the schema as the legacy named-format path, is always a {agent_url, id} object rather than a plain string, and it is one of three mutually exclusive format selectors, alongside the format_kind used above and format_option_ref.
dry_run, validation_mode and delete_missing are the flags worth knowing before the first run against a new counterparty, and the creative area reference has the other seven operations. dry_run rehearses. validation_mode defaults to strict, which fails the whole call on one bad creative, against lenient, which processes the good ones and reports the rest. delete_missing archives everything the request does not name, carries a “use with caution” in its own description, and cannot be combined with creative_ids.
Signals attach on a discriminated destination
Third-party segments and first-party CRM records take different roads. get_signals and activate_signal handle segments; CRM records go to sync_audiences, whose SHA-256 hashed_email the schema calls “Pseudonymous PII, not anonymous”. Both end in targeting, which is why signal and audience get conflated.
{
"adcp_version": "3.1",
"action": "activate",
"signal_agent_segment_id": "seg_auto_intenders_us",
"destinations": [{ "type": "platform", "platform": "example-dsp", "account": "seat_44120" }],
"idempotency_key": "8c1b0d4e-77aa-4f19-9a02-3d5e6f7a8b90"
}
activate_signal requires idempotency_key, signal_agent_segment_id and destinations, and core/destination.json is a real oneOf discriminated on type: a platform branch requiring a platform string, an agent branch requiring an agent_url. Send a hybrid and it fails.
action also takes deactivate, tied in the schema to “removing the segment from downstream platforms, required when campaigns end to comply with data governance policies (GDPR, CCPA)”. The teardown is in the contract rather than in a runbook.
The verdict, then the money
check_governance is the gate, and it is read-only.
{
"adcp_version": "3.1",
"plan_id": "plan_q4_launch",
"caller": "https://buyer.example-agency.com/agent",
"tool": "create_media_buy",
"phase": "purchase",
"purchase_type": "media_buy",
"payload": { "total_budget": { "amount": 250000, "currency": "USD" } }
}
The response requires check_id, verdict, plan_id and explanation. verdict takes approved, denied or conditions; on conditions, the conditions array says what has to become true and expires_at says how long the answer is good for. Porting 3.0 code, read that field’s description first: “Renamed from status in 3.1 to free the top-level status key for the envelope task-status …” The values did not move, only the property name, so a 3.0 client reads a task status and files it as a decision.
Then the money.
{
"adcp_version": "3.1",
"idempotency_key": "7ab3f5d1-9c02-4e6b-a1f8-0c4d2e5b7a99",
"account": { "account_id": "acct_8842" },
"brand": { "domain": "example-advertiser.com" },
"proposal_id": "prop_q4_ctv_mix_v3",
"total_budget": { "amount": 250000, "currency": "USD" },
"start_time": "2026-09-01T00:00:00Z",
"end_time": "2026-10-15T23:59:59Z",
"po_number": "PO-2026-4417"
}
Five fields are required and neither packages nor proposal_id is one of them. total_budget sits in that payload because the schema puts it there: a draft-07 dependencies clause reads {"proposal_id": ["total_budget"]}, so proposal mode arriving without a budget is rejected by name, and the seller derives package budgets by applying the proposal’s allocation percentages to the figure.
create_media_buy payload | Result |
|---|---|
the required five, no packages, no proposal_id | accepted |
proposal_id, no total_budget | rejected: “‘total_budget’ is a dependency of ‘proposal_id’” |
proposal_id and total_budget | accepted |
Row one is the whole thesis in one payload. “One of packages or proposal_id must be provided” is a sentence in the description and nothing else in the file says it, so the call that commits the money accepts a request with nothing in it to buy, in a schema whose author was two keys away from a dependencies clause that would have caught it. That reads as a gap rather than a design.
The response is one of exactly three mutually exclusive shapes, all three worth reading in full before you write the client. Here the enforcement runs the other way: create-media-buy-response.json is a oneOf over sync success with media_buy_id, packages and a media_buy_status of pending_creatives, pending_start, active or paused; terminal failure with an errors array and no artifact; and a task envelope carrying task_id, where media_buy_id arrives later on the completion artifact. A client that reads media_buy_id unconditionally breaks on the third. A repeat of the idempotency key returns the existing buy, marked replayed: true on the envelope, and which operations take a key at all is worth checking per counterparty.
Four of the thirteen operations can answer later
Seven of the 64 operations ship a dedicated async response family, and the counting rule is a filename: *-async-response-submitted.json exists for get_products, get_signals, sync_creatives, build_creative, create_media_buy, sync_catalogs and update_media_buy. Four of those seven are in this trace. Each also ships a working arm, and six of the seven ship an input-required arm, which is the state the human-signs-the-insertion-order case actually lands in: enums/task-status.json defines input-required as “Task is paused and waiting for input from the user (e.g., clarification, approval)”, and handling submitted, working and input-required is one of the five conformance obligations required-tasks.mdx puts on an orchestrator.
Retrieval is specified either way. A client “can always poll get_task_status (legacy tasks/get) with task_id”, webhooks through push_notification_config are A2A and REST only because “MCP uses progress notifications instead of webhooks”, and a submitted task is queued for “hours to days”, so the polling interval comes off a human’s calendar rather than a request timeout. The other 57 operations answer in line.
The reporting loop runs to three different agents
get_media_buys answers what state the buy is in. get_media_buy_delivery answers what it delivered, and requires nothing at all: all twelve of its fields are optional, including the buy being asked about, so an empty request is a schema-valid way to ask a seller for everything it has.
{
"adcp_version": "3.1",
"media_buy_ids": ["mb_9f21c0"],
"start_date": "2026-09-01",
"end_date": "2026-09-30",
"time_granularity": "daily",
"include_package_daily_breakdown": true
}
The response carries partial_data and next_expected_at, because some channels report in stages.
Three optional webhook slots ride on the create_media_buy request, reporting_webhook, artifact_webhook and push_notification_config, and the first carries a constraint of its own: a reporting_frequency of hourly, daily or monthly that “must be supported by all products in the media buy”, so one product can invalidate hourly across a mixed CTV and online-video buy.
Three reports close the campaign and they go to three different places. provide_performance_feedback returns to the sales agent carrying a normalised performance_index rather than raw conversions, and that single float is the entire quantitative channel from buyer to seller in 3.1.13. report_plan_outcome goes to the governance agent. report_usage goes to the vendor agent, and it exists because AdCP settles no payment: it reports the consumption an off-protocol invoice is built from. Build only the seller-facing half and you have built a third of the loop.
The same buy through AAMP’s contract
Discovery is a file plus a call. GET /.well-known/agent.json returns the agent card with capabilities, skills, inventory_types, supported_deal_types and trust_status; POST /registry/agents/discover takes a one-field body. No capability negotiation, no version pin, nothing saying which release you are talking to, and both models sit side by side here.
GET /products takes limit and offset and nothing else, because “filtering is client-side; no /products/search”. Then POST /products/avails per product, in the lowercase unseparated field names (productid, startdate, enddate) inherited from OpenDirect 2.1. POST /api/v1/quotes requires idempotency_key, product_id and a deal_type of PG, PD or PA, and buyer_identity sets the access tier, though QuoteRequest.json says “the effective tier is capped server-side by registry-verified trust”.
So the tier your agent asks for is a claim the seller’s registry lookup can quietly downgrade, and the round limits and concession caps you thought you had move with it. The map that caps it, TRUST_TIER_CEILING in registry_client.py, is labelled in place as the “Legacy AAMP trust-tier model”, kept for one test rig, and the class that supersedes it says “The real registry has no AAMP trust-tier enum”, so what a live lookup does to a tier today is documented nowhere in the corpus.
The limits themselves are not in the wire contract either. They come off STRATEGY_LIMITS in the reference seller agent’s src/ad_seller/models/negotiation.py, reached through TIER_STRATEGY_MAP and reproduced as a four-row table in that repository’s docs/integration/negotiation.md, and they key on access tier rather than on trust status: public gets 3 rounds and 8% cumulative, advertiser gets 6 and 20%.
Here AAMP has a step AdCP doesn’t.
{
"action": "counter",
"quote_id": "qt_7712",
"buyer_price": { "amount_micros": 41500000, "currency": "USD" },
"round_number": 2,
"rationale": "Comparable primetime CTV at 40.00 elsewhere in the plan",
"idempotency_key": "3f8c2a11-6b04-4d90-9c77-11de5f0a2b34"
}
That price object is the most opinionated thing in either corpus. Flagged decision FD-11 makes Money an integer micros amount and bans floats outright: “IEEE 754 floating point is non-deterministic for money math … that defect must not be fossilized into the spec.” Send 41.50 and it bounces rather than truncating. One float survives on the wire, the budget on the OpenDirect-dialect avails request, and PROTOCOL_RECONCILIATION.md books it as a deliberate exception rather than an oversight: “Money fields stay FLOAT … Money migration is next-major.”
action takes accept, counter, final_offer or reject and has no default, and a round requires round_number, buyer_price, seller_price and action, with the response adding concession_pct and cumulative_concession_pct.
Booking is thin because the quote did the work. POST /api/v1/deals requires only quote_id and idempotency_key, and the returned Deal requires deal_id, deal_type, product, pricing and terms while carrying openrtb_params as an optional. That last field is what hands the buy to a DSP, and a seller can return a valid booked deal without it.
Fifteen typed error shapes, and one open code list
Both stacks publish one error shape and then disagree about what a code is for. AdCP’s core/error.json requires code and message and carries a retry_after the client “MUST clamp” to 1 through 3600 seconds. Its vocabulary is 92 documented codes in enums/error-code.json, deliberately open: error.code is wire-typed string, sellers may emit codes outside the list, and receivers “MUST decode unknown codes” by falling back to error.recovery, one of transient, correctable, terminal. So the code list is documentation and the recovery classification is the contract. Absent recovery defaults to transient, which means an error that forgot to say what kind of error it is gets retried.
Underneath sit 15 typed schemas in error-details/, one per failure a client can do something about. budget-too-low, version-unsupported and rate-limited each carry the field that makes the next attempt different from a repeat: a minimum budget, the version the seller does speak, a wait. A production client spends more code against those 15 than against the happy path.
AAMP’s ErrorEnvelope is one shape over a closed 11-value enum, with no recovery classification and no retry timing. A closed enum is easier to switch on, and a client built on one ships a release the day the spec adds a code.
What the schema enforces, and what it only describes
Across the 48 JSON Schemas in AAMP’s wire contract there is not one if. There are 431 oneOf and anyOf nodes, and every one of them is a nullable union, [T, null], with no structural discriminator anywhere in the set. That is not an editorial choice, it is a build artifact: spec/generate_schemas.py and spec/jsonschema/protocol/generate.py produce those files by calling model_json_schema() on Pydantic models, and two drift tests fail if anyone edits the output by hand. The generator’s own docstring calls the exported schemas “the normative, language-neutral artifacts … the Python package is its reference implementation”. The normative artifact is strictly weaker than the implementation it is generated from.
You can watch the difference cost you money in one file. NegotiationMessage enforces three cross-field rules in negotiation.py, in a @model_validator(mode="after") that raises: a priced action with no buyer_price, a reject carrying one, and a message that names no negotiation_id, proposal_id or quote_id. None of the three can be expressed by model_json_schema(), so none of them is in the file you were handed.
NegotiationMessage payload | Published schema | Python model |
|---|---|---|
action: "counter", no buyer_price | accepted | raises |
action: "reject" carrying a buyer_price | accepted | raises |
no negotiation_id, proposal_id or quote_id | accepted | raises |
buyer_price.amount_micros: 41.5 | rejected: “41.5 is not of type ‘integer’” | raises |
a price with no action | rejected: “‘action’ is a required property” | raises |
FD-11 holds up, because a type and a required-field list are exactly what a generated schema carries. Everything that reasons across two fields at once went missing in export. Anyone building against NegotiationMessage is implementing that state machine in application code, whether they planned to or not. If that code is not Python, they are implementing it from a description string: buyer_price is “REQUIRED for ‘counter’/‘final_offer’” and “omitted on ‘reject’”, and nothing checks either half.
AdCP publishes conditionals a counterparty’s validator executes, and this trace runs into four of them: the human-review rule, the proposal-mode budget pairing, the three-armed create_media_buy response, and the platform-or-agent discriminator on core/destination.json. It also leaves “one of packages or proposal_id” as prose in the file where the pairing rule compiles. Run both stacks’ schemas against your own fixtures before you trust a description.
Where the trace stops
Neither trace measures delivery against a conversion and neither settles a payment. AdCP says the first part about itself in known-limitations.mdx, “Not an attribution protocol”, and the list of everything else neither stack standardises is longer than the trace above. So the thirteen calls get you a booked, delivering, governed campaign and stop one step short of the number your CFO cares about, which stays where it is today: in your measurement stack, reconciled by hand.
If you are scoping one of these integrations, I do that for money.