# Billing Webhooks (/docs/billing-webhooks)



Billing Webhooks [#billing-webhooks]

Silent `402`s in production are how you lose an account overnight. SocialCrawl warns you before your app breaks: a configurable low-credit email alert, plus signed outbound webhooks so your own infrastructure can machine-react to a draining balance (top up, page on-call, flip a feature flag).

Configure both on your dashboard under **Billing → Payments**.

Low-credit alerts [#low-credit-alerts]

By default we email you the moment your balance crosses **20% of your most recently purchased pack**. That scales with you: a 2,500-credit pack alerts at 500, a 150,000-credit pack alerts at 30,000. (A hard-coded threshold is a poor default at scale, because a single request can cost more than the threshold itself.)

Before your first purchase there is no pack to scale from, so the default falls back to an absolute **10 credits remaining** — which on the 100-credit welcome grant is late, and later than one call to a composite endpoint. If you are building against the free grant, set an absolute threshold with some headroom (20 is the equivalent of the 20% rule) rather than relying on the default. `credits.exhausted` still fires at zero either way.

You can override this per account:

* **Absolute threshold**: set an exact number (e.g. alert me at 5,000).
* **Disable**: turn the email off entirely, or set the threshold to `0`.

The alert fires **once per crossing**: on the single call that takes you from above the threshold to at or below it, not on every subsequent call. A 7-day de-duplication window is the backstop.

Webhook events [#webhook-events]

When you configure a webhook URL, SocialCrawl `POST`s a signed JSON body for these events:

| Event                     | Fires when                                                 |
| ------------------------- | ---------------------------------------------------------- |
| `credits.low`             | Your balance crosses the low-credit threshold (see above). |
| `credits.exhausted`       | Your balance reaches zero.                                 |
| `payment.succeeded`       | A one-time credit pack purchase is fulfilled.              |
| `auto_recharge.succeeded` | An automatic top-up added credits.                         |
| `auto_recharge.failed`    | An automatic top-up failed (card declined, auth required). |

Payload shape [#payload-shape]

```json
{
  "event": "credits.low",
  "created": 1700000000,
  "data": {
    "balance": 480,
    "threshold": 500
  }
}
```

`created` is a Unix timestamp (seconds). The `data` object varies per event:

```json
// payment.succeeded / auto_recharge.succeeded
{ "credits_added": 2500, "new_balance": 2600, "amount_gbp": "£15", "plan": "starter" }

// credits.exhausted
{ "balance": 0 }

// auto_recharge.failed
{ "reason": "card_declined", "action_required": false }
```

How we sign and retry [#how-we-sign-and-retry]

SocialCrawl signs every webhook delivery. Billing webhooks and [Monitor webhooks](/docs/webhooks) share the exact same signing scheme, so one verification routine covers both.

Signature [#signature]

Each delivery carries an `x-socialcrawl-signature` header, Stripe-style:

```
x-socialcrawl-signature: t=1700000000,v1=<hex>
```

The signature is `HMAC-SHA256(secret, "<t>.<rawBody>")`, where `<t>` is the Unix timestamp and `<rawBody>` is the exact bytes of the request body. Fold `t` into your check to enforce a replay window. Your signing secret (a `whsec_...` value) is shown **once**, when you create or replace the webhook. Store it immediately; we keep only an encrypted copy and cannot show it again.

Verifying a signature (Node.js) [#verifying-a-signature-nodejs]

```js
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(parts.v1, "hex"),
    Buffer.from(expected, "hex"),
  );
}
```

Verifying a signature (Python) [#verifying-a-signature-python]

```python
import hashlib, hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    expected = hmac.new(
        secret.encode(),
        f"{parts['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)
```

Always verify against the **raw** request body. Parsing and re-serializing the JSON will change the bytes and break the signature.

Retries and delivery [#retries-and-delivery]

Deliveries go out through a retrying queue (up to 5 attempts with exponential backoff). Respond `2xx` promptly to acknowledge. An endpoint that fails repeatedly is automatically paused; re-save the URL on your dashboard to reactivate it. Requirements:

* The URL must be **HTTPS** on a public host (loopback, private, and link-local addresses are rejected).
* Handlers should be **idempotent**: treat retries and occasional duplicates as normal, and de-duplicate on `(event, created)` if you need exactly-once semantics.
