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

# Signing the webhooks you send us

> How a sales agent or inventory source signs the callbacks it sends Apostra: HMAC, RFC 9421 with brand.json keys, and AdCP 3.3 Web Bot Auth.

When Apostra calls your sales agent or inventory source with an async task, such as
`create_media_buy`, `update_media_buy`, `sync_creatives` or `media_buy_delivery`, the call includes
a `push_notification_config`. It says where to push the result and how to sign it. **We reject an
unsigned or wrongly signed callback with `401`, and the task is never resolved.** A refused delivery
changes nothing on our side.

This page covers callbacks you send to Apostra. To verify the events Apostra sends to your own
endpoints, see [Webhook endpoints](/v3/webhooks); that is a different scheme.

## Choose a profile

You can sign with one of three profiles, strongest first:

| Profile | Your public keys are found through | Use it when |
| - | - | - |
| AdCP 3.3 Web Bot Auth | A key directory on your agent's origin, named in `Signature-Agent` | Your agent signs AdCP 3.3 Web Bot Auth |
| RFC 9421 with `brand.json` keys | The `jwks_uri` in your `brand.json` | Your agent signs the AdCP 3.2 webhook profile |
| HMAC-SHA256 | The `authentication.credentials` value in our call | Neither of the above |

HMAC is deprecated in AdCP and removed in 4.0. AdCP 3.3 deprecates `brand.json` key discovery, and
4.0 removes it, so new agents should sign with Web Bot Auth.

> [!WARNING]
>
> **We do not step down from Web Bot Auth.** After we accept one delivery signed with Web Bot Auth from
> your origin, we refuse every RFC 9421 `brand.json` delivery and every HMAC delivery from that origin
> with `401`, whatever its signature. We do not fall back to them if a Web Bot Auth delivery fails.
> Roll your signing keys through your key directory instead. Moving back to an older profile is not a
> setting you can change; contact us if you need it.

## AdCP 3.3 Web Bot Auth

With [Web Bot
Auth](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-http-message-signatures-directory/),
each delivery names its signer in the `Signature-Agent` header, and we fetch your public keys from
that origin's key directory. We accept it on:

* `sync_creatives` callbacks registered for RFC 9421 (see Creative-sync callbacks below);
* storefront inventory-source callbacks;
* direct `media_buy_delivery` reports to a sales agent's callback URL.

Storefront catalog and change-feed callbacks accept the `brand.json` profile only for now.

To sign with Web Bot Auth:

1. Publish a signed key directory at `/.well-known/http-message-signatures-directory` on the origin
   of your agent endpoint, the endpoint we have on file for your agent or source.
2. On each delivery, send `Signature-Agent` with your agent's origin, plus `Signature-Input` (tagged
   `web-bot-auth`) and `Signature`.
3. Cover `@method`, `@target-uri`, `@authority` and `signature-agent`, and, when the callback has a
   body, `content-type` and `content-digest`.
4. Use a fresh `nonce` on every delivery attempt, including a retry, and keep `expires` within 300
   seconds of `created`.
5. Optionally publish an origin revocation list at `/.well-known/governance-revocations.json`. We
   refuse a key on that list.

We accept a delivery only for the agent the callback URL is bound to:

* **Origin.** The origin in `Signature-Agent` must be the origin of the endpoint we registered for
  that agent or source. A signature from another agent's directory, or from a different origin of
  your operator, gets `401`, however valid it is.
* **URL.** The `@target-uri` you sign must be the callback URL exactly as we sent it in
  `push_notification_config.url`, query string included. A signature made for one operation fails
  on any other.
* **Replay.** A nonce we have already accepted gets `401`. We record a nonce only after every check
  that could answer `503` has passed, so redelivering after a `503` is always safe, provided the
  retry is signed afresh.

## RFC 9421 with brand.json keys

To sign the AdCP 3.2 webhook profile with [RFC 9421 HTTP Message
Signatures](https://www.rfc-editor.org/rfc/rfc9421.html), you need:

* An https agent endpoint that answers `get_adcp_capabilities` without credentials, over the protocol
  you registered with us (MCP or A2A). Your capabilities tell us where your `brand.json` lives.
* A `brand.json` whose `agents[]` entry for your endpoint has the same URL as the endpoint we call and
  declares a `jwks_uri`.
* `Signature` and `Signature-Input` headers on each delivery. Always send both: a `Signature` with no
  `Signature-Input` is refused rather than checked as HMAC.

We find your `brand.json` like this:

1. If your capabilities declare `identity.brand_json_url`, we fetch that https URL. When it is on a
   different registrable domain from your agent, it must list your agent's domain in
   `authorized_operators[]`.
2. An AdCP 3.x agent that declares no `identity.brand_json_url` is looked up at
   `/.well-known/brand.json` on its origin, then on its registrable domain if that returns `404` or
   `410`. We follow at most one `authoritative_location` or `house` redirect.
3. An AdCP 4.0 agent must declare `identity.brand_json_url`.

When a delivery carries both RFC 9421 and HMAC headers, we check RFC 9421 and ignore the HMAC headers.

## HMAC-SHA256

Sign with the `authentication.credentials` value from the `push_notification_config` on the call you
are answering. It is unique to that callback URL: it is not your Apostra API key, and a sales agent
that is also an inventory source receives a different value on each.

Send two headers:

| Header | Value |
| - | - |
| `X-ADCP-Timestamp` | The current Unix time in seconds |
| `X-ADCP-Signature` | `sha256=` followed by the lowercase hex of `HMAC-SHA256(credentials, "{timestamp}.{rawBody}")` |

Serialize the body once and sign the exact bytes you send. We allow 300 seconds of clock skew.

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

async function sendWebhook(url, credentials, payload) {
  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,
  })
}
```

The [`@adcp/sdk`](https://docs.adcontextprotocol.org) webhook emitter produces this signature when
the `push_notification_config` declares `HMAC-SHA256`.

## Creative-sync callbacks

We choose the scheme for each `sync_creatives` call from the capabilities we have stored for your
agent, and it cannot change once we send the call:

| Your stored `get_adcp_capabilities` | The call carries | Sign the callback with |
| - | - | - |
| `webhook_signing.supported: true` **and** `identity.brand_json_url`, each valid under AdCP 3.2 | No `authentication` block; the `url` ends in `?source=<id>` | Web Bot Auth, or RFC 9421 with your `brand.json` keys |
| Anything else, or no capabilities stored | `authentication.credentials` | That HMAC credential |

* An HMAC delivery to a URL we registered without a credential gets `401`.
* An RFC 9421 or Web Bot Auth delivery for a call that carried a credential gets `401`.
* To go back to HMAC, set `webhook_signing.supported` to `false` or remove `identity.brand_json_url`,
  unless your origin has already signed with Web Bot Auth. Calls already sent keep their scheme.

## Responses

| Response | Meaning | What to do |
| - | - | - |
| `2xx` | Accepted | Nothing |
| `401` | The signature, key, signer origin, URL or nonce did not pass. The body never says which | Fix the signing. Contact us with your agent or source ID and the delivery times, and we can tell you which check failed |
| `409` | A creative verdict that contradicts the one we already recorded for that creative | Don't retry |
| `503` | A dependency we need could not be reached: your key directory, `brand.json`, JWKS or revocation list, or one of ours | Redeliver, signed afresh |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.