# Webhooks

> Receive signed events when records change, subscribe from code, and verify each delivery.

- Canonical page: https://hispecular.com/docs/webhooks
- Section: Developers
- Updated: 2026-10-22

Webhooks send an HTTPS request to your endpoint when something changes in the workspace, so you do not have to poll. They are included on Pro and Enterprise.

## Add an endpoint

1. Open Settings → Webhooks and choose **Add an endpoint**.
2. Enter your HTTPS address and choose the events to send.
3. Copy the signing secret. You need it to verify deliveries.

A workspace can have up to 100 endpoints. An address that resolves to a private network is refused, and in production the address must be HTTPS.

## Subscribe with the API

A program can manage its own subscriptions, which is how an integration turns an instant trigger on and off. The key needs the `webhooks:write` scope and a role that can manage integrations.

```bash
curl -X POST https://hispecular.com/api/v1/webhooks \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/syncon", "events": ["record.created", "opportunity.won"]}'
```

The response includes `data.secret`, once. Store it with the subscription's `id`. To stop, `DELETE https://hispecular.com/api/v1/webhooks/{id}`. You can also list and change subscriptions, rotate a secret, and read a subscription's delivery history; see the [webhooks reference](https://hispecular.com/docs/api-reference/webhooks).

## Events

| Event | Sent when |
| --- | --- |
| `record.created` | A customer, company, lead, opportunity, quote or custom record was added. |
| `record.updated` | A field on a record changed. Carries the field key, not the value. |
| `record.deleted` | A record was permanently deleted. |
| `opportunity.stage_changed` | An opportunity moved to another open stage. |
| `opportunity.won` | An opportunity moved to a won stage. |
| `opportunity.lost` | An opportunity moved to a lost stage. |
| `task.completed` | A task was marked done. |
| `conversation.created` | A visitor or customer started a new conversation, on the website or a connected channel. |
| `conversation.message_received` | A visitor or customer sent a message. The text is not in the event; read the conversation. |
| `conversation.handoff_requested` | A visitor asked for a person, or left a follow-up for one. |
| `meeting.booked` | A visitor booked a meeting in a conversation. |
| `task.created` | A task was added. |
| `note.created` | A note was written on a customer, company, lead or deal. The text is not in the event; read the note. |

`GET https://hispecular.com/api/v1/webhooks/events` lists the events a key can subscribe to, including ones that are listed but not sent yet.

## What a delivery contains

A delivery is deliberately thin: identifiers and the kind of change, never field values. That keeps a webhook from becoming a way around field security. To get the record, read it with the API using a key that is allowed to see it.

```json
{
  "id": "evt_9c1e…",
  "type": "record.created",
  "createdAt": "2026-11-05T10:00:00.000Z",
  "organizationId": "org_…",
  "data": {
    "objectType": "person",
    "recordId": "6f1c…",
    "publicId": "pr_1a2b3c4d5e6f7a8b"
  }
}
```

`data.publicId` is the id the API uses, so `GET https://hispecular.com/api/v1/people/pr_1a2b3c4d5e6f7a8b` returns the customer. Updates add `fieldKey`; stage events add the stage facts. Conversation events carry `data.conversationId` (`GET https://hispecular.com/api/v1/conversations/conv_…`, with the latest message), a booking carries `meetingId`, and a new task carries `taskId`, each the id the API reads it by. What a visitor wrote is never in the event. A record that key cannot see answers `404`, which your handler should treat as nothing to do. Headers: `x-syncon-event`, `x-syncon-delivery` and `x-syncon-signature`.

## Verify a delivery

The `x-syncon-signature` header looks like `t=1729590000,v1=…`: a Unix timestamp and an HMAC-SHA256, in hex, of the timestamp, a full stop and the raw request body, keyed with your signing secret. Reject a delivery whose timestamp is more than five minutes old or whose signature does not match.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

// body: the raw request body as a string, before any JSON parsing
export function isGenuine(body, header, secret) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((part) => {
      const at = part.indexOf("=");
      return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
    }),
  );
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
```

```python
import hashlib, hmac, time

def is_genuine(body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > 300:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

> **Verify against the raw body** Parsing the JSON and serialising it again changes the bytes, and the signature will not match. Read the body as text first.

## Delivery, retries and ordering

- Answer with any `2xx` within 10 seconds. Anything else, or no answer, is a failure.
- A failed delivery is retried with growing gaps, up to eight attempts. The same event keeps the same `id` on every attempt, so dedupe on it.
- Deliveries are not guaranteed to arrive in order. Use `createdAt`, or read the record for its current state.
- An endpoint whose deliveries keep failing every retry is switched off, and the workspace is told. Fix the address, then set its status back to `active`.
- Events typically reach your endpoint within a few seconds of the change.
