Webhooks

A webhook is an HTTP request Grihafy sends to your server when something happens to an analysis, so you do not have to keep checking. This page covers setup, the events, and how to verify that a request really came from Grihafy.

Set up an endpoint

  1. An organization owner or admin opens /org/your-org/settings/webhooks in the Grihafy web app.
  2. Enter your endpoint URL. It must use HTTPS and cannot point to a private network address.
  3. Copy the signing secret (whsec_...). It is shown once. Store it in your server configuration.
  4. Use the test button to send an example event to your endpoint.

Each organization has one endpoint. It receives both test and live events; check livemode to tell them apart. If no endpoint is enabled when an event happens, that event is not sent later, so set the endpoint up before you submit analyses.

Events

EventSent when
analysis.review_requiredAn analysis paused and needs your answer. Every flat of a multi-flat plan waiting for review sends its own event. See Review flows.
analysis.completedAn analysis finished and its result is ready.
analysis.failedAn analysis could not be completed.
analysis.expiredA paused analysis was not answered in time.
endpoint.testYou pressed the test button. It has no analysis data.

New event types may be added. Ignore types you do not recognize and still reply with a 2xx status. Event bodies are always in English.

What you receive

{
  "id": "evt_example",
  "object": "event",
  "type": "analysis.completed",
  "createdAt": "2026-10-12T10:01:12Z",
  "livemode": false,
  "data": { "analysis": { "id": "507f1f77bcf86cd799439011", "status": "completed" } }
}

data.analysis is the same object GET /v1/analyses/{id} returns at that moment. See the reference webhook schemas for every field. A completed, failed or expired event is sent after the credit has been settled, so the balance is already up to date.

Verify every request

Anyone can send a request to your URL, so check the signature before you trust it. Each request has a Grihafy-Signature header:

Grihafy-Signature: t=1791280872,v1=3f1c...e9a2
  1. Keep the request body as the exact bytes you received. Do not parse and re-serialize the JSON first; that changes the bytes.
  2. Build the text <t>.<raw body>, where t is the number in the header.
  3. Compute HMAC-SHA256 of that text with your signing secret. It must equal one of the v1 values, compared in constant time.
  4. Reject the request if t is more than five minutes from your clock.

When you rotate the secret, requests carry two v1 values for 24 hours. Pass both the new and the old secret during that time.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Reject old timestamps so a captured request cannot be replayed later.
const TOLERANCE_SECONDS = 5 * 60;

// Reads "t=<unix seconds>,v1=<hex>,v1=<hex>". Returns null if the header is malformed.
function parseSignatureHeader(header) {
  const parts = header.split(',').map((part) => part.trim());
  const timestamps = parts.filter((part) => part.startsWith('t='));
  const signatures = parts
    .filter((part) => part.startsWith('v1='))
    .map((part) => part.slice(3))
    .filter((hex) => /^[a-f0-9]{64}$/i.test(hex));
  if (timestamps.length !== 1 || !/^t=[0-9]+$/.test(timestamps[0])) return null;
  return { timestamp: timestamps[0].slice(2), signatures };
}

// rawBody: the exact Buffer you received, before any JSON parsing.
// secrets: [currentSecret], or [newSecret, oldSecret] during the 24-hour rotation overlap.
export function verifyWebhook(rawBody, header, secrets, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== 'string') return false;
  const parsed = parseSignatureHeader(header);
  if (!parsed) return false;

  const ageSeconds = Math.abs(now / 1000 - Number(parsed.timestamp));
  if (ageSeconds > TOLERANCE_SECONDS) return false;

  // The signed text is "<timestamp>.<raw body>". Compare in constant time.
  return secrets.some((secret) => {
    const expected = createHmac('sha256', secret)
      .update(parsed.timestamp + '.')
      .update(rawBody)
      .digest();
    return parsed.signatures.some((hex) => timingSafeEqual(expected, Buffer.from(hex, 'hex')));
  });
}

A complete receiver

This example verifies the signature, ignores duplicates, and replies quickly. Do slow work after you have replied or in a background job.

// Express example. Requires the verifyWebhook function above.
import express from 'express';

const WEBHOOK_SECRETS = [process.env.GRIHAFY_WEBHOOK_SECRET]; // whsec_... from your organization settings.
const seenEventIds = new Set(); // Use a database or cache in production.

const app = express();

// express.raw keeps the body as the exact bytes Grihafy signed. Do not use express.json() here.
app.post('/grihafy-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(req.body, req.get('Grihafy-Signature'), WEBHOOK_SECRETS)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString('utf8'));

  // The same event can be delivered more than once. Skip ones you have already handled.
  if (seenEventIds.has(event.id)) return res.sendStatus(200);
  seenEventIds.add(event.id);

  if (event.type === 'analysis.completed') {
    // Queue your own work here and return quickly. Fetch the analysis by ID if you need fresh data.
    console.log('Analysis completed:', event.data.analysis.id, 'live mode:', event.livemode);
  }
  // Ignore event types you do not recognize. Still reply 2xx so they are not retried.
  res.sendStatus(200);
});

app.listen(3000);

Retries and duplicates

TopicHow it works
SuccessReply with any 2xx status within 10 seconds. Redirects are not followed.
RetriesIf delivery fails, Grihafy tries again after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts in total.
DuplicatesAn event can arrive more than once. Use the event id to skip events you already handled. Do not use the signature timestamp for this.
OrderEvents can arrive out of order. When it matters, fetch the analysis and use its current status.
Delivery logOwners and admins can see recent deliveries for 30 days, and resend a failed one, in the webhook settings.