# Billing Webhooks (/docs/billing-webhooks) Configurable low-credit alerts and signed outbound webhooks for billing events Silent `402`s in production are how you lose an account overnight. SocialCrawl warns you first: a configurable low-credit email alert, plus signed outbound webhooks so your own infrastructure can react to a draining balance by topping up, paging on-call, or degrading gracefully. Billing webhooks and [Monitor webhooks](/docs/webhooks.md) share the exact same signing scheme, so one verification routine covers both. ## Prerequisites - A SocialCrawl account. Configure both features on your dashboard under **Billing → Payments**. - An HTTPS endpoint on a public host that can read a raw request body. Loopback, private, and link-local addresses are rejected, and the URL must be 2048 characters or fewer. ## How do I set them up? ### Save your endpoint URL Open **Billing → Payments** and save the HTTPS URL you want deliveries sent to. ### Store the signing secret Saving the URL returns a `whsec_...` signing secret, shown once. Store it immediately: SocialCrawl keeps only an encrypted copy and cannot display it again. Re-saving the URL later mints a fresh secret and clears any failure or pause state, so a rotation is deliberate rather than silent. ### Verify every delivery Check the `x-socialcrawl-signature` header against the raw request body using the routine in [Verifying signatures](#verifying-signatures) below, and reject anything that fails. ### Tune the low-credit threshold The default scales with your last purchase (see below). If you are building against the free grant, set an absolute threshold with some headroom instead. ## Low-credit alerts By default you are emailed 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**. On the 100-credit welcome grant that 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, for example 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. ## Event catalogue | Event | Fires when | | ------------------------- | ------------------------------------------------------------------------------------------- | | `credits.low` | A deduction takes a positive balance from above the low-credit threshold to at or below it. | | `credits.exhausted` | A deduction takes a positive balance to exactly 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, authentication required). | `credits.low` and `credits.exhausted` are mutually exclusive on any one call: a balance that lands on zero is exhausted, not low. ## Payloads Every delivery has the same three top-level fields. `created` is a Unix timestamp in seconds, and it is the same value as the `t` in the signature header. ```json title="Response" { "event": "credits.low", "created": 1700000000, "data": { "balance": 480, "threshold": 500 } } ``` The `data` object varies per event. ```json title="Response" { "event": "credits.exhausted", "created": 1700000000, "data": { "balance": 0 } } ``` ```json title="Response" { "event": "payment.succeeded", "created": 1700000000, "data": { "credits_added": 2500, "new_balance": 2600, "amount_gbp": "£15", "plan": "starter" } } ``` ```json title="Response" { "event": "auto_recharge.succeeded", "created": 1700000000, "data": { "credits_added": 2500, "new_balance": 2600, "amount_gbp": "£15", "plan": "starter" } } ``` ```json title="Response" { "event": "auto_recharge.failed", "created": 1700000000, "data": { "reason": "card_declined", "action_required": false } } ``` `plan` is a stable identifier such as `starter`, not a display name, so it is safe to branch on. ## Verifying signatures Each delivery carries an `x-socialcrawl-signature` header, Stripe-style: ```text title="Header" 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. Always verify against the raw request body: parsing and re-serializing the JSON changes the bytes and breaks the signature. ```js title="JavaScript" import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; function verify(rawBody, header, secret) { const parts = Object.fromEntries( header.split(",").map((kv) => { const i = kv.indexOf("="); return [kv.slice(0, i), kv.slice(i + 1)]; }), ); const t = Number(parts.t); if (!Number.isFinite(t)) return false; // Replay window: reject anything older than the tolerance. if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) { return false; } const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(parts.v1 ?? "", "hex"); const b = Buffer.from(expected, "hex"); // timingSafeEqual throws a RangeError on a length mismatch, and a truncated // or non-hex v1 produces a short buffer, so compare lengths first. if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } ``` ```python title="Python" import hashlib, hmac, time TOLERANCE_SECONDS = 300 def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(kv.split("=", 1) for kv in header.split(",")) try: t = int(parts["t"]) except (KeyError, ValueError): return False # Replay window: reject anything older than the tolerance. if abs(int(time.time()) - t) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(parts.get("v1", ""), expected) ``` ## Delivery, retries, and auto-pause Deliveries go out through a retrying queue: up to 6 attempts (the initial delivery plus 5 retries) with exponential backoff. Respond `2xx` promptly to acknowledge; a non-`2xx` response or a timeout counts as a failed attempt. A successful delivery resets the failure counter. After 10 consecutive failed deliveries the webhook is automatically paused and stops receiving events. Re-save the URL on your dashboard to reactivate it, remembering that this also issues a new signing secret. URL requirements, enforced when you save: - Must be HTTPS. - Must be a public host. Loopback, private, and link-local addresses are rejected. - Must be 2048 characters or fewer. ## Idempotency Handlers must be idempotent. Retries and occasional duplicate deliveries are normal. De-duplicate on `(event, created)`. Delivery is already de-duplicated per occurrence on our side, so a replay of the same billing occurrence collapses into one delivery, while two genuinely distinct occurrences that happen to share a payload shape (two `auto_recharge.failed` events with the same `reason`, for example) both deliver as they should. ## Testing locally There is no test-fire button, and a loopback URL is rejected when you save, so: 1. Expose your handler on a public HTTPS hostname (a tunnel, or a hosted request inspector) and save that URL. 2. Trigger a real event you control. `credits.low` is the easiest: set an absolute threshold just under your current balance, then make one billed call so the next deduction crosses it. 3. Capture the raw body and the `x-socialcrawl-signature` header, then replay both against your `verify()` function offline. That removes the network from the loop when you are debugging a signature mismatch. ## Troubleshooting You are almost certainly verifying a re-serialized body. Capture the raw bytes before any JSON middleware touches them, and hash `"."` with `t` read from the header. If you re-saved the endpoint URL recently, you also have a new secret: the old one no longer validates anything. Three common causes. The threshold may be `0`, which disables the alert. The balance may have gone straight to zero on one call, which is `credits.exhausted` rather than `credits.low`. Or the crossing already happened: the event fires once, on the call that crosses the line, not on every call below it. The endpoint is paused after 10 consecutive failures. Re-save the URL on your dashboard to reactivate it, and store the fresh signing secret it returns. The URL must be HTTPS, on a public host, and 2048 characters or fewer. Loopback, private, and link-local addresses are rejected so a webhook can never be pointed at internal infrastructure. You can have one. Monitor and billing deliveries use the identical header, signing base string, and secret format. Verify first, then branch: a billing payload has a top-level `event` field, and a Monitor payload has `monitor_id` and `run_id`. ## Next steps - [Monitor webhooks](/docs/webhooks.md): Signed payloads on every scheduled Monitor run. - [Credits](/docs/credits.md): How the ledger works, and what gets refunded. - [Endpoint pricing](/docs/endpoint-pricing.md): The exact credit cost of every endpoint. - [Errors](/docs/errors.md): What a 402 means and how to recover.