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

# Send events with an AO Webhook

> Push events into AO from any app or service with a signed HTTPS request: endpoint, request signing, the ao.webhook.v1 envelope, limits, and error codes.

An **AO Webhook** gives your organization its own signed ingest endpoint.
Any system that can make an HTTPS request — a deploy script, a CI job, an
internal service — can send events to AO through it. No client library is
required: a request is a JSON body plus two headers.

You can create as many AO Webhooks as you like, for example one per source
system or environment. Each has its own install ID, secret, and
deduplication namespace.

## Create a webhook

In AO Cloud, open **Integrations**, choose **AO Webhook**, and create it.
AO shows three values:

| Value | Example | Use |
| - | - | - |
| Install ID | `wh_…` | Identifies the webhook. Not a secret. |
| Endpoint URL | `https://ingest.automatedoperations.com/integrations/webhooks/<install_id>` | Where you `POST` events. |
| Signing secret | `whsec_…` | Signs every request. Store it like a password. |

You can reveal or rotate the secret later from the installed integrations
list. Rotating takes effect immediately: requests signed with the previous
secret are rejected, so update your sender at the same time.

## Sign each request

Every request carries a timestamp and a signature. The secret itself is
never sent.

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-AO-Timestamp` | Current time in Unix seconds, e.g. `1747922580` |
| `X-AO-Signature-256` | `sha256=` followed by the lowercase hex HMAC-SHA256 digest described below |

To compute the signature:

1. Build the string to sign: the `X-AO-Timestamp` value, a literal `.`,
   then the **exact bytes of the request body**.
2. Compute HMAC-SHA256 over that string, keyed with the full signing
   secret as-is, including its `whsec_` prefix. Do not decode or trim it.
3. Hex-encode the digest in lowercase and prefix it with `sha256=`.

AO verifies the signature against the raw body it receives. Sign the same
bytes you send: serialize the JSON once, then sign and send that string.
Re-serializing after signing changes the bytes and the signature will not
match.

AO rejects a request whose timestamp is more than 5 minutes from its own
clock, in either direction. Keep your sender's clock in sync and sign each
request, including retries, with a fresh timestamp.

### Check your implementation

Your signing code should reproduce this signature exactly:

| Input | Value |
| - | - |
| Secret | `whsec_test_c2luZ2xlZXZlbnRzZWNyZXQ` |
| Timestamp | `1747922580` |
| Body | `{"schema":"ao.webhook.v1","records":[{"type":"event","source_event_id":"evt-2026-0001","kind":"deploy.completed","opened_at":"2026-05-22T14:03:00Z"}]}` |
| `X-AO-Signature-256` | `sha256=10df80c8550f1d7b5564aa75e85296595630dc341c6ffa00b1bd5653ba51aef7` |

## Examples

Each example sends one event. Set `AO_WEBHOOK_URL` to your endpoint URL
and `AO_WEBHOOK_SECRET` to your signing secret. The Node.js example is an
ES module: save it as a `.mjs` file.

<CodeGroup>
  ```bash curl theme={null}
  BODY='{"schema":"ao.webhook.v1","records":[{"type":"event","source_event_id":"deploy-api-1.8.3","kind":"acme:deployment","opened_at":"2026-05-22T14:03:00Z","title":"Deployed api 1.8.3 to production","tags":{"env":"production","service":"api"}}]}'

  TIMESTAMP=$(date +%s)
  SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \
    | openssl dgst -sha256 -hmac "$AO_WEBHOOK_SECRET" -hex \
    | sed 's/^.* //')

  curl -sS -X POST "$AO_WEBHOOK_URL" \
    -H "Content-Type: application/json" \
    -H "X-AO-Timestamp: $TIMESTAMP" \
    -H "X-AO-Signature-256: sha256=$SIGNATURE" \
    --data-raw "$BODY"
  ```

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

  const body = JSON.stringify({
    schema: 'ao.webhook.v1',
    records: [
      {
        type: 'event',
        source_event_id: 'deploy-api-1.8.3',
        kind: 'acme:deployment',
        opened_at: new Date().toISOString(),
        title: 'Deployed api 1.8.3 to production',
        tags: { env: 'production', service: 'api' },
      },
    ],
  });

  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = createHmac('sha256', process.env.AO_WEBHOOK_SECRET)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  const res = await fetch(process.env.AO_WEBHOOK_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-AO-Timestamp': timestamp,
      'X-AO-Signature-256': `sha256=${signature}`,
    },
    body,
  });
  console.log(res.status, await res.json());
  ```

  ```python Python theme={null}
  import hashlib, hmac, json, os, time, urllib.error, urllib.request
  from datetime import datetime, timezone

  body = json.dumps({
      "schema": "ao.webhook.v1",
      "records": [{
          "type": "event",
          "source_event_id": "deploy-api-1.8.3",
          "kind": "acme:deployment",
          "opened_at": datetime.now(timezone.utc).isoformat(),
          "title": "Deployed api 1.8.3 to production",
          "tags": {"env": "production", "service": "api"},
      }],
  }).encode()

  timestamp = str(int(time.time()))
  signature = hmac.new(
      os.environ["AO_WEBHOOK_SECRET"].encode(),
      timestamp.encode() + b"." + body,
      hashlib.sha256,
  ).hexdigest()

  req = urllib.request.Request(
      os.environ["AO_WEBHOOK_URL"],
      data=body,
      method="POST",
      headers={
          "Content-Type": "application/json",
          "X-AO-Timestamp": timestamp,
          "X-AO-Signature-256": f"sha256={signature}",
      },
  )
  try:
      with urllib.request.urlopen(req) as res:
          print(res.status, json.load(res))
  except urllib.error.HTTPError as err:
      print(err.code, json.load(err))
  ```
</CodeGroup>

## The request body

The body is an `ao.webhook.v1` envelope holding 1 to 500 records:

```json theme={null}
{
  "schema": "ao.webhook.v1",
  "records": [
    {
      "type": "event",
      "source_event_id": "incident-4821",
      "kind": "acme:incident",
      "opened_at": "2026-05-22T09:11:00Z",
      "closed_at": "2026-05-22T10:02:30Z",
      "observed_at": "2026-05-22T10:02:31Z",
      "title": "Checkout latency above SLO",
      "affected_entities": [
        { "kind": "service", "identity_tags": { "name": "checkout" } }
      ],
      "tags": { "severity": "high", "team": "payments" },
      "payload": { "incident_id": "4821", "state": "resolved" }
    }
  ]
}
```

`schema` must be exactly `ao.webhook.v1`. The only record type accepted
today is `event`:

| Field | Required | Description |
| - | - | - |
| `type` | yes | `"event"` |
| `source_event_id` | yes | Your stable ID for this event. See [Updates and duplicates](#updates-and-duplicates). |
| `kind` | yes | Namespaced event kind: a namespace, one `:` or `.`, then a name of at least two characters. Both parts start with a lowercase letter and use only lowercase letters, digits, `_`, and `-`, e.g. `acme:deployment` or `deploy.completed`. `deployment` and `acme.deploy.completed` are rejected. |
| `opened_at` | yes | When the event started. RFC 3339, e.g. `2026-05-22T14:03:00Z`. |
| `closed_at` | no | When the event ended. Omit while it is still open. |
| `observed_at` | no | When your system observed this state of the event. Defaults to `opened_at`. |
| `title` | no | A short human-readable summary. |
| `affected_entities` | no | Entities the event affects, each `{"kind": "…", "identity_tags": {"key": "value"}}`. |
| `tags` | no | An object of string keys to string values, e.g. `{"env": "production"}`. Keys starting with `_`, and `schema_compliance`, are reserved for AO and are dropped. |
| `payload` | no | Any JSON object with source-specific detail. |

Unknown fields in a record are rejected rather than ignored, so a typo
surfaces as an `invalid_schema` error instead of silently dropped data.

## Updates and duplicates

Events are identified by `source_event_id` within a webhook. Sending a
record with an ID the webhook has already sent updates that event instead
of creating a new one, which makes retries safe. AO keeps the version with
the latest `observed_at`, so when you update an event — for example to add
`closed_at` — set `observed_at` to the time of the change.

Two different webhooks can use the same `source_event_id` without
colliding.

## Responses

A successful request returns `200` with the number of records ingested:

```json theme={null}
{ "accepted": 1 }
```

If any record fails validation, the whole request is rejected and none
are ingested. Errors return a JSON body with a stable code:

```json theme={null}
{ "error": { "code": "bad_signature", "message": "signature verification failed" } }
```

| Code | HTTP | Meaning |
| - | - | - |
| `not_found` | 404 | No webhook matches the install ID in the URL. |
| `missing_signature` | 401 | `X-AO-Timestamp` or `X-AO-Signature-256` is missing or malformed. |
| `bad_signature` | 401 | The signature does not match the body and secret. |
| `expired_timestamp` | 401 | `X-AO-Timestamp` is more than 5 minutes from AO's clock. |
| `revoked` | 403 | The webhook is no longer active. |
| `payload_too_large` | 413 | The body is larger than 1 MiB. |
| `invalid_schema` | 400 | The body or a record is malformed: invalid JSON, wrong `schema`, no records or more than 500, an unknown record `type`, a missing required field, a field of the wrong type, an unknown field, or an invalid `kind`. |
| `unsupported_type` | 422 | The record type is reserved for a future version (`graph.*`, `telemetry`). |
| `type_not_accepted` | 422 | This webhook is not configured to accept the record type. |
| `internal_error` | 500 | AO could not store the events. Some records in the batch may already be stored. Retry with backoff. |

Retry `internal_error` and network failures. Retries are safe because
records are deduplicated by `source_event_id`. Other errors will not
succeed unchanged. Re-sign each retry with a fresh timestamp.

## Limits

| Limit | Value |
| - | - |
| Request body | 1 MiB |
| Records per request | 500 |
| Timestamp tolerance | ±5 minutes |


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