> ## Documentation Index
> Fetch the complete documentation index at: https://docs.interchange.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Signing the webhooks you send us

> How to sign an async task callback so Interchange accepts it: the X-ADCP-Signature contract for connected sales agents and inventory sources.

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](/v2/buyer/webhooks) instead. That is a **different and
incompatible** scheme. See [Which scheme am I implementing?](#which-scheme-am-i-implementing) below.

<Warning>
  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.
</Warning>

## 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.

```json theme={null}
{
  "push_notification_config": {
    "url": "https://api.interchange.io/adcp/source-webhook/17/2009/update_media_buy/17/op_abc123",
    "authentication": {
      "schemes": ["HMAC-SHA256"],
      "credentials": "9f8c2a...e41b"
    }
  }
}
```

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.

<Warning>
  **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.
</Warning>

## The signature

Send two headers:

| Header             | Value                                               |
| ------------------ | --------------------------------------------------- |
| `X-ADCP-Timestamp` | Current Unix time in **seconds**                    |
| `X-ADCP-Signature` | `sha256=` followed by the lowercase hex HMAC-SHA256 |

The signed message is the timestamp, a literal `.`, then the raw request body:

```
HMAC-SHA256(credentials, "{timestamp}.{rawBody}")
```

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](/v2/buyer/webhooks) 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

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto'

  async function sendWebhook(url, credentials, payload) {
    // Serialize once. These are the bytes we sign AND the bytes we send.
    const rawBody = JSON.stringify(payload)
    const timestamp = Math.floor(Date.now() / 1000).toString()

    const digest = crypto
      .createHmac('sha256', credentials)
      .update(`${timestamp}.${rawBody}`, 'utf8')
      .digest('hex')

    return fetch(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'x-adcp-timestamp': timestamp,
        'x-adcp-signature': `sha256=${digest}`,
      },
      body: rawBody,
    })
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import time

  import requests


  def send_webhook(url: str, credentials: str, payload: dict):
      # Serialize once. These are the bytes we sign AND the bytes we send.
      raw_body = json.dumps(payload)
      timestamp = str(int(time.time()))

      digest = hmac.new(
          credentials.encode("utf-8"),
          f"{timestamp}.{raw_body}".encode("utf-8"),
          hashlib.sha256,
      ).hexdigest()

      return requests.post(
          url,
          data=raw_body.encode("utf-8"),
          headers={
              "content-type": "application/json",
              "x-adcp-timestamp": timestamp,
              "x-adcp-signature": f"sha256={digest}",
          },
      )
  ```

  ```go Go theme={null}
  package webhook

  import (
  	"bytes"
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"encoding/json"
  	"fmt"
  	"net/http"
  	"strconv"
  	"time"
  )

  func SendWebhook(url, credentials string, payload any) (*http.Response, error) {
  	// Marshal once. These are the bytes we sign AND the bytes we send.
  	rawBody, err := json.Marshal(payload)
  	if err != nil {
  		return nil, err
  	}
  	timestamp := strconv.FormatInt(time.Now().Unix(), 10)

  	mac := hmac.New(sha256.New, []byte(credentials))
  	fmt.Fprintf(mac, "%s.%s", timestamp, rawBody)
  	digest := hex.EncodeToString(mac.Sum(nil))

  	req, err := http.NewRequest("POST", url, bytes.NewReader(rawBody))
  	if err != nil {
  		return nil, err
  	}
  	req.Header.Set("content-type", "application/json")
  	req.Header.Set("x-adcp-timestamp", timestamp)
  	req.Header.Set("x-adcp-signature", "sha256="+digest)

  	return http.DefaultClient.Do(req)
  }
  ```
</CodeGroup>

If you build on the official [`@adcp/sdk`](https://docs.adcontextprotocol.org) 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](https://www.rfc-editor.org/rfc/rfc9421.html) 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](/v2/storefront/inventory-sources/connect-your-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.

<Note>
  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.
</Note>

## Which scheme am I implementing?

Two different signing schemes exist on the platform, and they are not interchangeable. Pick by
direction of travel:

|                      | You send to us                             | We send to you                         |
| -------------------- | ------------------------------------------ | -------------------------------------- |
| **Scheme**           | This page                                  | [Buyer webhooks](/v2/buyer/webhooks)   |
| **Header**           | `X-ADCP-Signature`                         | `X-Webhook-Signature`                  |
| **Value**            | `sha256=<hex>`                             | bare `<hex>`, no prefix                |
| **Signed message**   | `{timestamp}.{rawBody}`                    | `rawBody` only                         |
| **Timestamp header** | Required (`X-ADCP-Timestamp`, seconds)     | None                                   |
| **Key**              | `authentication.credentials` from our call | The `secret` you chose at registration |
| **Who you are**      | A seller or inventory source we call       | A buyer subscribing to our events      |

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:

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Signature header format">
    The value must be `sha256=` plus lowercase hex. Base64, uppercase hex, or a bare digest with no
    prefix are all rejected.
  </Accordion>
</AccordionGroup>

## 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.
