Webhooks
CertifiEd notifies external systems about license events through outgoing webhooks. Delivery is performed by the WebhookDispatchWorker background worker — with retries and an HMAC signature over the request body.
Endpoint management is operator-only, through the panel API at /api/v1/panel/webhooks — see the API reference.
Events
The live list comes from GET /api/v1/panel/webhooks/event-types (sourced from WebhookEventTypes).
| Event | Published when | Key `data` fields |
|---|---|---|
license.issued | A license has been issued | licenseId, licenseKey, companyId, templateId, expiresAt |
license.activated | First activation on a new machine | licenseId, licenseKey, activationId, hwFingerprint, machineName, clientIp |
license.deactivated | An activation was dropped | licenseId, licenseKey, activationId |
license.revoked | A license was revoked | licenseId, licenseKey, reason, revokedAt |
license.expired | A license expired | depends on the event source |
license.heartbeat_missed | An expected heartbeat was missed | depends on the event source |
An endpoint subscribes to a subset of events (events); deliveries are only created for active (isActive) endpoints subscribed to that type.
Delivery format
A POST to the endpoint URL; the body is a JSON envelope:
{ "event": "license.issued", "occurredAt": "2026-07-16T14:06:59.1234567+00:00", "data": { "licenseId": "019f6b40-89c4-7d41-be2a-4a151a3c0063", "licenseKey": "CFED-S7FN-RJFA-KAEY-8KZC", "companyId": "019f6b40-0f9c-7efd-996b-0dadd2ed1077", "templateId": "019f6b40-0fb8-78f0-9af2-e3589de54069", "expiresAt": "2027-07-16T14:06:59+00:00" }}| Header | Value |
|---|---|
Content-Type | application/json |
X-CertifiEd-Event | The event type, e.g. license.issued |
X-CertifiEd-Delivery | Delivery GUID — use it for idempotency on the receiver |
X-CertifiEd-Signature | sha256={hex} — HMAC-SHA256 of the body |
The X-CertifiEd-Signature header
X-CertifiEd-Signature: sha256=<hex( HMAC_SHA256(key = endpoint.secret, msg = raw_body) )>- Key — the endpoint secret (
secret, at least 16 characters) set at creation time. - Message — the raw request body bytes (UTF-8) exactly as received: do not reformat the JSON before verifying.
- Value — lowercase hex prefixed with
sha256=.
Receiver-side verification — C# (ASP.NET Core)
[HttpPost("/webhooks/certified")]public async Task<IActionResult> Receive(){ // 1. Raw body — before any deserialization. using var reader = new StreamReader(Request.Body); var body = await reader.ReadToEndAsync();
// 2. Expected signature. var expected = "sha256=" + Convert.ToHexStringLower( System.Security.Cryptography.HMACSHA256.HashData( Encoding.UTF8.GetBytes(secret), // the endpoint secret Encoding.UTF8.GetBytes(body)));
var received = Request.Headers["X-CertifiEd-Signature"].ToString();
// 3. Constant-time comparison. var ok = CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(received)); if (!ok) return Unauthorized();
// 4. Idempotency by X-CertifiEd-Delivery, then handle the event. var deliveryId = Request.Headers["X-CertifiEd-Delivery"].ToString(); // ... dedupe(deliveryId); handle(body); return Ok();}Receiver-side verification — Node.js (Express)
const crypto = require('crypto');
// Raw body required: express.raw({ type: 'application/json' })app.post('/webhooks/certified', express.raw({ type: 'application/json' }), (req, res) => { const body = req.body; // Buffer with the raw bytes const expected = 'sha256=' + crypto .createHmac('sha256', secret) // the endpoint secret .update(body) .digest('hex');
const received = req.get('X-CertifiEd-Signature') || ''; const a = Buffer.from(expected); const b = Buffer.from(received); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.status(401).end(); }
const deliveryId = req.get('X-CertifiEd-Delivery'); const payload = JSON.parse(body.toString('utf8')); // ... dedupe(deliveryId); handle(payload); res.status(200).end();});Retry policy
The worker polls the queue every 15 seconds and sends due deliveries. Success means 2xx; any other response or a network error triggers a retry.
| Attempt | Delay before the next one |
|---|---|
| 1 | +1 minute |
| 2 | +5 minutes |
| 3 | +30 minutes |
| 4 | +2 hours |
| 5 | +12 hours |
| after 5 | status Failed, retries stop |
The delivery HTTP timeout is 30 seconds. The receiver's response body is stored (up to 2000 characters) in responseBody, the status code in responseStatusCode and the last error in lastError.
Replay (manual re-send)
POST /api/v1/panel/webhooks/deliveries/{deliveryId}/replayThis resets the delivery: attemptCount → 0, status → Pending, nextAttemptAt → now. On the next cycle the worker sends it again — handy after fixing the receiver or while testing signature verification. The endpoint's delivery history is at GET /api/v1/panel/webhooks/{id}/deliveries.