Microsoft Advertising Conversions API: Server-Side UET in Practice
Contents
- Management Summary
- 1. The current state of server-side conversions at Microsoft
- 2. The technical contract: endpoint, authentication, required fields
- 3. The auth key in guide and reference
- 4. Deduplication: how the UET tag and the server event find each other
- 5. Normalisation, hashing and consent
- 6. The client in production: batches, the seven-day window, resumption
- 7. Monitoring: which metrics indicate a problem in the pipeline
- Conclusion
Microsoft Advertising accepts conversions directly from server systems through the Conversions API, and as of the beginning of August 2026 the integration guide has been rewritten from top to bottom. This article addresses the people who build and run that integration: the tracking owner who decides on scope, the engineer who writes the client, and the manager who reads the summary and nothing else.
Management Summary
The Conversions API (CAPI) allows a backend to post conversion events to Microsoft Advertising without depending on the browser to fire them. It is a plain HTTPS endpoint, one path per UET tag, authenticated with a bearer token, accepting up to a thousand events per request. Since the May 2026 release the bearer token can be fetched programmatically instead of being copied by hand out of the interface, which turns the setup from a manual step into something automatable and rotatable. The current documentation describes the shape of that token response in two different ways, and a client written against only one of them can fail on a response that in fact succeeded — a failure that resembles a permissions problem without being one. The remainder of the contract is conventional: normalise, hash, deduplicate, stay inside a seven-day window.
- What it is: A server-to-server endpoint at
https://capi.uet.microsoft.com/v1/{tagId}/eventsthat accepts conversion events for one UET tag per path, authenticated by a bearer token in theAuthorizationheader. - Why now: The integration guide was fully revised on 4 August 2026 and the auth key can now be retrieved through the Campaign Management API, so the setup is scriptable rather than a manual copy-paste step.
- The documentation divergence: The guide documents a nested auth-key response, the operation reference documents a flat string; a client written against the guide raises an exception on a response that actually succeeded. A parser that accepts both forms is covered against either state.
- What it costs: One engineer, roughly one to two weeks to a production client including backfill, retry and monitoring — the API surface is small, and identity normalisation, consent handling and deduplication logic account for most of the effort.
- What it delivers: Conversions that are recorded despite ad blockers, browser restrictions and abandoned sessions, plus a deduplication key that keeps the browser tag and the server from counting the same purchase twice.
The timing is not forced by a deadline. Nothing breaks if the integration is postponed, and the browser-side UET tag keeps working exactly as before. What a delay costs is measurement quality on the conversions the browser never reports, and — because eventTime must fall within the last seven days — a gap older than a week cannot be filled retroactively. That single constraint is the argument for an early start: every postponed week is a week that can never be sent.
1. The current state of server-side conversions at Microsoft
Server-side conversion APIs are not new as a category. What is new here is the state of Microsoft’s own documentation, which now describes the surface in enough detail for a team to implement against it directly. The integration guide carries a document date of 4 August 2026 — six days old as this is written — and it is a full rewrite rather than a touch-up. The reference page for the auth-key operation was updated one day earlier, on 3 August. Two related pages moving within a day of each other usually indicates that the surface behind them moved too, which makes a re-reading of familiar pages worthwhile.
The practical driver is simpler than any platform narrative. The browser reports only a part of the conversions. A conversion that happens in a checkout flow with an ad blocker, in a session that ends before the confirmation page renders, in a native app wrapper, or hours later in a call centre, is a conversion the UET tag never sees. The order database contains all of them. The Conversions API is the pipe between the system that holds the record and the system that optimises the bidding.
There is a second, related development worth knowing about, though not yet as a foundation. The May 2026 release notes introduced the MSClickIdPerformanceReport, which exposes performance at the level of the individual click id and can therefore be joined against an in-house msclkid store. Its ConversionsQualified column is typed as Double. The page states “Not everyone has this feature yet” — and it states it twice. That indicates a pilot rather than general availability. The report is suitable for planning, not as a dependency.
2. The technical contract: endpoint, authentication, required fields
The contract is small. There is one endpoint, https://capi.uet.microsoft.com/v1/{tagId}/events, and the tag id is part of the path rather than the body. Where several UET tags are in operation — a common pattern when one account serves several brands or markets — several distinct URLs are involved, and the client needs a tag id as a first-class parameter rather than a constant.
Authentication is a bearer token in the Authorization header. There is no signing, no timestamp header, no request-body hash to compute. The token belongs to the tag, which means token management is per-tag as well: one secret per tag id, stored and rotated per tag id.
TAG_ID=123456789
AUTH_KEY="$(cat /etc/uet/authkey-${TAG_ID})" # read from the local secret store
curl -sS -X POST
"https://capi.uet.microsoft.com/v1/${TAG_ID}/events"
-H "Authorization: Bearer ${AUTH_KEY}"
-H "Content-Type: application/json"
--data @batch.json
A request carries up to 1000 events. Each event’s eventTime must fall within the last seven days. Each event should carry an eventId for deduplication and an adStorageConsent value. User identifiers are normalised to lowercase and then hashed with SHA-256.
A note on method here, because it matters more than any code sample. The fields named in this article are the ones the sources pin down explicitly. The field names that describe the individual conversion — the goal name, the revenue value, the currency — come from the respective tag configuration and from the field table in the integration guide itself. That table is the authoritative source; field names taken from a blog post, including this one, are not. The same applies to error codes and rate limits: the sources this article relies on do not state them, so this article does not supply them. A client that logs whatever the endpoint actually returns provides the basis for a retry policy built on observed behaviour in the respective environment rather than on an assumed number.
3. The auth key in guide and reference
Before the May 2026 release the CAPI bearer token was obtained by opening the Microsoft Advertising interface and copying it. That works for a single tag. A team with eleven tags, a secret rotation policy and an on-call engineer covering nights needs a programmatic path instead. The Campaign Management operation GetUetTagAuthKey now returns the token for a given UET tag programmatically, which is the change that makes the whole integration operable.
The two documents differ at this point. The integration guide shows the call with a request body of {"TagId": 123456789} and a nested response of the form {"TagAuthKey": {"AuthKey": "..."}}. The reference page for GetUetTagAuthKey defines something different: the request element is UetTagId, and the response element is UetTagAuthKey as a flat string.
Only one of the two descriptions applies to a given account, and the resulting failure is easy to misread. A developer who follows the guide writes a parser that reaches for TagAuthKey["AuthKey"]. Against a response shaped the way the reference describes, that parser raises an exception — on a call that in truth succeeded. The stack trace points at the JSON handling, the symptom reads as “no key came back”, and the search then often moves on to account ids, customer ids and API permissions. The call itself was successful; the response shape simply differed from the one in the guide.
A client that accepts both shapes is covered against either state of the documentation. An extractor that reads both forms, together with a log entry recording which one actually arrived, makes visible which document is currently true for the account in question.
import logging
log = logging.getLogger(__name__)
def extract_auth_key(response):
"""Accept both documented shapes of the GetUetTagAuthKey response.
Guide : {"TagAuthKey": {"AuthKey": "..."}} (nested)
Reference : {"UetTagAuthKey": "..."} (flat string)
"""
# Flat form, as defined by the operation reference.
flat = response.get("UetTagAuthKey")
if isinstance(flat, str) and flat:
log.info("auth key: flat shape (reference)")
return flat
# Nested form, as shown in the integration guide.
nested = response.get("TagAuthKey")
if isinstance(nested, dict):
key = nested.get("AuthKey")
if isinstance(key, str) and key:
log.info("auth key: nested shape (guide)")
return key
# Some wrappers hand back an object, not a dict.
for attr in ("UetTagAuthKey", "TagAuthKey"):
value = getattr(response, attr, None)
if isinstance(value, str) and value:
log.info("auth key: attribute %s on response object", attr)
return value
raise ValueError(
"GetUetTagAuthKey returned neither the flat nor the nested "
"shape; raw response logged for inspection"
)
The request side allows the same treatment. Where a SOAP or REST wrapper permits the request element to be named, UetTagId as the reference defines it is the value to send, with a code comment noting that the guide calls it TagId. A call rejected outright for an unknown element yields a concrete result within thirty seconds rather than an assumption.
4. Deduplication: how the UET tag and the server event find each other
Once conversions are sent from the server, most of them are also sent from the browser. That is not a condition to be engineered away — both paths are intentional, because each covers cases the other misses. What both paths must avoid is one purchase counted twice. The mechanism for that is eventId: it is the field over which the browser-side UET hit and the server-side hit are deduplicated.
The rule that makes this work concerns the application itself, not the API. The identifier is generated once, by the system that owns the event, and handed to both transports unchanged. In practice the backend mints the id when the order is created, renders it into the page so the UET tag can send it, and stores it alongside the order so the server client can send the same value later.
The failure modes are all variations on generating the id twice. A random value created in JavaScript at page load and a second random value created in the batch job never match. A value derived from a session id does not match when the conversion happens in a different session. A value derived from the order id is stable, unique, and survives retries — which is the second reason deduplication matters. When a batch job re-sends a chunk after a timeout, the identical eventId is what keeps that from becoming a second conversion.
- Generate once:
eventIdis derived from something the order already owns, so the same event always produces the same id. - Pass through, never regenerate: The template and the batch job both read the stored value; neither computes its own.
- Keep it stable across retries: A retried batch carries the identical ids; otherwise the retry produces a double count.
- Store it: The id is persisted with the order record, because it is required for reconciliation when the figures diverge.
5. Normalisation, hashing and consent
User identifiers are not sent in the clear. The documented procedure is two steps in a fixed order: normalise to lowercase first, then hash with SHA-256. The order is not negotiable, because hashing is not case-insensitive. Anna@example.com and anna@example.com produce completely unrelated digests, and a mismatch here does not raise an error — it silently produces a hash that never matches anything on the other side. The pipeline reports success while the match rate stays near zero, which is why this step is worth verifying explicitly.
import hashlib
def hash_identifier(value):
"""Lowercase first, then SHA-256, as the documentation specifies.
Trimming surrounding whitespace is defensive hygiene against the
storage layer; the documented steps are lowercase + SHA-256.
"""
if value is None:
return None
normalised = str(value).strip().lower()
if not normalised:
return None
return hashlib.sha256(normalised.encode("utf-8")).hexdigest()
assert hash_identifier("Anna@Example.COM") == hash_identifier("anna@example.com")
Any further per-field cleanup — the canonical form of phone numbers, the handling of plus-addressing in email, the treatment of names with diacritics — is a matter of local decision and local risk. The sources define lowercase and SHA-256. They do not define a country-specific phone format, so this article does not supply one either. A single rule, applied identically on the browser side and the server side and written down, is what the match rate rests on.
Consent travels with the event in the adStorageConsent field, which takes the value "G" for granted and "D" for denied. Two design points follow. First, the value is the consent state as it was at the moment of the conversion, not the state at the moment the batch job runs — which means the order record persists it rather than looking it up later. Second, unknown is treated as denied: where the consent store has no answer for a given order, "D" is the value sent. Defaulting an absent record to granted is not visible in a dashboard and is relevant in an audit.
6. The client in production: batches, the seven-day window, resumption
Three constraints shape the client: 1000 events per request, eventTime within the last seven days, and no documented guidance on rate limiting or error codes. The first two are hard limits to design around. The third means the client is built to learn from what it observes rather than from what it assumed.
The seven-day window is the constraint most often underestimated. It sets an upper bound on how long the pipeline may be broken before data is permanently lost. A job that fails silently on a Friday and is noticed the following Monday week has already dropped events past recovery. It also caps the initial backfill: at launch, the last seven days of history can be sent and nothing more. The launch plan follows from that — there is no catch-up run for month three.
from datetime import datetime, timedelta, timezone
MAX_BATCH = 1000
WINDOW = timedelta(days=7)
# Small safety margin so an event does not expire mid-flight during
# a retry. Tune this against the latency observed in production.
MARGIN = timedelta(hours=1)
def within_window(event_time, now=None):
now = now or datetime.now(timezone.utc)
return (now - event_time) < (WINDOW - MARGIN)
def batches(events):
"""Yield API-sized chunks, dropping anything past the window."""
chunk = []
for event in events:
if not within_window(event.event_time):
# Count these. A rising number means the pipeline is late.
yield_expired(event)
continue
chunk.append(event)
if len(chunk) >= MAX_BATCH:
yield chunk
chunk = []
if chunk:
yield chunk
Resumption is built on the operator’s own database rather than on any cursor the API might offer. A send-state column on the order record — pending, sent, expired, failed — is read and written back by the job. A crash mid-run then costs nothing: the next run picks up exactly the rows that were never confirmed, and because eventId is stable, re-sending a row whose confirmation was lost in transit is harmless.
- Pending events are selected ordered by
eventTime, oldest first, so the ones closest to expiry leave first. - Anything already outside the window is dropped and counted separately instead of being rejected by the API.
- Chunks of at most 1000 events are posted, and a chunk is marked sent only after a confirmed success.
- On failure, the client backs off, logs the actual response verbatim, and leaves the rows pending for the next run.
- An alert fires when the count of expired events crosses a defined threshold — this signal indicates data loss in the pipeline.
On retries: because the documentation available here does not enumerate error codes or rate limits, assumptions about which failures are retryable are not hard-coded. Exponential backoff on transport errors and on any non-success response is the starting point, with the full body logged and the policy refined once a week of real responses from the account in question is available. A client that hard-codes “retry on 429” for an API whose rate-limit behaviour has not been observed rests on an assumption rather than a measurement.
7. Monitoring: which metrics indicate a problem in the pipeline
A CAPI integration fails quietly. No user reports a broken checkout; there is only a slow drift in numbers that someone notices six weeks later. Monitoring is therefore deliberate, and most of it lives in the operator’s own database rather than in a vendor dashboard.
-- Daily health of the send pipeline. Table and column names are
-- placeholders for the local order / event store.
SELECT
date_trunc('day', event_time) AS day,
count(*) AS events_total,
count(*) FILTER (WHERE send_state = 'sent') AS sent,
count(*) FILTER (WHERE send_state = 'pending') AS still_pending,
count(*) FILTER (WHERE send_state = 'expired') AS lost_to_window,
count(*) FILTER (WHERE ad_storage_consent = 'D') AS consent_denied,
count(*) FILTER (WHERE hashed_email IS NULL) AS no_identifier,
round(avg(EXTRACT(EPOCH FROM (sent_at - event_time)) / 60.0), 1)
AS avg_lag_minutes
FROM conversion_events
WHERE event_time >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1 DESC;
Five signals are worth alerting on. Expired events above zero means the pipeline is running late and data is being lost outright; this is the one that should page someone. Send lag is the leading indicator for the same condition — the average distance between eventTime and the moment of sending, with an alert well before it approaches seven days. Identifier coverage, the share of events that carry a hashed identifier at all, drops the day a checkout form or a consent default changes; a sudden step change there indicates a code change, not a market shift. Consent mix, the ratio of "G" to "D", moves slowly under normal conditions; a jump indicates a change in the consent tool. Batch success rate shows whether the endpoint is accepting what is sent, and it belongs on the same dashboard as the rest even though it is the most obvious one.
Reconciliation against the platform side is the last piece, and it is where click-level data becomes relevant. Comparing the sent count against reported conversions shows whether deduplication is working: if the platform figure sits meaningfully above the union of browser and server events, the eventId is not matching. The MSClickIdPerformanceReport is the natural tool for that comparison, since it can be joined to an in-house msclkid store on the click id, and ConversionsQualified provides a numeric column for the comparison. Its own page states twice that not everyone has this feature yet. A reconciliation built to degrade gracefully to the aggregate comparison covers the case where the report is not available to the account.
Conclusion
What is built here consists of a token fetcher, a normaliser, a batcher and a state column. The endpoint takes one path per tag, a bearer token, and up to a thousand events at a time. The parts that require the most attention are not API-shaped — they are the decision about where eventId is born, the persistence of the consent state at the moment it applies, and the guarantee that lowercase happens before SHA-256 every single time.
What the integration delivers are the conversions the browser never reported, and a bidding signal that reflects the order database rather than the subset of it that survived the trip through a browser. The seven-day window is what makes this a scheduling matter rather than a backlog item: unsent history older than a week is gone, so the value of the integration is bounded by how soon it starts running.
What stays open is the documentation itself. As of 10 August 2026 the integration guide and the GetUetTagAuthKey reference describe different request elements and different response shapes for the same operation, and both pages were updated within the last week. A tolerant parser, a log entry recording which shape arrived, and a re-check of both pages before the next release cover that state; the pages are moving. The primary sources are the UET Conversion API integration guide, the GetUetTagAuthKey operation reference, and the Microsoft Advertising release notes for the May 2026 changes. Anything not in those pages — error codes, rate limits, retry semantics — has to be measured in the respective environment, and this article has deliberately left those blanks empty rather than filling them with a plausible guess.