Verificación de firmas (borrador)
Borrador técnico en inglés de la verificación de firmas prevista para futuros webhooks.
Borrador técnico en inglés · no operativo
Los webhooks salientes todavía no entregan eventos. El cuerpo en inglés que sigue conserva una propuesta técnica: no es un contrato publicado ni una guía para activar una función disponible. No implementes una integración de producción contra estos ejemplos. La API pública actual ofrece suscriptores; consulta la hoja de ruta.
Webhooks are open URLs on the public internet. Anyone who guesses the path can POST to them. To prove that a delivery really came from Fluyio — and that nobody has tampered with the body — every request is signed with an HMAC SHA-256 you can verify with your endpoint secret.
Always verify signatures in production. Treat any request that fails verification as if it never happened — log it, return 400, and move on.
Anatomy of the signature
Every webhook request includes this header:
Fluyio-Signature: t=1715000000,v1=8d5b3c1e0a9f4f2c8e1d2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3dIt is a comma-separated list of key=value pairs:
| Key | Description |
|---|---|
t | Unix timestamp (seconds) when Fluyio signed the request. |
v1 | Hex HMAC SHA-256 of ${t}.${raw_body} using your endpoint secret. |
Future schemes will be added with new prefixes (v2, v3...). Always read the v1 value today; new versions will be additive.
The algorithm
Pull the raw body and the header
The body must be the exact bytes Fluyio sent — do not let your framework parse or re-serialize it before you compute the HMAC. Whitespace and key ordering matter.
Split the header
Parse t and v1 from the comma-separated list.
Build the signed payload
Concatenate: signed_payload = timestamp + "." + raw_body. Note the literal dot.
Compute the expected signature
expected = hex(hmac_sha256(secret, signed_payload)).
Compare in constant time
Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) to avoid timing attacks.
Check the timestamp
Reject any request where |now - t| > 300 seconds. This prevents replay attacks even if a signature leaks.
Code examples
import crypto from 'crypto';
import express from 'express';
const app = express();
const TOLERANCE_SECONDS = 5 * 60;
function verify(rawBody: Buffer, header: string, secret: string) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
) as { t?: string; v1?: string };
if (!parts.t || !parts.v1) throw new Error('malformed signature');
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > TOLERANCE_SECONDS) throw new Error('timestamp out of tolerance');
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('bad signature');
}
}
app.post(
'/webhooks/fluyio',
// CRITICAL: keep the body raw — do NOT use express.json() here.
express.raw({ type: 'application/json' }),
(req, res) => {
try {
verify(
req.body,
req.header('Fluyio-Signature')!,
process.env.FLUYIO_WEBHOOK_SECRET!,
);
} catch (err) {
console.warn('rejecting webhook', (err as Error).message);
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event...
res.status(200).send('ok');
},
);
app.listen(3000);Replay protection
The t timestamp prevents an attacker who captures a valid signature from replaying it hours or days later. Fluyio considers any request older than 300 seconds (5 minutes) invalid.
If your servers and Fluyio's clock drift by more than that, deliveries will be rejected. Sync your servers with NTP — don't widen the tolerance.
Rotating the secret
If you suspect a secret has been exposed:
- Open Webhooks in the dashboard and select the endpoint.
- Click Roll secret. A new secret is generated.
- For the next 24 hours both the new and the previous secret are valid, so in-flight deliveries keep verifying. Update your application during that window.
- After 24h the old secret is decommissioned.
Common pitfalls
Body parsed before verification. Frameworks like Express, Fastify and Flask default to parsing JSON, which re-serializes the body and changes the bytes. Always grab the raw request body before any middleware touches it.
Wrong character encoding. Fluyio signs UTF-8 bytes. If you decode and re-encode through latin-1, the HMAC won't match. Stay in bytes.
Non-constant-time comparison. Don't use == to compare HMACs — it leaks information through timing. Use crypto.timingSafeEqual, hmac.compare_digest, or hash_equals.
Reverse proxies stripping headers. Some proxies lowercase or drop X- and custom headers. Make sure Fluyio-Signature reaches your app.
What's next
- Retries — when and how often we retry failed deliveries.
- Best practices — idempotency, async processing, ordering.
- Events catalog — every event type and payload.