Reuse the secret returned when you provisioned the webhook so you can validate each signature.
1. Compute and compare signatures
import crypto from 'node:crypto';
const verifyWebhookSignature = (signature: string, payload: unknown) => {
const secret = process.env.HORIZON_WEBHOOK_SECRET!;
const computed = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(computed, 'hex'));
};
2. Handle webhook deliveries
app.post('/webhooks/horizon', async (req, res) => {
const signature = req.header('X-Horizon-Signature') ?? '';
if (!verifyWebhookSignature(signature, req.body)) {
return res.status(401).end();
}
const { jobId, state, result, error } = req.body;
if (state === 'succeeded') {
console.log('Artifacts ready', jobId, result);
} else if (state === 'failed') {
console.error('Job failed', jobId, error?.code, error?.message);
}
res.status(204).end();
});
3. Fall back to light polling
const shortPoll = async (statusUrl: string, retries = 3) => {
for (let attempt = 0; attempt < retries; attempt++) {
const status = await fetchWithPayment(statusUrl).then((res) => res.json());
if (status.state !== 'processing') {
return status;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
return null;
};
4. Clean up webhooks when done
await fetchWithPayment(`${baseUrl}/webhooks/${webhookId}`, {
method: 'DELETE',
});
- Keep separate secrets per environment so leaked tokens can be rotated quickly.
- Use short polling for the first few moments of a job, then rely on webhooks for canonical state.
- Log
deliveryId headers (if present) to deduplicate retries.