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:
{
"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
| Event | Fires when |
|---|---|
appointment.created | An appointment is created |
appointment.updated | An appointment is updated |
appointment.status_changed | An appointment's status changes |
appointment.deleted | An appointment is deleted |
client.created | A client is created |
lead.created | A lead is created |
invoice.created | An invoice is created |
invoice.sent | An invoice is sent |
invoice.paid | An invoice is paid in full |
estimate.created | An estimate is created |
estimate.sent | An estimate is sent |
estimate.status_changed | An estimate's status changes |
payment.received | A payment is received |
payment.refunded | A payment is refunded, in full or in part |
payment.failed | A payment attempt fails (card declined, bank return) |
call.missed | An inbound call is missed |
call.completed | A call completes |
message.received | A 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.sent | A message is sent on any channel by a team member, an automation or the AI receptionist |
form.submitted | A public form is submitted |
review.received | A 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:
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);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.