Webhooks

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

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.

#Events

EventSent when
record.createdA customer, company, lead, opportunity, quote or custom record was added.
record.updatedA field on a record changed. Carries the field key, not the value.
record.deletedA record was permanently deleted.
opportunity.stage_changedAn opportunity moved to another open stage.
opportunity.wonAn opportunity moved to a won stage.
opportunity.lostAn opportunity moved to a lost stage.
task.completedA task was marked done.
conversation.createdA visitor or customer started a new conversation, on the website or a connected channel.
conversation.message_receivedA visitor or customer sent a message. The text is not in the event; read the conversation.
conversation.handoff_requestedA visitor asked for a person, or left a follow-up for one.
meeting.bookedA visitor booked a meeting in a conversation.
task.createdA task was added.
note.createdA 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)

#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.