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.
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
every async call. Read it from the request you are answering.
- Not your Interchange 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:- A
brand.jsonat/.well-known/brand.jsonon your agent’s origin, whoseagents[]entry for your endpoint declares ajwks_uri. This is how we resolve your public keys. See Connect your sales agent. SignatureandSignature-Inputheaders on each delivery.
Signature and Signature-Input together. A lone
Signature with no Signature-Input is not a valid RFC 9421 request and will not be treated as one.
RFC 9421 verification is available on the sales-agent and storefront-catalog callbacks today. Support
on the storefront inventory-source callback (
/adcp/source-webhook/*) is rolling out; until it
lands, sign those deliveries with HMAC-SHA256 as described above.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. We do log the specific cause on our side, so if
you are stuck, contact us with your agent or source id and the approximate timestamps of the failed
deliveries and we can tell you exactly which check rejected them.
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 Interchange 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.