Skip to content

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

New event types are added over time. Ignore a type you do not recognise rather than failing on it.

POST /api/v1/external/webhooks scope webhooks:manage
Terminal window
curl -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.

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/hook is 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/hook is 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 301 or 302 is 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.

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
}
}
  • id is the delivery id, also sent as the X-CP-Delivery-Id header. Use it to de-duplicate.
  • type is one of the event types above.
  • createdAt is when the delivery was queued, not when this attempt was sent. A retry carries the original createdAt.
  • apiVersion is the envelope version, currently v1. It is independent of the URL path version.
  • Field names are camelCase and the property order is stable.
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.

X-CP-Signature: t=1758105600,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

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

  1. 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.
  2. 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.
  3. Reject a timestamp more than 5 minutes from your clock, in either direction. Keep your server’s clock in sync.
  4. 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.

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'));
},
);
import hashlib
import hmac
import 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 "", 200
using 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();
}

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:

  1. Look up the delivery id in a store of ids you have already processed.
  2. If it is there, return 200 and stop.
  3. 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.

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

POST /api/v1/external/webhooks/{id}/rotate-secret scope webhooks:manage

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

  1. Create the subscription and store the secret.
  2. POST /webhooks/{id}/test. You get a webhook.test envelope.
  3. Check that your verification passes, then deliberately break it: change one character of the secret and confirm you reject the delivery.
  4. Send the same delivery id twice and confirm your handler is idempotent.
  5. Check GET /webhooks/{id}/deliveries and confirm the test shows delivered.

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.