A webhook is a URL of yours that Blixye sends a POST to every time something you subscribed to happens: a new call, a citation, a ban, a promotion… The event list and each one's scope is in API v1: events. They are created, changed and deleted through /v1/webhooks with the webhooks:manage scope (API v1: webhooks); a key can only subscribe to events covered by its own scopes.
A webhook is a URL of yours that Blixye sends a POST to every time something you subscribed to happens: a new call, a citation, a ban, a promotion… The event list and each one's scope is in API v1: events. They are created, changed and deleted through /v1/webhooks with the webhooks:manage scope (API v1: webhooks); a key can only subscribe to events covered by its own scopes.
NOTE
A webhook is a URL of yours that Blixye sends a POST to every time something you subscribed to happens: a new call, a citation, a ban, a promotion… The event list and each one's scope is in API v1: events. They are created, changed and deleted through /v1/webhooks with the webhooks:manage scope (API v1: webhooks); a key can only subscribe to events covered by its own scopes.
The destination
The destination
NOTE
The destination
NOTE
https only, no username or password in the URL, and 500 characters at most.
Every IP the name resolves to must be public: not private, loopback, link-local or reserved. It is checked on save and again on every delivery.
Redirects are not followed: a 3xx counts as a failure.
How many destinations you can have depends on the plan: 1 on Plus, 5 on Premium and 20 on Business.
What arrives
What arrives
NOTE
What arrives
NOTE
A POST with the event envelope as JSON: {"id", "type", "created_at", "data"}. It is the same envelope, with the same id, that GET /v1/events returns.
X-Blixye-Signature: t=<unix time>,v1=<hex HMAC-SHA256>.
X-Blixye-Event: the event type.
X-Blixye-Delivery: the delivery id, the same across all its retries. Use it, or the event id, to avoid processing anything twice.
User-Agent: always Blixye-Webhooks/1.0.
Verifying the signature
Verifying the signature
NOTE
Verifying the signature
The secret (whsec_…) is shown only once, when the webhook is created or its secret rotated. The signature is the HMAC-SHA256, with that secret, of the time t, a dot and the body EXACTLY as it arrives, byte for byte: do not re-serialise it. Compare in constant time and reject the delivery if t is more than 5 minutes away from your clock.
The secret (whsec_…) is shown only once, when the webhook is created or its secret rotated. The signature is the HMAC-SHA256, with that secret, of the time t, a dot and the body EXACTLY as it arrives, byte for byte: do not re-serialise it. Compare in constant time and reject the delivery if t is more than 5 minutes away from your clock.
NOTE
The secret (whsec_…) is shown only once, when the webhook is created or its secret rotated. The signature is the HMAC-SHA256, with that secret, of the time t, a dot and the body EXACTLY as it arrives, byte for byte: do not re-serialise it. Compare in constant time and reject the delivery if t is more than 5 minutes away from your clock.
In Node.js, with no dependencies:
In Node.js, with no dependencies:
NOTE
In Node.js, with no dependencies:
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.BLIXYE_WEBHOOK_SECRET;
function verify(header, rawBody) {
const m = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec(header || '');
if (!m) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(m[1])) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(m[1] + '.')
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verify(req.headers['x-blixye-signature'], rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
res.writeHead(204).end();
console.log(event.type, event.id, req.headers['x-blixye-delivery']);
});
}).listen(3000);
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.BLIXYE_WEBHOOK_SECRET;
function verify(header, rawBody) {
const m = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec(header || '');
if (!m) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(m[1])) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(m[1] + '.')
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verify(req.headers['x-blixye-signature'], rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
res.writeHead(204).end();
console.log(event.type, event.id, req.headers['x-blixye-delivery']);
});
}).listen(3000);
NOTE
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.BLIXYE_WEBHOOK_SECRET;
function verify(header, rawBody) {
const m = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec(header || '');
if (!m) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(m[1])) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(m[1] + '.')
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verify(req.headers['x-blixye-signature'], rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
res.writeHead(204).end();
console.log(event.type, event.id, req.headers['x-blixye-delivery']);
});
}).listen(3000);
In PHP:
In PHP:
NOTE
In PHP:
<?php
$secret = getenv('BLIXYE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BLIXYE_SIGNATURE'] ?? '';
if (!preg_match('/^t=([0-9]+),v1=([a-f0-9]{64})$/', $header, $m)
|| abs(time() - (int) $m[1]) > 300
|| !hash_equals(hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret), $m[2])) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(204);
error_log($event['type'] . ' ' . $event['id']);
<?php
$secret = getenv('BLIXYE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BLIXYE_SIGNATURE'] ?? '';
if (!preg_match('/^t=([0-9]+),v1=([a-f0-9]{64})$/', $header, $m)
|| abs(time() - (int) $m[1]) > 300
|| !hash_equals(hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret), $m[2])) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(204);
error_log($event['type'] . ' ' . $event['id']);
NOTE
<?php
$secret = getenv('BLIXYE_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BLIXYE_SIGNATURE'] ?? '';
if (!preg_match('/^t=([0-9]+),v1=([a-f0-9]{64})$/', $header, $m)
|| abs(time() - (int) $m[1]) > 300
|| !hash_equals(hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret), $m[2])) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(204);
error_log($event['type'] . ' ' . $event['id']);
Answer quickly with a 2xx and do the work afterwards: Blixye waits 5 seconds at most.
Answer quickly with a 2xx and do the work afterwards: Blixye waits 5 seconds at most.
NOTE
Answer quickly with a 2xx and do the work afterwards: Blixye waits 5 seconds at most.
Retries and shutdown
Retries and shutdown
NOTE
Retries and shutdown
NOTE
Deliveries go out in a pass that runs every minute: an event arrives within about a minute, not instantly.
A 2xx is a completed delivery. Anything else (a 3xx, 4xx or 5xx, a timeout or a network error) is a failure.
A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h: 8 attempts in total. After that it is given up.
10 CONSECUTIVE failures on the same destination, from any delivery, turn it off: it is left with disabled_reason too_many_failures and its pending deliveries are given up. A single success resets the count.
To turn it back on: PATCH /v1/webhooks/{id} with {"is_active": true}. It starts from zero, but whatever was lost while it was off is not re-sent: recover it with GET /v1/events.
Order is not guaranteed: a retry can arrive after newer events. Sort by created_at.
Delivery history is kept for 30 days.
When you rotate the secret (POST /v1/webhooks/{id}/rotate-secret), the old one stops signing immediately: every later delivery, retries included, uses the new one. Update your receiver right away.
When you rotate the secret (POST /v1/webhooks/{id}/rotate-secret), the old one stops signing immediately: every later delivery, retries included, uses the new one. Update your receiver right away.
NOTE
When you rotate the secret (POST /v1/webhooks/{id}/rotate-secret), the old one stops signing immediately: every later delivery, retries included, uses the new one. Update your receiver right away.
If you cannot receive webhooks (behind NAT, or a script that runs now and then), poll GET /v1/events: they are the same events (API v1: events).
If you cannot receive webhooks (behind NAT, or a script that runs now and then), poll GET /v1/events: they are the same events (API v1: events).
NOTE
If you cannot receive webhooks (behind NAT, or a script that runs now and then), poll GET /v1/events: they are the same events (API v1: events).