Webhooks

Genaya pushes signed events to your server the moment they happen - no polling required.

Create webhook endpoints in the Genaya app under Settings > Developer > Webhooks, or programmatically via POST /v1/webhook-endpoints. Each endpoint is an HTTPS URL subscribed to one or more event types (or ["*"] for all of them); an organization can have up to 10 endpoints, shared across both surfaces. URLs must be https - plaintext http, private or internal network addresses, and URLs with embedded credentials are rejected.

The event envelope

Every delivery is an HTTPS POST with a JSON body in one consistent envelope:

JSON
{
  "id": "evt_8ZwQ3kX9pL2mN0aB",
  "object": "event",
  "type": "invoice.paid",
  "created": "2026-08-03T17:24:05.000Z",
  "organization_id": "9f2kX0aBcDeFgHiJ",
  "data": {
    "object": {
      "object": "invoice",
      "id": "7Qx2mF0aBcDeFgHiJkLm",
      "number": "1042",
      "status": "paid",
      "total": 450,
      "amount_paid": 450,
      "balance": 0,
      "client": { "name": "Jordan Avery", "phone": "+14155550142", "email": "jordan.avery@example.com" },
      "company_id": "cmp_9aF2"
    }
  }
}

data.object is a thin, event-specific summary: its own object key names the entity and it always carries the record's id plus the few fields you route on. When you need the full record, fetch it with an authenticated GET (for example GET /v1/invoices/{id}). Each request also carries the headers X-Genaya-Event (the event type), X-Genaya-Delivery (the delivery id), and X-Genaya-Signature (see Verifying signatures below).

Event types

EventFires when
appointment.createdAn appointment is created
appointment.updatedAn appointment is updated
appointment.status_changedAn appointment's status changes
appointment.deletedAn appointment is deleted
client.createdA client is created
lead.createdA lead is created
invoice.createdAn invoice is created
invoice.sentAn invoice is sent
invoice.paidAn invoice is paid in full
estimate.createdAn estimate is created
estimate.sentAn estimate is sent
estimate.status_changedAn estimate's status changes
payment.receivedA payment is received
payment.refundedA payment is refunded, in full or in part
payment.failedA payment attempt fails (card declined, bank return)
call.missedAn inbound call is missed
call.completedA call completes
message.receivedA message arrives on any channel - SMS/MMS, WhatsApp, Messenger, Instagram, the live chat widget, or a lead feed. `channel` on the payload says which
message.sentA message is sent on any channel by a team member, an automation or the AI receptionist
form.submittedA public form is submitted
review.receivedA review is received

The same catalog is available machine-readably at GET /v1/webhook-endpoints/events, so an integration can present the list without hardcoding it. New event types may be added over time - treat unrecognized types as ignorable rather than as errors. (The Send test event button in Settings > Developer > Webhooks delivers a synthetic ping event that is not part of the catalog.)

Delivery and retries

  • Delivery is AT-LEAST-ONCE - the same event can occasionally arrive more than once. Deduplicate on the event id.
  • Respond with a 2xx status within 10 seconds. Anything else - an error status, a redirect, or a timeout - counts as a failed attempt.
  • Failed attempts are retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, then 24 hours - 7 total attempts across about a day and a half - before the delivery is marked dead.
  • An endpoint whose deliveries keep going dead is automatically paused (status auto_disabled). Fix the receiver, then set status back to "enabled" via PATCH /v1/webhook-endpoints/{id} or in Settings > Developer > Webhooks - re-enabling also resets the failure streak.
  • Deliveries are not guaranteed to arrive in order - sort on the envelope's created timestamp if ordering matters.

Verifying signatures

Every delivery is signed with the endpoint's secret. The X-Genaya-Signature header is t=<unix>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with the secret. Verify with a timing-safe compare against the RAW request body (before any JSON parsing), and reject stale timestamps to block replay:

Node
const crypto = require("crypto");
const express = require("express");

const app = express();
// The whsec_... secret from the endpoint's create response.
const WEBHOOK_SECRET = process.env.GENAYA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

app.post(
  "/genaya/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("X-Genaya-Signature") || "";
    const parts = Object.fromEntries(
      header.split(",").map((pair) => pair.split("="))
    );
    const t = Number(parts.t);
    const v1 = String(parts.v1 || "");

    // Reject stale or missing timestamps (replay protection).
    if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
      return res.status(400).send("stale timestamp");
    }

    // HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret.
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(t + "." + req.body.toString("utf8"))
      .digest("hex");

    // Timing-safe compare.
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(v1, "hex");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send("bad signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // Deduplicate on event.id - delivery is at-least-once.
    console.log("received", event.type, event.id);

    res.status(200).send("ok");
  }
);

app.listen(3000);
warningThe full signing secret is returned exactly once - in the 201 response that created the endpoint - and masked (whsec_...ab12) in every later read. It cannot be re-revealed; delete the endpoint and create a new one to rotate it.

REST hooks for integration platforms

The endpoint API is a standard REST-hook surface, which is how integration platforms (Zapier, Make, and similar) subscribe to events: POST /v1/webhook-endpoints with the target URL and the events to watch, store the returned id and secret, and DELETE /v1/webhook-endpoints/{id} to unsubscribe. GET /v1/webhook-endpoints/events supplies the trigger list dynamically.