Soft Kill webhooks
Last updated: October 2026
What a Soft Kill is
A Soft Kill lets your own service degrade itself before spend gets out of hand (turn off an expensive feature, scale workers down, switch to a maintenance page) without cutting billing. When a budget threshold you attached it to is crossed, FireShield sends one signed POST request to an HTTPS endpoint you control. What happens next is entirely up to your code.
If you need a guaranteed stop instead, use a Hard Kill, which disconnects the project's billing account.
1. Set it up in FireShield
- Open your project's budget (Configure), and on the threshold you want, choose + Add action → Soft Kill (webhook).
- Enter your endpoint's URL. It must use HTTPS.
- FireShield generates a signing secret for it. Press View secret key on the action to see it, and store it as a secret in your service (for example
FIRESHIELD_WEBHOOK_SECRET). If it ever leaks, press Regenerate there: the old secret stops working at once, so update your service with the new one. - Click Save so the budget syncs. An endpoint can be reused on other thresholds of the same project.
- Want to degrade step by step? Add a Soft Kill to several thresholds (say 50%, 80% and 100%) and branch on
thresholdPercentin your handler.
2. The request
POST /fireshield/soft-kill HTTP/1.1
Content-Type: application/json
X-FireShield-Timestamp: 1790899412
X-FireShield-Signature: 5f2b1c…e9a0
{
"event": "soft_kill",
"cloudProjectId": "e3e0cc6f0146409c8d54ce43c0757294",
"gcpProjectId": "firewand-prod",
"thresholdPercent": 90,
"costAmount": 90.12,
"budgetAmount": 100,
"currency": "USD",
"reason": "FireWand hit 90% of budget (90.12 / 100 USD)",
"timestamp": "1790899412"
}Headers:
X-FireShield-Timestamp: Unix time in seconds when the request was signed.X-FireShield-Signature: Hex HMAC-SHA256 of{timestamp}.{raw body}, keyed with your signing secret.
Body (JSON):
event:"soft_kill"when a threshold is crossed, or"test"when you press Send test in FireShield (see below). Ignore events you don't recognise; just answer 2xx.cloudProjectId: FireShield's ID for the project, not your GCP project ID. Use it to tell projects apart if one endpoint serves several.gcpProjectId: Your Google Cloud project ID.thresholdPercent: The budget threshold that fired, as a percentage (e.g.90). Each threshold with a Soft Kill sends its own request, so use it to decide how far to degrade.costAmount: Spend so far this budget period, as Google reported it.budgetAmount: The budget amount for the period.currency: ISO 4217 code of both amounts, e.g."USD".reason: A human-readable sentence for your logs, e.g. which threshold was hit and the spend. Don't parse it; its wording may change.timestamp: Same value as the header, as a string.
3. Verify it
Before acting on a request, check both of these and reject it with a 401 otherwise:
- Freshness: the timestamp is within 5 minutes of your clock. This stops a captured request from being replayed later.
- Signature: recompute the HMAC over the timestamp, a dot, and the raw request body exactly as received, then compare it to the header in constant time. Re-serializing parsed JSON changes the bytes and breaks the signature.
// Firebase Cloud Function (2nd gen). The same logic works in Express:
// sign over the raw body bytes, never over a re-serialized JSON object.
import { onRequest } from "firebase-functions/v2/https";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.FIRESHIELD_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 5 * 60;
export const fireshieldSoftKill = onRequest(async (req, res) => {
const timestamp = req.get("X-FireShield-Timestamp") ?? "";
const signature = req.get("X-FireShield-Signature") ?? "";
const rawBody = req.rawBody.toString("utf8");
// 1. Reject stale or replayed requests.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > MAX_AGE_SECONDS) {
res.status(401).send("Stale request");
return;
}
// 2. Recompute the signature and compare in constant time.
const expected = createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).send("Bad signature");
return;
}
// 3. Degrade your service — then answer fast with a 2xx. Each threshold
// with a Soft Kill sends its own request, so you can step down gradually.
const event = JSON.parse(rawBody);
if (event.event === "soft_kill") {
if (event.thresholdPercent >= 100) await showMaintenancePage(); // your code
else if (event.thresholdPercent >= 80) await disableExpensiveFeatures();
else await pauseBatchJobs();
}
res.status(204).send();
});4. Respond
Answer with any 2xx status as soon as you've accepted the request; do slow work asynchronously. Any other status, or a network error, is recorded as a failed action and shows up in your project's Recent logs.
5. Delivery guarantees
- Each Soft Kill action fires once per budget period (calendar month), the first time the spend Google reports crosses its threshold. Changing the budget's amount re-arms it, so it fires again when the new threshold is crossed.
- Failed deliveries are retried with each new cost update Google publishes for the budget (typically every half hour or so) until one succeeds. Answer with a
2xxonce you've handled the request, or you'll keep receiving it. - Google publishes cost data with a delay of up to several hours, so the request arrives some time after the spend actually crossed the threshold.
- Handle the request idempotently anyway: enabling degraded mode twice should be harmless.
6. Test your endpoint
The quickest way: in your project's budget, press Send test under the Soft Kill action. FireShield sends a signed request exactly like a real one, with that threshold's thresholdPercent and event set to "test", so a handler that only acts on "soft_kill" won't degrade anything. It tells you right away if your endpoint can't be reached, rejects the signature, or doesn't answer 2xx.
Or send yourself a correctly signed request with the secret FireShield gave you:
SECRET='your-signing-secret'
TS=$(date +%s)
BODY='{"event":"soft_kill","cloudProjectId":"test","gcpProjectId":"test","thresholdPercent":90,"costAmount":90,"budgetAmount":100,"currency":"USD","reason":"Manual test","timestamp":"'$TS'"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST https://your-app.example.com/fireshield/soft-kill \
-H "Content-Type: application/json" \
-H "X-FireShield-Timestamp: $TS" \
-H "X-FireShield-Signature: $SIG" \
--data "$BODY"