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

# Webhooks

> Get events from your watches pushed to a URL, a Slack channel or a GitHub repository as they happen.

[Watches](https://docs.appgoat.ai/api-reference/endpoint/create-watch.md) turn significant changes on apps and keywords into events (new versions, metadata and price changes, rating drops, review spikes, chart and rank moves). You can poll them for free with [`GET /v1/events`](https://docs.appgoat.ai/api-reference/endpoint/list-events), or have AppGoat push each event as it happens. Webhooks are free to use on paid plans; on Free, creating one returns 402 [`plan_limit`](https://docs.appgoat.ai/errors/plan_limit).

## Create an endpoint

```bash theme={null}
curl -X POST "https://api.appgoat.ai/v1/webhooks" \
  -H "x-api-key: $APPGOAT_API_KEY" \
  -H "content-type: application/json" \
  -d '{"url":"https://example.com/hooks/appgoat","event_types":["new_version","rating_drop"]}'
```

The response contains the signing `secret` (`whsec_…`). It is returned only once; rotate it with [`PATCH /v1/webhooks/{id}`](https://docs.appgoat.ai/api-reference/endpoint/update-webhook) and `rotate_secret`. `event_types: null` (the default) sends every type. Send a test event with [`POST /v1/webhooks/{id}/test`](https://docs.appgoat.ai/api-reference/endpoint/test-webhook). An organization can have up to 20 endpoints.

| `kind` | `url` | What is sent |
| - | - | - |
| `webhook` (default) | An `https` URL on a public host | `POST` of the event JSON, signed |
| `slack` | A Slack incoming webhook (`https://hooks.slack.com/services/…`) | A Slack message |
| `github` | `owner/repo`, plus `github_token` (a fine-grained token with Contents: read and write) | `repository_dispatch` with `{ "event_type": "appgoat.<type>", "client_payload": <event> }`, to start a workflow `on: repository_dispatch` |

## The delivery

Every `webhook` delivery is a `POST` with the [event](https://docs.appgoat.ai/api-reference/endpoint/list-events.md) as JSON and these headers:

| Header | |
| - | - |
| `appgoat-signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>` |
| `appgoat-event-id` | The event id. A retry repeats it, so deduplicate on it. |
| `appgoat-event-type` | For example `new_version` (`test` for test sends). |
| `appgoat-delivery-id` | The delivery id. |
| `user-agent` | `AppGoat-Webhooks/1.0 (+https://docs.appgoat.ai/webhooks)` |

```json theme={null}
{
  "id": "4812",
  "type": "new_version",
  "created_at": "2026-10-22T08:15:02.000Z",
  "watch_id": 12,
  "app": { "store": "apple", "app_id": "324684580", "title": "Spotify", "icon_url": null },
  "country": "us",
  "title": "Spotify released version 9.0.2 (was 9.0.1)",
  "summary": "Spotify 9.0.2 adds offline lyrics and fixes CarPlay crashes.",
  "significance": 0.81,
  "payload": { "version": "9.0.2", "previous_version": "9.0.1", "release_notes": "Offline lyrics. CarPlay fixes." }
}
```

Answer with any 2xx within 10 seconds and do the work asynchronously.

## Verify the signature

Compute HMAC-SHA256 of `<t>.<raw request body>` with your secret, compare it in constant time with `v1`, and reject timestamps older than 5 minutes.

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

export function verifyAppgoat(secret, rawBody, header, toleranceSec = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec || !parts.v1) return false;
  const want = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
  const got = Buffer.from(parts.v1);
  return want.length === got.length && timingSafeEqual(want, got);
}
```

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

def verify_appgoat(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance or "v1" not in parts:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts["v1"])
```

## Retries

A non-2xx answer (redirects included) or a timeout is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts over about 2 days); after the last one the delivery is marked failed. Inspect them with [`GET /v1/webhooks/{id}/deliveries`](https://docs.appgoat.ai/api-reference/endpoint/list-webhook-deliveries). An endpoint that fails 50 times in a row is disabled; [`PATCH`](https://docs.appgoat.ai/api-reference/endpoint/update-webhook) it with `active: true` to turn it back on.

Endpoint URLs must resolve to public addresses: `localhost`, private networks and URLs with credentials are refused with [`invalid_param`](https://docs.appgoat.ai/errors/invalid_param).


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