# Change webhooks

Subscribe to one app's installs or removals and we POST each change to your URL as our scans detect it, for **10 credits** per change delivered.

## Why use one

Without a webhook, finding who started or stopped using an app means pulling its list (or [`GET /v1/events`](https://installgraph.com/docs/reference/list-events.md)) on a schedule and comparing. A webhook does the comparing for you: each store that changes reaches you once, a minute or so after the scan that saw it. Every store is scanned weekly, so a change reaches you within about a week of happening.

## Subscribe

```bash
curl -X POST "https://api.installgraph.com/v1/webhooks" \
  -H "Authorization: Bearer $INSTALLGRAPH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"app": "klaviyo", "url": "https://example.com/hooks/installgraph", "types": ["removed"], "country": "US"}'
```

| Field | Meaning |
| --- | --- |
| `app` | An app slug from [`GET /v1/apps`](https://installgraph.com/docs/reference/list-apps.md). One app per webhook. |
| `url` | Where we POST. https on the standard port, at a public address. |
| `types` | `installed`, `removed` or both (the default). |
| `country`, `region`, `industry`, `aov` | Optional, as on [`GET /v1/events`](https://installgraph.com/docs/reference/list-events.md): only changes at matching stores are sent and charged. |

The response carries a `secret` (`whsec_…`). Store it: it is shown only once, and it signs every delivery. Then call [`POST /v1/webhooks/{id}/test`](https://installgraph.com/docs/reference/test-webhook.md) to send a free sample and check your endpoint.

## What you receive

```json
{
  "id": "6733d1a07be42c19f5a8e301",
  "type": "technology.removed",
  "subscriptionId": "6731b2e4c9a1f20d8e4b7c15",
  "createdAt": "2026-11-12T04:21:07.000Z",
  "data": {
    "id": "evt_6720a4c3f1b2",
    "type": "removed",
    "domain": "example.com",
    "app": { "slug": "klaviyo", "name": "Klaviyo", "category": "email-sms" },
    "detectedAt": "2026-11-12T04:20:31.000Z",
    "previousScanAt": "2026-11-05T03:58:10.000Z",
    "lastSeenAt": "2026-11-05T03:58:10.000Z",
    "country": "US",
    "region": "north-america",
    "industry": null,
    "pricePoint": null
  }
}
```

`data` is the same row [`GET /v1/events`](https://installgraph.com/docs/reference/list-events.md) returns. The change happened between `previousScanAt` and `detectedAt`. A store's first scan reports no installs, because we cannot say when they happened.

Headers: `Installgraph-Webhook-Id` (the payload's `id`, the same on every retry) and `Installgraph-Signature`.

## Verify the signature

`Installgraph-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 of `<t>.<raw request body>`, keyed by your secret. Check it against the raw body, before parsing, compare in constant time, and refuse a `t` more than 5 minutes old.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

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

```python
import hashlib, hmac, time

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

## Answer quickly, deduplicate

Reply with any 2xx within 10 seconds, then do your work. Anything else (an error status, a timeout, a redirect) is retried after 1, 5 and 30 minutes, then 2, 6, 12 and 24 hours. Deliveries can arrive out of order and, rarely, twice, so deduplicate on `Installgraph-Webhook-Id`. [`GET /v1/webhooks/{id}/deliveries`](https://installgraph.com/docs/reference/list-webhook-deliveries.md) shows each delivery's attempts and your endpoint's last answer.

After 3 changes in a row fail every retry, the webhook is `disabled`; fix the endpoint and create a new one.

## Price

- **10 credits per change delivered**, one store installing or removing the app. Creating, listing, testing and deleting webhooks is free.
- Charged when a change is first sent, and **refunded** if every retry fails or you delete the webhook first.
- If your balance cannot cover a change, it waits and is sent once you top up (or auto top-up runs), for up to 7 days; after that it is dropped, uncharged.
- Webhooks need an account that has bought a credit pack (`402 paid_plan_required` otherwise). Up to 25 active webhooks per account (`409 webhook_limit_reached`); for more, [talk to us](https://installgraph.com/talk-to-us).
