# 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.md) 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= ``` The signature is `HMAC-SHA256(secret, ".")`, where `` is the Unix timestamp and `` 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.