Webhooks
A webhook subscription tells the platform to POST a signed JSON body to an endpoint
of yours whenever one of your systems raises or resolves an alert. It is the
alternative to polling GET /alerts, and it is the one you want.
All webhook management endpoints require the webhooks:manage scope.
Event catalogue
Section titled “Event catalogue”| Event type | Sent when | data |
|---|---|---|
alert.raised |
An alert moves to open |
The alert object |
alert.resolved |
An alert moves to resolved |
The alert object, with status: "resolved" and resolvedAt set |
webhook.test |
You call the test endpoint | { "message": "...", "requestedAt": "..." } |
alert.raised and alert.resolved are the two types a subscription can list.
webhook.test is implicit: every subscription receives it when you ask for a test,
and it cannot be subscribed to or unsubscribed from.
The live list is also available from the API:
GET /api/v1/external/webhooks/event-types scope webhooks:manageNew event types are added over time. Ignore a type you do not recognise rather
than failing on it.
Subscribing
Section titled “Subscribing”POST /api/v1/external/webhooks scope webhooks:managecurl -sS -X POST https://api.claytonpower.com/api/v1/external/webhooks \ -H "X-API-Key: $CP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Ops alerting", "endpointUrl": "https://hooks.example.com/clayton-power", "eventTypes": ["alert.raised", "alert.resolved"] }'{ "subscription": { "id": "b8c2f5a1-9e47-4d3b-8f60-1a7c4e92d5b8", "name": "Ops alerting", "endpointUrl": "https://hooks.example.com/clayton-power", "eventTypes": ["alert.raised", "alert.resolved"], "isActive": true, "consecutiveFailures": 0, "disabledReason": null, "disabledAt": null, "createdAt": "2026-09-17T10:02:44Z", "updatedAt": null }, "secret": "whsec_Qx7mR2vT9nK4pL1sW6yZ8cB3dF5gH0jN7qU2eA4iO6k"}Store the secret now. It is returned on create and on rotate, and never again -
GET /webhooks and GET /webhooks/{id} deliberately omit it.
The other management endpoints:
| Endpoint | Purpose |
|---|---|
GET /webhooks |
List your subscriptions |
GET /webhooks/{id} |
One subscription, including isActive and disabledReason |
PUT /webhooks/{id} |
Replace name, endpoint, event types and active flag |
DELETE /webhooks/{id} |
Delete the subscription and its delivery history |
POST /webhooks/{id}/rotate-secret |
Issue a new signing secret |
POST /webhooks/{id}/test |
Queue a webhook.test delivery to this subscription |
GET /webhooks/{id}/deliveries |
Delivery history, newest first |
PUT replaces the whole subscription: send name, endpointUrl, eventTypes and
isActive every time, not just the field you are changing.
An id belonging to another company returns 404, never 403.
Endpoint requirements
Section titled “Endpoint requirements”The endpoint URL is validated before it is stored, and again on every delivery
attempt - DNS can be re-pointed after the fact. A URL that fails validation is
rejected with 400 and a detail naming the reason.
Your endpoint must:
- use
https. Plain http is rejected. - use the default port (443).
https://example.com:8443/hookis rejected. - use a hostname, not an IP address literal.
- resolve to public unicast addresses only. Every A and AAAA record must be public: loopback, private ranges, link-local (including the cloud metadata address) and multicast are all rejected, and one private record among several is enough to reject the host.
- carry no credentials in the URL.
https://user:pass@example.com/hookis rejected; authenticate with the signature instead. - respond with any 2xx within 10 seconds. Anything else counts as a failed attempt.
Two more things to know about how we call you:
- Redirects are not followed. A
301or302is a failed attempt. Register the final URL. - The response body is discarded. Only the status code matters, so there is no point returning data.
Acknowledge fast and process asynchronously. Queue the delivery, return 200, do the
work afterwards. A handler that writes to a slow downstream system inside the request
will start timing out under load, and 50 consecutive timeouts disable the
subscription.
The envelope
Section titled “The envelope”Every delivery has the same four metadata fields; only data varies by type.
{ "id": "0d9a3f7c-5b12-4e88-9a34-6c7e2f1b0d45", "type": "alert.raised", "createdAt": "2026-09-17T09:41:31Z", "apiVersion": "v1", "data": { "id": "6b1f5c8e-3a7d-4f21-9c0b-2d5e8a41f7b3", "kind": "soc.low", "severity": "warning", "status": "open", "serialNumber": "1234567890", "title": "Low state of charge", "message": "Battery on 1234567890 is at 14%, at or below the 20% alert level.", "data": { "socPercent": 14.0, "thresholdPercent": 20, "source": "device" }, "raisedAt": "2026-09-17T09:41:30Z", "resolvedAt": null }}idis the delivery id, also sent as theX-CP-Delivery-Idheader. Use it to de-duplicate.typeis one of the event types above.createdAtis when the delivery was queued, not when this attempt was sent. A retry carries the originalcreatedAt.apiVersionis the envelope version, currentlyv1. It is independent of the URL path version.- Field names are camelCase and the property order is stable.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type |
application/json |
X-CP-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-CP-Event |
The event type, for example alert.raised |
X-CP-Delivery-Id |
The delivery id, same as id in the envelope |
User-Agent |
ClaytonPower-Webhooks/1.0 |
Do not use User-Agent for authentication. The signature is the only thing that
proves a request came from us.
Verifying the signature
Section titled “Verifying the signature”X-CP-Signature: t=1758105600,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08t is the signing time in whole unix seconds. v1 is the lowercase hex HMAC-SHA256
of the string "<t>.<raw body>", keyed with your subscription secret.
Four rules, all of them load-bearing:
- Sign the raw body. Verify against the exact bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will not match. In most frameworks this means asking for the raw body explicitly.
- Build the signed string as
t, a dot, then the body. The timestamp is inside the signature, which is what stops a captured body being replayed later. - Reject a timestamp more than 5 minutes from your clock, in either direction. Keep your server’s clock in sync.
- Compare in constant time. A byte-by-byte comparison that returns early leaks the expected signature.
Ignore parts of the header you do not recognise. The format is a comma-separated list
so that a future v2= can be added without breaking receivers that only read v1.
Node.js
Section titled “Node.js”import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** * @param {string} secret The subscription secret (whsec_...) * @param {string} header The raw X-CP-Signature value * @param {string} body The raw request body, exactly as received */export function verifyWebhook(secret, header, body) { if (!secret || !header) return false;
let timestamp = null; let signature = null;
for (const part of header.split(',')) { const index = part.indexOf('='); if (index <= 0) continue; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key === 't') timestamp = value; else if (key === 'v1') signature = value; }
if (!timestamp || !signature) return false;
const signedAt = Number(timestamp); if (!Number.isFinite(signedAt)) return false;
const ageSeconds = Math.floor(Date.now() / 1000) - signedAt; if (Math.abs(ageSeconds) > TOLERANCE_SECONDS) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${body}`, 'utf8') .digest('hex');
const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(signature, 'utf8'); return a.length === b.length && crypto.timingSafeEqual(a, b);}With Express, make sure you get the raw body:
import express from 'express';
const app = express();
app.post( '/clayton-power', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8');
if (!verifyWebhook(process.env.CP_WEBHOOK_SECRET, req.get('X-CP-Signature'), raw)) { return res.status(400).send('invalid signature'); }
const event = JSON.parse(raw);
// Acknowledge first, work afterwards. res.sendStatus(200); void handleEvent(event, req.get('X-CP-Delivery-Id')); },);Python
Section titled “Python”import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_webhook(secret: str, header: str, body: bytes) -> bool: """secret: whsec_... header: raw X-CP-Signature body: raw request bytes.""" if not secret or not header: return False
timestamp = None signature = None
for part in header.split(","): key, separator, value = part.strip().partition("=") if not separator: continue if key == "t": timestamp = value elif key == "v1": signature = value
if not timestamp or not signature: return False
try: signed_at = int(timestamp) except ValueError: return False
if abs(int(time.time()) - signed_at) > TOLERANCE_SECONDS: return False
expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + body, hashlib.sha256, ).hexdigest()
return hmac.compare_digest(expected, signature)With Flask:
from flask import Flask, request
app = Flask(__name__)
@app.post("/clayton-power")def clayton_power_webhook(): raw = request.get_data() # bytes, before any JSON parsing
if not verify_webhook(SECRET, request.headers.get("X-CP-Signature", ""), raw): return "invalid signature", 400
event = request.get_json() enqueue(event, request.headers.get("X-CP-Delivery-Id")) return "", 200using System.Security.Cryptography;using System.Text;
public static class ClaytonPowerWebhookSignature{ private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
/// <param name="secret">The subscription secret (whsec_...).</param> /// <param name="header">The raw X-CP-Signature value.</param> /// <param name="body">The raw request body, exactly as received.</param> public static bool Verify(string secret, string header, string body) { if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(header)) return false;
long? timestamp = null; string? signature = null;
foreach (var part in header.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var separator = part.IndexOf('='); if (separator <= 0) continue;
var key = part[..separator]; var value = part[(separator + 1)..];
if (key == "t" && long.TryParse(value, out var parsed)) timestamp = parsed; else if (key == "v1") signature = value; }
if (timestamp is null || string.IsNullOrEmpty(signature)) return false;
var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp.Value); if (age > Tolerance || age < -Tolerance) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp.Value}.{body}")); var expected = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)); }}In ASP.NET Core, read the raw body before model binding:
[HttpPost("/clayton-power")]public async Task<IActionResult> Receive(){ using var reader = new StreamReader(Request.Body, Encoding.UTF8); var raw = await reader.ReadToEndAsync();
var header = Request.Headers["X-CP-Signature"].ToString(); if (!ClaytonPowerWebhookSignature.Verify(_secret, header, raw)) return BadRequest();
_queue.Enqueue(raw, Request.Headers["X-CP-Delivery-Id"].ToString()); return Ok();}Idempotency
Section titled “Idempotency”Assume you will receive the same event more than once. A delivery that times out
after your handler already committed its work is retried, and the retry is
byte-identical - same envelope, same id.
De-duplicate on X-CP-Delivery-Id, which is the same value as id in the envelope:
- Look up the delivery id in a store of ids you have already processed.
- If it is there, return
200and stop. - Otherwise process the event, record the id, return
200.
Keep the ids for at least a few hours - long enough to cover the full retry schedule, which spans about two and a half hours.
Do not de-duplicate on the alert id instead. A single alert legitimately produces two
events, alert.raised and later alert.resolved, with the same alert id in data.
Retries
Section titled “Retries”A delivery that does not get a 2xx is retried with a fixed schedule:
| Attempt | Sent |
|---|---|
| 1 | Immediately when the event occurs |
| 2 | 10 minutes after attempt 1 |
| 3 | 20 minutes after attempt 2 |
| 4 | 40 minutes after attempt 3 |
| 5 | 80 minutes after attempt 4 |
After the fifth failed attempt the delivery is failed and is not retried again.
That event is gone; there is no replay endpoint in v1. If you need to recover, read
GET /alerts with since set to before the outage.
Delivery status values:
| Status | Meaning |
|---|---|
pending |
Queued, not attempted yet |
retrying |
Attempted and failed; nextRetryAt holds the next attempt |
delivered |
Accepted by your endpoint with a 2xx |
failed |
Terminal: attempts exhausted, or the endpoint stopped passing validation |
Inspect the history:
curl -sS -G "https://api.claytonpower.com/api/v1/external/webhooks/$ID/deliveries" \ -H "X-API-Key: $CP_API_KEY" \ --data-urlencode "status=failed"Each row carries attemptCount, lastAttemptAt, lastResponseCode, lastError,
nextRetryAt and deliveredAt, which is usually enough to tell a TLS problem from a
timeout from a 500 on your side.
Auto-disable and re-enabling
Section titled “Auto-disable and re-enabling”After 50 consecutive failed attempts, the subscription is deactivated. The counter is consecutive: any successful delivery resets it to zero.
A disabled subscription shows up on GET /webhooks/{id} as:
{ "isActive": false, "consecutiveFailures": 50, "disabledReason": "auto-disabled after 50 consecutive failed deliveries", "disabledAt": "2026-09-17T14:20:11Z"}While it is disabled, no new deliveries are queued for it and queued ones are abandoned rather than drained to a dead endpoint.
To re-enable it: fix the endpoint, then PUT /webhooks/{id} with isActive: true.
That clears disabledReason and resets the failure counter. Send the full body:
curl -sS -X PUT "https://api.claytonpower.com/api/v1/external/webhooks/$ID" \ -H "X-API-Key: $CP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Ops alerting", "endpointUrl": "https://hooks.example.com/clayton-power", "eventTypes": ["alert.raised", "alert.resolved"], "isActive": true }'Then confirm with a test delivery before you rely on it:
curl -sS -X POST "https://api.claytonpower.com/api/v1/external/webhooks/$ID/test" \ -H "X-API-Key: $CP_API_KEY"Events that occurred while the subscription was disabled are not replayed.
Rotating the secret
Section titled “Rotating the secret”POST /api/v1/external/webhooks/{id}/rotate-secret scope webhooks:manageThe new secret is returned once. There is no overlap window in v1: the moment the call returns, deliveries are signed with the new secret only. Update your receiver in the same operation, or accept a short window of rejected deliveries - they will be retried, so a receiver updated within a few minutes loses nothing.
Rotate when the secret may have leaked, and when someone with access to it leaves.
Testing your receiver
Section titled “Testing your receiver”- Create the subscription and store the secret.
POST /webhooks/{id}/test. You get awebhook.testenvelope.- Check that your verification passes, then deliberately break it: change one character of the secret and confirm you reject the delivery.
- Send the same delivery id twice and confirm your handler is idempotent.
- Check
GET /webhooks/{id}/deliveriesand confirm the test showsdelivered.
A local development endpoint cannot be registered: it is not a public https host. Use a tunnelling service with a public https hostname on the default port, or a staging receiver.