# Webhooks

> Markdown variant of <https://www.skillzdrive.com/docs/rest-api/webhooks>.

Get notified when your account's credit balance crosses a threshold —
so partner systems can alert end-users before service stops.

Partners running a master SkillzDrive account on behalf of their
users need advance warning when credits are running low. Polling is
expensive and laggy; webhooks push a signed notification the moment
the balance crosses a threshold you picked.

## Event catalog

| event_type | Fires when | Uses `threshold_value`? |
|------------|-----------|-------------------------|
| `credits.threshold_hit` | Balance crosses downward from above `threshold_value` to at-or-below. | Required |
| `credits.exhausted` | Balance hits 0. | Ignored |

Each subscription fires **once per downward crossing**. If the
balance recovers (e.g. you top up) and crosses the threshold again
later, it fires again. It does not fire while the balance stays
below the threshold.

## Create a subscription

Use the REST API or the CLI. The response contains a **signing
secret shown exactly once** — save it now.

```bash
curl -X POST https://www.skillzdrive.com/api/v1/account/webhooks \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "credits.threshold_hit",
    "threshold_value": 500,
    "target_url": "https://example.com/hooks/skillzdrive"
  }'
```

CLI equivalent:

```bash
skillzdrive webhooks create \
  --event credits.threshold_hit \
  --threshold 500 \
  --url https://example.com/hooks/skillzdrive
```

JavaScript:

```js
const res = await fetch("https://www.skillzdrive.com/api/v1/account/webhooks", {
  method: "POST",
  headers: {
    authorization: "Bearer " + process.env.SKILLZDRIVE_API_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    event_type: "credits.threshold_hit",
    threshold_value: 500,
    target_url: "https://example.com/hooks/skillzdrive",
  }),
});
const { data } = await res.json();
// SAVE data.secret NOW — it won't be returned again.
```

## Delivery format

Deliveries are HTTP `POST` with JSON body.

```http
POST https://example.com/hooks/skillzdrive
Content-Type: application/json
X-Skillzdrive-Event: credits.threshold_hit
X-Skillzdrive-Signature: sha256=<hmac-sha256 of raw body using your secret, hex>
```

```json
{
  "event": "credits.threshold_hit",
  "owner_user_id": "00000000-0000-0000-0000-000000000000",
  "credits_remaining": 487,
  "threshold": 500,
  "timestamp": "2026-04-22T03:15:42.000Z"
}
```

### Timeouts and delivery semantics

- Delivery timeout: **5 seconds**. Slower targets are treated as failed.
- At-most-once: failed deliveries are *not* retried in v1 (we log the HTTP status in `last_delivery_status`). Build your receiver to be idempotent anyway.
- No ordering guarantees.

## Verify signatures

Compute HMAC-SHA256 of the **raw request body** using your
subscription secret, hex-encode it, prefix with `sha256=`, and
constant-time compare against the `X-Skillzdrive-Signature` header.

### Node.js

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

function verifySkillzDriveSignature(rawBody, secret, headerValue) {
  const expected = "sha256=" +
    createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(headerValue);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

// Express example — must use raw body
app.post("/hooks/skillzdrive",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySkillzDriveSignature(
      req.body,
      process.env.SKILLZDRIVE_WEBHOOK_SECRET,
      req.header("x-skillzdrive-signature") ?? "",
    );
    if (!ok) return res.status(401).end();
    const event = JSON.parse(req.body.toString());
    // ...handle event...
    res.status(200).end();
  });
```

### Python

```python
import hmac, hashlib

def verify_skillzdrive_signature(raw_body: bytes, secret: str, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header)
```

### Ruby

```ruby
require "openssl"

def verify_skillzdrive_signature(raw_body, secret, header)
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("sha256", secret, raw_body)
  ActiveSupport::SecurityUtils.secure_compare(expected, header)
end
```

**Verify the raw body**: signature verification depends on the exact
bytes we signed. Parse the header *before* JSON-decoding the body,
and feed the untouched `Buffer`/`bytes` to your HMAC. Frameworks
that silently re-encode JSON will break signatures.

## List and delete

```bash
# List
curl -H "Authorization: Bearer sk_live_..." \
     https://www.skillzdrive.com/api/v1/account/webhooks

# Delete
curl -X DELETE -H "Authorization: Bearer sk_live_..." \
     https://www.skillzdrive.com/api/v1/account/webhooks/<id>

# CLI equivalents
skillzdrive webhooks list
skillzdrive webhooks delete <id>
```
