Skip to content

🔗 Webhooks

Webhooks let your own systems react to what happens inside Ainisa in real time. Whenever a lead is captured or a booking is made, updated, or cancelled, Ainisa sends an HTTP POST request to a URL you control.

Use webhooks to:

  • Sync leads and bookings into your CRM
  • Trigger automations in n8n, Zapier, or Make
  • Notify your team in real time
  • Feed data into your own database or dashboards

Availability

Webhooks are available on paid plans. If your subscription lapses, your webhook configuration is kept but deliveries stop until the subscription is active again.


📍 Creating a Webhook

Navigate to:

Dashboard → Webhooks → Create webhook

Fill in the basic information:

FieldDescription
NameInternal label for your reference (e.g. CRM sync)
URLThe HTTPS endpoint that will receive events. Only https:// is accepted
Event TypesWhich events this webhook should receive
StatusActive or inactive. An inactive webhook receives nothing

When you create a webhook, Ainisa generates a signing secret and shows it to you once. Copy it immediately and store it securely — it is not shown again. You need it to verify incoming requests.

WARNING

You can create a limited number of webhooks per team. If you reach the limit, delete one you no longer need.


📡 Available Events

An event name has the form resource.action.

EventFired when
lead.createdA new lead is captured
lead.updatedA lead is changed (content or status)
lead.cancelledA lead is cancelled
booking.createdA new booking is made
booking.updatedA booking is rescheduled or its contact details change
booking.cancelledA booking is cancelled

Some *.updated events include a change_type field so you can tell what kind of update it was (for example rescheduled, contact_updated, or status_changed).


📦 Request Format

Each delivery is an HTTP POST with a JSON body and the following headers:

HeaderDescription
Content-TypeAlways application/json
User-AgentAinisa-Webhooks/1.0
X-Ainisa-EventThe event name, e.g. lead.created
X-Ainisa-Event-IdThe global event ID (used for idempotency)
X-Ainisa-DeliveryUnique ID for this specific delivery attempt
X-Ainisa-TimestampUnix timestamp (seconds) when the request was signed
X-Ainisa-SignatureSignature used to verify authenticity

Envelope

Every payload shares the same top-level structure:

json
{
  "id": "8c1f...abc",
  "event": "lead.created",
  "timestamp": "2026-09-09T15:20:00+00:00",
  "data": {
    // event-specific fields
  }
}
FieldDescription
idGlobal event ID (same as the X-Ainisa-Event-Id header)
eventThe event name
timestampISO 8601 time the event was generated
dataThe event-specific body

🔐 Verifying Signatures

Anyone who learns your URL could send fake requests. To confirm a request genuinely came from Ainisa, verify the signature on every request using your signing secret.

The signature header

X-Ainisa-Signature looks like this:

text
t=1757430000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
PartDescription
tThe timestamp the request was signed (same as X-Ainisa-Timestamp)
v1A hex-encoded HMAC-SHA256 signature

How to verify

  1. Extract the t and v1 values from the header.
  2. Build the signed string by joining the timestamp and the raw request body with a period: {t}.{body}.
  3. Compute an HMAC-SHA256 of that string using your signing secret as the key.
  4. Compare your result to v1 using a constant-time comparison.
  5. Reject the request if they don't match.

Use the raw body

Sign the exact bytes you received, before any JSON parsing or re-serialization. Re-encoding the JSON can change whitespace or key order and break the signature.

Timestamp tolerance (replay protection)

Also check that t is within about 5 minutes of your server's current time, and reject anything older. This prevents an attacker from capturing a valid request and replaying it later.

Examples

php
function verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$k, $v] = array_pad(explode('=', $piece, 2), 2, '');
        $parts[$k] = $v;
    }

    $timestamp = $parts['t'] ?? null;
    $signature = $parts['v1'] ?? null;
    if (!$timestamp || !$signature) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false; // replay protection
    }

    $expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);

    return hash_equals($expected, $signature);
}

// Usage
$payload = file_get_contents('php://input');       // raw body
$header  = $_SERVER['HTTP_X_AINISA_SIGNATURE'] ?? '';
if (!verify($payload, $header, 'whsec_your_secret')) {
    http_response_code(401);
    exit;
}
javascript
const crypto = require('crypto');

function verify(payload, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=', 2))
  );
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) {
    return false; // replay protection
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Usage (Express) — capture the RAW body:
// app.use(express.raw({ type: 'application/json' }))
app.post('/webhook', (req, res) => {
  const raw = req.body.toString('utf8');
  const header = req.get('X-Ainisa-Signature') || '';
  if (!verify(raw, header, 'whsec_your_secret')) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(raw);
  // ... handle event
  res.sendStatus(200);
});
python
import hashlib
import hmac
import time


def verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(
        piece.split("=", 1) for piece in header.split(",") if "=" in piece
    )
    timestamp = parts.get("t")
    signature = parts.get("v1")
    if not timestamp or not signature:
        return False

    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # replay protection

    signed = f"{timestamp}.".encode() + payload
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature)


# Usage (Flask) — use request.get_data() for the raw body
from flask import request, abort

@app.route("/webhook", methods=["POST"])
def webhook():
    raw = request.get_data()  # raw bytes
    header = request.headers.get("X-Ainisa-Signature", "")
    if not verify(raw, header, "whsec_your_secret"):
        abort(401)
    # ... handle event
    return "", 200

✉️ Responding

Return any 2xx status code (for example 200 or 204) to acknowledge the event. Anything else — a 4xx, a 5xx, a timeout, or a connection error — is treated as a failed delivery and will be retried.

Respond quickly. Ainisa waits up to 10 seconds for a response. If your handler needs to do slow work, acknowledge the request immediately and process it in the background.


🔄 Retries

If a delivery fails, Ainisa retries automatically with increasing delays, up to 5 attempts total:

AttemptDelay after previous failure
1— (immediate)
2~30 seconds
3~2 minutes
4~10 minutes
5~1 hour

After the fifth attempt the delivery is marked Failed. You can inspect it and trigger a manual retry from the webhook's Deliveries page.

Some failures are permanent and are not retried — for example if the URL resolves to a blocked (internal) address.


♻️ Idempotency

Because of retries and network hiccups, your endpoint may occasionally receive the same event more than once. Design your handler to be idempotent.

Every event has a stable ID in both the X-Ainisa-Event-Id header and the payload's id field. This ID stays the same across retries and is shared by all webhooks that receive the same event. Record the IDs you have already processed and ignore duplicates.


📄 Payloads

All payloads use the envelope above. The sections below describe the data block for each event.

Leads

Lead events include the lead's collected data under fields, plus identifiers.

json
{
  "id": "8c1f...abc",
  "event": "lead.created",
  "timestamp": "2026-09-09T15:20:00+00:00",
  "data": {
    "lead_id": 123,
    "lead_uuid": "019a7d...",
    "assistant_id": 45,
    "assistant_action_id": 78,
    "assistant_action_name": "Food order lead",
    "status": "new",
    "fields": {
      "CUSTOMER_NAME": "John Doe",
      "PHONE_NUMBER": "+9945050 111 22 33",
      "ORDER_DETAILS": "2 pizza, 2 kola",
      "ORDER_TYPE": "Delivery"
    }
  }
}

For lead.updated, a change_type indicates what changed. When it is status_changed, the payload also includes old_status, new_status, and changed_by (ai or dashboard):

json
{
  "event": "lead.updated",
  "data": {
    "change_type": "status_changed",
    "lead_id": 123,
    "lead_uuid": "019a7d...",
    "old_status": "new",
    "new_status": "contacted",
    "changed_by": "dashboard",
    "fields": { "CUSTOMER_NAME": "John Doe" }
  }
}

Field names

Lead fields are dynamic — they reflect exactly the variables your assistant is configured to collect, so the keys vary between assistants.

Bookings

Booking events use customer_* keys for the person who booked, plus booking details and any collected business data under booking_data.

json
{
  "id": "9d2e...def",
  "event": "booking.created",
  "timestamp": "2026-09-09T15:20:00+00:00",
  "data": {
    "booking_id": 456,
    "assistant_id": 45,
    "assistant_action_id": 90,
    "assistant_action_name": "Appointment Booking",
    "service_name": "Consultation",
    "customer_id": 321,
    "customer_name": "Jane Roe",
    "customer_phone": "+1000000000",
    "customer_email": "jane@example.com",
    "booking_date": "2026-09-15",
    "start_time": "14:00",
    "end_time": "14:30",
    "status": "confirmed",
    "changed_by": "ai",
    "booking_data": {}
  }
}

booking.updated carries a change_type:

change_typeMeaning
rescheduledDate/time changed — payload includes the new booking_date / start_time / end_time plus old_date and old_time
contact_updatedThe customer's contact details changed

booking.cancelled includes a cancellation_reason and status: "cancelled".

Lead vs booking payloads

Lead and booking payloads are shaped differently: leads carry their data in fields, while bookings use explicit customer_* keys plus booking_data. Handle each event type accordingly.


🔑 Rotating the Secret

If your signing secret is ever exposed, rotate it from the webhook's settings. A new secret is generated and shown once. The old secret stops working immediately, so update your endpoint with the new value as part of the rotation.


🧪 Testing & Debugging

Each webhook has a Deliveries page showing recent deliveries with their status, HTTP response code, response time, and attempt count. Open any delivery to see the full request payload and the response body Ainisa received — useful when your endpoint returns an unexpected result. From here you can also manually retry a failed delivery.

The Send Test Event button sends a sample payload to your endpoint without creating any real lead or booking, so you can confirm your setup end to end. Test events don't affect your webhook's health status.


✅ Notes

  • Webhooks are available on paid plans only.
  • Only https:// URLs are accepted as webhook destinations.
  • The signing secret is shown once at creation and once on each rotation — store it securely.
  • Always verify the signature and check the timestamp on incoming requests.
  • Design your handler to be idempotent using the event id.
  • Respond within 10 seconds; do slow work in the background.
  • Failed deliveries retry automatically up to 5 times, then can be retried manually.