GroundScore
Sign in
API Docs

Webhooks

Receive a POST to your endpoint whenever a scan finishes, an AI Monitor run completes, or a Power-Up deploys. Every delivery is signed with HMAC-SHA256 so you can verify it came from GroundScore.

Create a subscription

POST/api/v1/webhooksscope: write:webhooks

Register a URL to receive event deliveries. The response includes a secret you must store immediately. It is shown only once and cannot be retrieved later.

Request body

FieldTypeDescription
urlstringMust start with https://. Plain http is rejected.
eventsstring[]Array of event types to subscribe to. Use ["*"] to subscribe to all current and future events.

Example request

bash
curl -X POST https://app.groundscore.ai/api/v1/webhooks \
  -H "Authorization: Bearer gs_live_yourkeyhere" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/groundscore",
    "events": ["scan.completed", "ai_check.completed"]
  }'

Example response

Responds 201 Created on success.

json
{
  "data": {
    "subscription": {
      "id": "clxc9p3r0007qz7m5d8h2k4n",
      "url": "https://example.com/webhooks/groundscore",
      "events": ["scan.completed", "ai_check.completed"],
      "secret": "5f2c9b1e7a4d3c8f0b6e2a9d4c7f1b8e3a6d0c5f2b9e4a7d1c8f3b6e0a5d2c9f",
      "createdAt": "2026-05-23T10:00:00.000Z"
    }
  }
}

The secret is 64 hexadecimal characters with no prefix. Use it exactly as returned when computing the HMAC.

Save the secret now. It will not appear in any later response. If you lose it, revoke this subscription and create a new one.

List subscriptions

GET/api/v1/webhooksscope: read:webhooks

Returns every subscription on this account, newest first, including per-subscription delivery health (lastDeliveryAt and failureCount). Auto-disabled subscriptions are listed too. The secret is never returned by this endpoint.

Example response

json
{
  "data": {
    "subscriptions": [
      {
        "id": "clxc9p3r0007qz7m5d8h2k4n",
        "url": "https://example.com/webhooks/groundscore",
        "events": ["scan.completed", "ai_check.completed"],
        "createdAt": "2026-05-23T10:00:00.000Z",
        "lastDeliveryAt": "2026-05-23T14:32:00.000Z",
        "failureCount": 0
      }
    ]
  }
}

Revoke a subscription

DELETE/api/v1/webhooks/{subscriptionId}scope: write:webhooks

Permanently revokes a subscription. No further deliveries will be attempted. The subscription's delivery history is deleted along with it, which cancels any retry still queued for a past failed delivery.

Example request

bash
curl -X DELETE \
  -H "Authorization: Bearer gs_live_yourkeyhere" \
  https://app.groundscore.ai/api/v1/webhooks/clxc9p3r0007qz7m5d8h2k4n

Example response

json
{
  "data": {
    "deleted": true,
    "id": "clxc9p3r0007qz7m5d8h2k4n"
  }
}

Event types

Three event types are available today. Every delivery wraps its payload in the same envelope:

json
{
  "event": "scan.completed",
  "timestamp": "2026-05-23T14:32:00.000Z",
  "data": { /* event-specific payload */ }
}

scan.completed

Fired when a scheduled or manual scan finishes with status completed.

json
{
  "event": "scan.completed",
  "timestamp": "2026-05-23T14:32:00.000Z",
  "data": {
    "scanId": "clx9m4t7v0003qz7m8b2k6h1w",
    "siteId": "clx8h2k4p0001qz7m3n9d5f2g",
    "domain": "example.com",
    "score": 72
  }
}

ai_check.completed

Fired when an AI Monitor run finishes scoring.

json
{
  "event": "ai_check.completed",
  "timestamp": "2026-05-23T14:40:00.000Z",
  "data": {
    "runId": "clxb7n2q0005qz7m1c4v9j8s",
    "siteId": "clx8h2k4p0001qz7m3n9d5f2g",
    "domain": "example.com",
    "compositeScore": 67
  }
}

power_up.deployed

Fired when a Power-Up is successfully deployed to a connected site.

json
{
  "event": "power_up.deployed",
  "timestamp": "2026-05-23T14:45:00.000Z",
  "data": {
    "deploymentId": "clxd2f8w0009qz7m7g1p4r6t",
    "siteId": "clx8h2k4p0001qz7m3n9d5f2g",
    "powerUpId": "organization_schema"
  }
}

Delivery headers

Every delivery POST carries these headers alongside Content-Type: application/json.

HeaderValue
X-GroundScore-SignatureHMAC-SHA256 of the raw body, formatted as sha256={hex digest}.
X-GroundScore-EventThe event type, e.g. scan.completed. Matches the event field in the body.
X-GroundScore-Delivery-IdIdentifier for this delivery. Stays the same across every retry of it.
User-AgentGroundScore-Webhooks/1.0

Signature verification

Every delivery includes an X-GroundScore-Signature header of the form sha256={hex digest}. The digest is HMAC-SHA256 of the raw request body using your subscription secret as the key.

Compute the HMAC over the raw bytes of the request body, not a re-serialized version. Re-encoding JSON can change whitespace and break the signature match.

Node.js / JavaScript

javascript
const crypto = require("node:crypto");

function verifySignature(rawBody, header, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return header === `sha256=${expected}`;
}

// Express example
app.post("/webhooks/groundscore", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifySignature(
    req.body, // raw Buffer
    req.headers["x-groundscore-signature"],
    process.env.GROUNDSCORE_WEBHOOK_SECRET
  );
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString("utf8"));
  // … handle event
  res.status(200).end();
});

Python

python
import hmac
import hashlib

def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return header == f"sha256={expected}"

Retry policy

Each delivery is attempted immediately when the source event fires. If your endpoint returns a non-2xx status, times out, or the connection fails, GroundScore schedules retries with exponential backoff:

AttemptDelay after previous failure
1Immediate (queued)
2+1 minute
3+5 minutes
4+30 minutes
5+2 hours
6+12 hours
7+24 hours

After the 7th failed attempt, the delivery is marked permanently failed. Each delivery also has a 10-second timeout. Endpoints that take longer than 10s to respond fail the attempt.

Auto-disable

The failureCount on a subscription counts deliveries that exhausted all seven attempts, not individual attempts. A single successful delivery resets it to zero. Once failureCount reaches 100, the subscription is auto-disabled and queued deliveries are marked failed without being sent, so a dead endpoint stops being hammered.

A disabled subscription is not deleted. It keeps appearing in GET /api/v1/webhooks with its failureCount intact so you can see what broke. To start delivering again, revoke it and create a new subscription.

Best practices

  • Always verify the signature. An unsigned POST to your endpoint is not a GroundScore event.
  • Respond within 10 seconds. Acknowledge the delivery fast, then do any heavy work asynchronously (queue, background worker, etc.).
  • Be idempotent. Retries can deliver the same event more than once. Use X-GroundScore-Delivery-Id as your idempotency key: it is stable across every retry of a delivery and unique per delivery. The event's primary ID (scanId, runId, deploymentId) works too when you want to collapse repeats of the same underlying event.
  • Return 2xx on success. Any non-2xx status (4xx or 5xx) triggers the retry schedule above.
  • Subscribe narrowly. Prefer ["scan.completed"] over ["*"] unless you really want every future event type.

Errors

CodeWhen
not_foundSubscription does not exist or belongs to a different account.
insufficient_scopeKey lacks the required scope (read:webhooks for GET, write:webhooks for POST/DELETE).
invalid_bodyThe POST body was not valid JSON.
invalid_urlurl is missing, longer than 500 characters, not https://, or not a valid URL.
invalid_eventsevents is missing, empty, not an array of strings, or names an event outside the list above.