SocialCrawl

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

EventFires when
credits.lowA deduction takes a positive balance from above the low-credit threshold to at or below it.
credits.exhaustedA deduction takes a positive balance to exactly zero.
payment.succeededA one-time credit pack purchase is fulfilled.
auto_recharge.succeededAn automatic top-up added credits.
auto_recharge.failedAn 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.

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

The data object varies per event.

Response
{
  "event": "credits.exhausted",
  "created": 1700000000,
  "data": { "balance": 0 }
}
Response
{
  "event": "payment.succeeded",
  "created": 1700000000,
  "data": {
    "credits_added": 2500,
    "new_balance": 2600,
    "amount_gbp": "£15",
    "plan": "starter"
  }
}
Response
{
  "event": "auto_recharge.succeeded",
  "created": 1700000000,
  "data": {
    "credits_added": 2500,
    "new_balance": 2600,
    "amount_gbp": "£15",
    "plan": "starter"
  }
}
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:

Header
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.

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
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

Next steps