Webhooks
Webhooks let you receive real-time HTTP notifications when events happen in GeckoGuard — license authorizations, revocations, team changes, and more.
Event Catalog
Every delivery has the same envelope:
{
"event": "<event-name>",
"data": { /* event-specific fields, see below */ },
"timestamp": "2026-04-29T14:32:11.000Z"
}
License events
license.created
A license was created (single or as part of a bulk create).
{
"event": "license.created",
"data": {
"licenseId": "9c1a8b2e-…"
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
Fetch the full license with GET /v1/dashboard/licenses/:licenseId if you need its key, expiry, policy, or product details.
license.authorized
A license was successfully validated against your product policy.
{
"event": "license.authorized",
"data": {
"licenseId": "9c1a8b2e-…",
"productId": "abc-123-…",
"wasActivated": true,
"effectiveExpiresAt": "2026-12-31T23:59:59.000Z"
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
wasActivated: true means this call flipped activatedAt from null — the very first successful authorization. You'll only see this once per license.
license.authorization_denied
A license validation was rejected by policy. The reasonCode is one of the license reason codes.
{
"event": "license.authorization_denied",
"data": {
"licenseId": "9c1a8b2e-…",
"productId": "abc-123-…",
"reasonCode": "HWID_MISMATCH",
"message": "Hardware ID does not match bound device"
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
license.expired
A license tried to authorize past its expiry.
{
"event": "license.expired",
"data": {
"licenseId": "9c1a8b2e-…",
"productId": "abc-123-…",
"effectiveExpiresAt": "2026-04-28T00:00:00.000Z"
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
license.revoked / license.unrevoked
{
"event": "license.revoked",
"data": { "licenseId": "9c1a8b2e-…" },
"timestamp": "2026-04-29T14:32:11.000Z"
}
license.unrevoked fires when a previously-revoked license is restored. Same payload shape.
license.frozen / license.unfrozen
{
"event": "license.frozen",
"data": {
"licenseId": "9c1a8b2e-…",
"productId": "abc-123-…",
"frozenAt": "2026-04-29T14:32:11.000Z",
"frozenDaysRemaining": 7
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
license.unfrozen omits frozenAt and frozenDaysRemaining.
license.hwid_reset / license.ip_reset
Fires whether the reset came from the dashboard or the end-user portal.
{
"event": "license.hwid_reset",
"data": {
"licenseId": "9c1a8b2e-…",
"productId": "abc-123-…",
"resetCount": 3
},
"timestamp": "2026-04-29T14:32:11.000Z"
}
resetCount is the new total for that license.
Other events
| Event | Trigger | Payload |
|---|---|---|
api_key.created | API key issued | { apiKeyId, productId, name, last4 } |
api_key.revoked | API key revoked | { apiKeyId, productId } |
api_key.rotated | API key secret rotated | { apiKeyId, productId, last4 } |
team.member_added | New org member | { orgId, userId, role } |
team.member_removed | Member removed | { orgId, userId } |
test.ping | You called POST /v1/dashboard/webhooks/products/:productId/:endpointId/test | { message: "pong" } |
Managing Webhook Endpoints
Create an Endpoint
const response = await fetch('/v1/dashboard/webhooks/products/PRODUCT_ID', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_TOKEN',
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://your-server.com/webhooks/geckoguard',
events: ['license.authorized', 'license.revoked', 'license.expired'],
enabled: true
})
});
const { data } = await response.json();
// data.endpoint.secret = 'whsec_...' — save this for signature verification!
The response includes a secret — store it securely. You'll use it to verify webhook signatures.
Update an Endpoint
PATCH /v1/dashboard/webhooks/products/:productId/:endpointId
Update the URL, events list, or enabled state.
Delete an Endpoint
DELETE /v1/dashboard/webhooks/products/:productId/:endpointId
View Delivery History
GET /v1/dashboard/webhooks/products/:productId/:endpointId/deliveries?limit=50
Returns recent delivery attempts with status, success flag, attempts count, error messages, and nextRetryAt. Same data is rendered in the dashboard under Settings → Webhooks → [endpoint] → Deliveries.
Send a Test Event
POST /v1/dashboard/webhooks/products/:productId/:endpointId/test
Sends a test.ping event to verify your endpoint is working.
Delivery Semantics
The contract you can rely on:
- At-least-once delivery. A successful delivery means at least one HTTP
2xxresponse from your endpoint. You may receive the same event more than once — design handlers to be idempotent. The standard pattern: keep a small dedupe set of(event, licenseId, timestamp)or(event, licenseId, deliveryId)tuples for a few minutes and skip duplicates. - Per-endpoint fan-out. If an org has multiple endpoints subscribed to the same event, each one gets its own delivery + retry timeline. One slow or failing endpoint does not delay the others.
- Per-event subscription. An endpoint only receives events it explicitly subscribed to (via the
eventsarray on create). Events not in the subscription list are dropped silently — no delivery row, no retry. - Disabled or deleted endpoints. Disabled endpoints receive no new deliveries and any in-flight retries stop (the row is updated with
lastError: "Endpoint disabled or removed"andnextRetryAt: null).
Retry policy
| Trigger for retry | Network error or HTTP response not in the 2xx range |
| Max attempts | 6 total (1 initial + 5 retries) |
| Retry delays | 30s → 1m → 2m → 4m → 8m → 16m (exponential, base 30s) |
| Total window | ~32 minutes from first attempt to last retry |
| Per-attempt timeout | 10 seconds |
| Permanent failures | SSRF / private-IP / redirect-refusal responses are not retried — your endpoint resolved to a private IP or refused, and that won't change |
After 6 failed attempts the delivery is marked permanently failed. You can replay any delivery manually:
POST /v1/dashboard/webhooks/products/:productId/deliveries/:deliveryId/replay
(Requires the endpoint to be enabled — disabled endpoints return 400 ENDPOINT_DISABLED.)
Ordering
Deliveries are not ordered. Two events fired in quick succession may arrive in either order. If your handler depends on order (e.g. "license.created must arrive before license.authorized"), don't rely on delivery sequence — fetch fresh state with GET /v1/dashboard/licenses/:id when you need a consistent snapshot.
Best practices
- Respond
2xximmediately, then process asynchronously. Your endpoint has 10 seconds before the request is considered failed and retried. Hand off to a queue, ack with200, do the work after. - Verify the signature before parsing. A failed signature is a stronger signal than parser exceptions. See Signature Verification below.
- Log the
X-GeckoGuard-TimestampanddeliveryId. The delivery row in the dashboard shares the same ID — useful for cross-referencing when something goes wrong.
Webhook Payload Format
{
"event": "license.authorized",
"data": {
"licenseId": "license-uuid",
"productId": "product-uuid",
"wasActivated": false,
"effectiveExpiresAt": "2025-12-31T23:59:59.000Z"
},
"timestamp": "2025-01-15T10:30:00.000Z"
}
Signature Verification
Every webhook request includes an HMAC-SHA256 signature in the X-GeckoGuard-Signature header. Verify it to ensure the request is authentic:
const crypto = require('crypto');
function verifyWebhook(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express example
app.post('/webhooks/geckoguard', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-geckoguard-signature'];
const body = req.body.toString();
if (!verifyWebhook(body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(body);
console.log('Received:', event.event, event.data);
res.status(200).send('OK');
});
Request Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-GeckoGuard-Signature | HMAC-SHA256 signature of the body |
X-GeckoGuard-Event | Event type (e.g., license.authorized) |
User-Agent | GeckoGuard-Webhooks/1.0 |
Delivery Behavior
- Webhook requests have a 10-second timeout
- A
2xxresponse is considered successful - Failed deliveries are recorded with the error message
- Delivery history is available via the dashboard API
Best Practices
- Always verify signatures — reject unsigned or invalid requests
- Respond quickly — return
200immediately, process asynchronously if needed - Handle duplicates — use idempotent processing in case of retries
- Subscribe only to events you need — reduces noise and load
- Monitor delivery history — check for failing endpoints regularly
- Use the test endpoint — verify your handler works before going live