Skip to main content
When Interchange calls your sales agent with an async task (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.
On the update_media_buy leg the webhook is the only way the change is confirmed. Unlike create_media_buy (which we can fall back to polling with tasks_get), a rejected update webhook leaves the buyer’s change permanently pending. Verify your signing against this page before you go live.

Where the key comes from

The key is the authentication.credentials value inside the push_notification_config we send on every async call. Read it from the request you are answering.
It is not any of these, and using one of them is the most common cause of a 100% rejection rate:
  • Not your Interchange API key.
  • Not a secret exchanged during onboarding.
  • Not shared across your integrations.
The key is per-callback-URL, not per-company. If you are registered with us both as a sales agent and as a storefront inventory source, those are two separate registrations that receive two different credentials values, even when they point at the same endpoint URL on your side. Signing both with whichever key you stored first makes one of the two rails reject every delivery. Always sign with the credentials from the call you are responding to rather than a stored global.

The signature

Send two headers: The signed message is the timestamp, a literal ., then the raw request body:
Four details reject a callback that is otherwise correct:
  1. sha256= is part of the header value. X-ADCP-Signature: sha256=a1b2c3..., not the bare hex.
  2. The timestamp is in seconds, not milliseconds. Date.now() in JavaScript and System.currentTimeMillis() in Java both return milliseconds; divide by 1000 and floor.
  3. 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.
  4. 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.
We allow 300 seconds of clock skew in either direction. Stamp the timestamp at send time, not when you queued the job, and keep your clock in NTP sync.

Reference implementation

If you build on the official @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.json at /.well-known/brand.json on your agent’s origin, whose agents[] entry for your endpoint declares a jwks_uri. This is how we resolve your public keys. See Connect your sales agent.
  • Signature and Signature-Input headers on each delivery.
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 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 returns 401 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:
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.
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.
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.
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.