create_media_buy, update_media_buy,
get_products, media_buy_delivery), we include a push_notification_config telling you where to
push the result and which key to sign it with. An unsigned or wrongly-signed callback is rejected
with 401 and the task is never resolved. The one exception to “which key” is a creative sync to
an agent that signs with RFC 9421: see Creative-sync callbacks.
Wholesale get_products catalog reads are synchronous and do not include a
push_notification_config. Curated brief or refine discovery may use the
asynchronous callback contract described here.
This page is the contract for that signature. If you are a buyer subscribing to our outbound
events, you want Buyer webhooks instead. That is a different and
incompatible scheme. See Which scheme am I implementing? below.
Where the key comes from
The key is theauthentication.credentials value inside the push_notification_config we send on
async calls. Read it from the request you are answering. A push_notification_config with no
authentication block means we are asking you to sign with RFC 9421 instead; we do that only for
creative syncs to agents that declare it (see Creative-sync callbacks).
- Not your Apostra API key.
- Not a secret exchanged during onboarding.
- Not shared across your integrations.
The signature
Send two headers:
The signed message is the timestamp, a literal
., then the raw request body:
sha256=is part of the header value.X-ADCP-Signature: sha256=a1b2c3..., not the bare hex.- The timestamp is in seconds, not milliseconds.
Date.now()in JavaScript andSystem.currentTimeMillis()in Java both return milliseconds; divide by 1000 and floor. - The timestamp is part of the signed message. Signing the body alone is the scheme our buyer webhook docs describe, and it will not verify here.
- Sign the exact bytes you send. Serialize the body once, sign those bytes, send those bytes. Do not re-serialize between signing and sending: a different key order or whitespace changes the digest.
Reference implementation
@adcp/sdk webhook emitter, it
produces this construction for you when the push_notification_config declares HMAC-SHA256. Pass
the credentials value through unchanged and you do not need to implement the above.
RFC 9421 message signatures
HMAC-SHA256 is deprecated in the AdCP spec and scheduled for removal in AdCP 4.0. If your agent publishes a JWKS, prefer RFC 9421 HTTP Message Signatures instead: it is asymmetric, so no shared secret ever leaves your side, and keys rotate through your JWKS without a credential exchange. To sign with RFC 9421 you need:- An https agent endpoint that answers
get_adcp_capabilitieswithout credentials, over the protocol you registered with us (MCP or A2A). We read your capabilities first, because they tell us where yourbrand.jsonlives. See Connect your sales agent. - A
brand.jsonwhoseagents[]entry for your endpoint has the same URL as the endpoint we call and declares ajwks_uri. This is how we resolve your public keys. SignatureandSignature-Inputheaders on each delivery.
brand.json like this:
- If your capabilities declare
identity.brand_json_url, we fetch that URL. It must be https. When it is on a different registrable domain from your agent, thatbrand.jsonmust list your agent’s domain inauthorized_operators[]. - If you are an AdCP 3.x agent and declare no
identity.brand_json_url, we fetch/.well-known/brand.jsonon your agent’s origin. When that returns 404 or 410, we try your registrable domain (for exampleexample.comforagent.example.com). We follow at most oneauthoritative_locationorhouseredirect from there. - An AdCP 4.0 agent must declare
identity.brand_json_url. The well-known fallback does not apply.
brand.json (a timeout, a connection failure, or an HTTP
5xx, 408 or 429), we respond 503 so you redeliver. Any other HTTP 4xx, such as a 404,
means the document isn’t available to us, so we respond 401: fix it rather than redeliver. A JWKS
fetch that fails gets 503. If they all load but do not establish a trusted key (for example the
agents[] entry is missing, the domains do not match, or the JWKS location is not trusted), we
respond 401.
When both RFC 9421 headers are present we verify RFC 9421 and ignore the HMAC headers entirely,
including their timestamp, so a sender cannot be downgraded to the weaker scheme.
Send one scheme or the other, and always send Signature and Signature-Input together. A lone
Signature with no Signature-Input is rejected rather than falling back to HMAC, so a stripped
header cannot downgrade you to the weaker scheme.
RFC 9421 verification is available on every inbound callback: sales-agent, storefront-catalog, and
storefront inventory-source. You can move a source onto it by publishing a jwks_uri in your
brand.json and signing with Signature / Signature-Input. No configuration change is needed on
our side.
Creative-sync callbacks
Creative-sync callbacks (sync_creatives) are registered one agent at a time, and the scheme is
fixed by what we send you. AdCP works the same way: a push_notification_config that carries an
authentication block asks you to sign with that legacy scheme, and one without it asks you to
sign with the RFC 9421 webhook profile.
For an agent that signs with RFC 9421, the
url in push_notification_config ends in
?source=<id>, which names your source. Sign that URL exactly as you received it, query string
included. We accept the callback only when the signature verifies against the key your brand.json
publishes, and only for the one buyer operation we sent you. A key in your brand.json proves who
you are; the operation and the ?source= on the URL are what bind it to the work you were sent.
The scheme can’t change after we send the call, in either direction:
- An HMAC-signed delivery to a URL registered without a credential is refused with
401, even with the right key. We never gave you a credential for it. - An RFC 9421 delivery for a call that carried a credential is refused with
401.
brand.json keys, or a replayed one, gets 401. A brand.json or JWKS we cannot reach gets 503
so you redeliver. The creative stays pending until a callback verifies, so fix the key (or publish
the brand.json again) and redeliver. To go back to HMAC, set webhook_signing.supported to
false or remove identity.brand_json_url; the next creative sync after we read your capabilities
again carries a credential. Syncs already in flight keep the scheme they were sent with.
The
@target-uri you sign must be the callback URL exactly as we registered it in
push_notification_config.url. We verify against that URL rather than the inbound Host header, so
signing a rewritten host will fail even though the request reaches us.Which scheme am I implementing?
Two different signing schemes exist on the platform, and they are not interchangeable. Pick by direction of travel:
Implementing the buyer scheme on this rail produces a well-formed request that fails verification
every time, because the digest covers the body without the timestamp prefix.
Troubleshooting a rejected webhook
A rejection returns401 with a deliberately generic message: the response never says which check
failed, so it cannot be used to probe the verifier. The owner of the agent can read the specific
reason for each refusal: see Reading your agent’s webhook results.
If you are still stuck, contact us with your agent or source id and the approximate timestamps of
the failed deliveries.
Work through these first, in order of how often they are the cause:
Every delivery is rejected, and always has been
Every delivery is rejected, and always has been
Almost always the wrong key. Confirm you are signing with
authentication.credentials from the
push_notification_config on the call you are answering, not an API key, an onboarding secret, or a
credential stored from a different registration. If you are registered with us on more than one rail,
see the per-callback-URL warning above.Deliveries were working and suddenly stopped
Deliveries were working and suddenly stopped
Check clock drift on the sending host. Outside the 300-second window every delivery fails regardless
of the digest. Also check whether the timestamp became milliseconds after a refactor.
The digest looks right but still fails
The digest looks right but still fails
Something re-serialized the body between signing and sending. Middleware that reformats JSON, adds a
trailing newline, or re-encodes a
Buffer will change the bytes. Sign the exact string you write to
the socket.Signature header format
Signature header format
The value must be
sha256= plus lowercase hex. Base64, uppercase hex, or a bare digest with no
prefix are all rejected.Verifying the webhooks we send you
The same{timestamp}.{rawBody} construction applies in reverse for callbacks Apostra pushes to
your agent. Recompute the digest over the raw bytes you received, compare it in constant time (use
crypto.timingSafeEqual, hmac.compare_digest, or hmac.Equal), and reject anything whose
timestamp is outside your own skew window.