Skip to main content

Webhooks

Webhooks deliver job, quota, and subscription events to your infrastructure in real time. Wire them into dashboards, alerting, or CRM so your systems react the moment MCPP sees a change.

Webhooks are available on the Pro plan and up. Manage endpoints from your dashboard.

Event types

  • job_returned — fires every time one of your API keys records a tool call against /usage/track.
  • quota_approaching — fires the first time your usage crosses 80% of your monthly quota, at most once per 24 hours.
  • quota_exceeded — fires when your usage blows through the monthly cap. At most one delivery per billing period.
  • subscription_changed — fires when Stripe notifies us of a plan update or cancellation. The payload reports the previous and new plan.

Delivery headers

Every delivery is an HTTP POST with Content-Type: application/json and the following custom headers:

  • X-Webhook-Signature sha256=<hex> HMAC of the raw request body using your endpoint's signing secret.
  • X-Webhook-Event — the event name, same as the top-level event field on the JSON body.
  • X-Webhook-Delivery-Id — unique id for this attempt; useful for deduplication.
  • X-Webhook-Timestamp — ISO 8601 timestamp of the attempt. Reject deliveries older than a few minutes to prevent replay.

Example payloads

job_returned
{
  "event": "job_returned",
  "job_count": 12,
  "tool": "get_jobs",
  "api_key_prefix": "gm_abcd",
  "timestamp": "2026-04-20T12:34:56.789Z"
}
quota_approaching
{
  "event": "quota_approaching",
  "plan": "pro",
  "usage": 4000,
  "limit": 5000,
  "percent": 80,
  "timestamp": "2026-04-20T12:34:56.789Z"
}
quota_exceeded
{
  "event": "quota_exceeded",
  "plan": "pro",
  "usage": 5001,
  "limit": 5000,
  "timestamp": "2026-04-20T12:34:56.789Z"
}
subscription_changed
{
  "event": "subscription_changed",
  "old_plan": "free",
  "new_plan": "pro",
  "stripe_event_id": "evt_1AbCdEfG",
  "timestamp": "2026-04-20T12:34:56.789Z"
}

Verifying signatures

Always verify X-Webhook-Signature against the signing secret you copied when you created the endpoint. Do the comparison over the raw request body — any reserialization will produce a different digest and fail.

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

function verifySignature(rawBody, header, secret) {
  // header format: "sha256=<hex>"
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(header ?? "");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

// Express example
app.post("/hooks/getmany", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const sig = req.get("X-Webhook-Signature");
  const ts = req.get("X-Webhook-Timestamp");

  if (!verifySignature(rawBody, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send("invalid signature");
  }

  // Replay protection: reject deliveries older than 5 minutes.
  if (!ts || Date.now() - new Date(ts).getTime() > 5 * 60_000) {
    return res.status(401).send("stale");
  }

  const payload = JSON.parse(rawBody);
  // handle payload.event ...
  res.status(200).send("ok");
});
Python
import hmac, hashlib, time
from datetime import datetime, timezone

def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header or "")

def handle(request, secret: str):
    raw = request.get_data()  # Flask: use get_data(), not .json
    sig = request.headers.get("X-Webhook-Signature", "")
    ts  = request.headers.get("X-Webhook-Timestamp", "")

    if not verify_signature(raw, sig, secret):
        return ("invalid signature", 401)

    # Replay protection
    try:
        when = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        if (datetime.now(timezone.utc) - when).total_seconds() > 300:
            return ("stale", 401)
    except Exception:
        return ("bad timestamp", 401)

    # process payload (request.get_json()) ...
    return ("ok", 200)

Retry policy

A delivery is considered successful only when your endpoint returns a 2xx status within 10 seconds. Any other response — or a network error — triggers a retry with exponential backoff:

  1. Attempt 1: immediate
  2. Attempt 2: 1 minute later
  3. Attempt 3: 5 minutes later
  4. Attempt 4: 15 minutes later
  5. Attempt 5: 1 hour later

After 5 failed attempts the delivery is marked failed_permanently and you can inspect the last response body in the Deliveries panel on the dashboard.

Security best practices

  • Treat your signing secret like a password — never commit it to source control. Use rotate secret from the dashboard if it leaks.
  • Always verify the signature using a constant-time comparison (timingSafeEqual / hmac.compare_digest).
  • Check X-Webhook-Timestamp and reject deliveries older than 5 minutes to prevent replay attacks.
  • Deduplicate on X-Webhook-Delivery-Id if your handler is not naturally idempotent — retries may deliver the same event more than once.
  • Only https:// URLs on public hostnames can be registered. Private ranges (127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, 169.254.0.0/16, ::1, fc00::/7, fe80::/10) are rejected.

Endpoint limits

Pro includes up to 5 active webhook endpoints. Enterprise includes 20. Contact sales for higher limits.

Managing endpoints

Create, edit, and test endpoints in your dashboard. From there you can send a test delivery, inspect recent attempts, rotate the signing secret, or pause an endpoint without deleting it.

Recipes that pair with webhooks

These job-search recipes pair well with webhook event pipelines — wire them into dashboards, alerting, or CRM flows.

Ready to wire webhooks?

Webhook delivery is a Pro-plan feature. Upgrade to pipe job, quota, and subscription events into your own systems in real time.