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.

View as MarkdownOpenAPI specAll docs for LLMs

Why use one

Without a webhook, finding who started or stopped using an app means pulling its list (or GET /v1/events) 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

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. 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: 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 to send a free sample and check your endpoint.

What you receive

{
  "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 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.

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);
}
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 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.