Billing Webhooks
Configurable low-credit alerts and signed outbound webhooks for billing events
Silent 402s 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 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 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.
{
"event": "credits.low",
"created": 1700000000,
"data": {
"balance": 480,
"threshold": 500
}
}The data object varies per event.
{
"event": "credits.exhausted",
"created": 1700000000,
"data": { "balance": 0 }
}{
"event": "payment.succeeded",
"created": 1700000000,
"data": {
"credits_added": 2500,
"new_balance": 2600,
"amount_gbp": "£15",
"plan": "starter"
}
}{
"event": "auto_recharge.succeeded",
"created": 1700000000,
"data": {
"credits_added": 2500,
"new_balance": 2600,
"amount_gbp": "£15",
"plan": "starter"
}
}{
"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:
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.
Always verify against the raw request body: parsing and re-serializing the JSON changes the bytes and breaks the signature.
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);
}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:
- Expose your handler on a public HTTPS hostname (a tunnel, or a hosted request inspector) and save that URL.
- Trigger a real event you control.
credits.lowis the easiest: set an absolute threshold just under your current balance, then make one billed call so the next deduction crosses it. - Capture the raw body and the
x-socialcrawl-signatureheader, then replay both against yourverify()function offline. That removes the network from the loop when you are debugging a signature mismatch.
