Skip to main content
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.