1. Get started
  2. Webhook events — payloads, headers and signatures
  • Quickstart
  • Data Model
  • Errors
  • Webhook events — payloads, headers and signatures
  • API versions and changes
  • API changelog

Webhook events — payloads, headers and signatures

Every event a webhook subscription receives, the exact request Corteksa sends, and how to verify it.

This page is the reference for what Corteksa sends to a webhook subscription: the events, the HTTP request, the body of each event, the retry rules, and the signature check. To create or delete a subscription, see Webhooks. For a short walkthrough, see Receive webhooks.

Events

A subscription watches one object (its object_slug) and names the events it wants. There are three events today:

EventSent when
record.createdA record is created in the object.
record.updatedA record in the object is changed and at least one value the write sent is different afterwards.
record.deletedA record is deleted from the object.

GET /api/v1/webhooks/events returns the current list. Read the names from it instead of hard-coding them, because new events are added over time:

json
{
  "status": 200,
  "message": "Success",
  "data": [
    { "event": "record.created", "description": "A record was created in a CRM object." },
    { "event": "record.updated", "description": "A record was updated in a CRM object." },
    { "event": "record.deleted", "description": "A record was deleted from a CRM object." }
  ]
}

What sends an event

Events are sent for single-record changes made in the Corteksa app, through the API, by automations, and by Corteksa itself (for example, when lead distribution assigns an owner).

These changes do not send per-record events:

  • Records created by a file import.
  • Bulk edits of field values: POST /api/v1/object/data/:objectSlug/bulk-update, and POST /api/v1/object/data/:objectSlug/bulk-actions with kind: "update".
  • Bulk deletes through POST /api/v1/object/data/:objectSlug/bulk-delete and DELETE /api/v1/object/data/:objectSlug/bulk/all.

Bulk assignment and POST /api/v1/object/data/:objectSlug/bulk-actions with kind: "delete" do send one event per record.

The request

Corteksa sends a POST to the subscription's target_url for each event, and again for each retry.

HeaderExampleMeaning
Content-Typeapplication/jsonThe body is JSON.
X-Corteksa-Eventrecord.updatedThe event name, as in the table above.
X-Corteksa-Delivery58213A number, unique for each delivery attempt. A retry of the same event carries a new value.
X-Corteksa-Signaturet=1789380000,v1=bcac4d48…The timestamp and HMAC signature. See Verify the signature.

The body

Every event body has the same top-level keys. Only record_slug is ever left out; see Events without record values.

json
{
  "event": "record_updated",
  "event_type": "update",
  "timestamp": "2026-09-14T10:00:00.000Z",
  "object": "contacts",
  "record_id": 4821,
  "record_slug": "5c9696e92c84",
  "changed_fields": {
    "email-k2x9p0zq": { "old": "sara@acme.com", "new": "sara.ali@acme.com" }
  },
  "full_record": {
    "email-k2x9p0zq": "sara.ali@acme.com",
    "slug": "5c9696e92c84"
  }
}
KeyTypeMeaning
eventstringrecord_created, record_updated or record_deleted. It uses underscores; the X-Corteksa-Event header uses dots.
event_typestringcreate, update or delete.
timestampstringISO 8601 time in UTC when this delivery attempt was prepared. It is not the time of the change, and a retry carries a later time.
objectstringThe object's slug.
record_idnumberAn internal number. Do not store it or send it to the API; use record_slug. It can be 0.
record_slugstringThe record's slug. Fetch the record with GET /api/v1/object/data/:objectSlug/:dataSlug. Left out when the event carries no record values.
changed_fieldsobjectField slug to { "old": …, "new": … }. What it holds depends on the event; see below.
full_recordobject or nullField slug to value, plus slug. What it holds depends on the event; see below.

There is no setting that removes full_record or makes it hold more fields.

record.created

changed_fields lists every field of the new record with old set to null. Fields left blank are included with new set to null. full_record holds the new record's field values and its slug.

json
{
  "event": "record_created",
  "event_type": "create",
  "timestamp": "2026-09-14T09:58:41.215Z",
  "object": "contacts",
  "record_id": 4821,
  "record_slug": "5c9696e92c84",
  "changed_fields": {
    "full-name-mpoc3dn1": { "old": null, "new": "Sara Ali" },
    "email-k2x9p0zq": { "old": null, "new": "sara@acme.com" },
    "phone-r7w1c4ma": { "old": null, "new": null }
  },
  "full_record": {
    "full-name-mpoc3dn1": "Sara Ali",
    "email-k2x9p0zq": "sara@acme.com",
    "phone-r7w1c4ma": null,
    "slug": "5c9696e92c84"
  }
}

record.updated

changed_fields lists only the fields whose value is different after the change. full_record is not the whole record: it holds the fields the write sent, including any it sent with an unchanged value, plus slug. To get every field, fetch the record by record_slug.

A side of a change that had no value can be null or left out of the pair. For example, a change made by an automation can arrive as { "new": "sara.ali@acme.com" } with no old.

The example at the top of The body is a record.updated event.

record.deleted

changed_fields lists every field the record held, with new set to null. full_record holds the record's field values at the moment it was deleted, and its slug.

json
{
  "event": "record_deleted",
  "event_type": "delete",
  "timestamp": "2026-09-14T11:20:03.940Z",
  "object": "contacts",
  "record_id": 4821,
  "record_slug": "5c9696e92c84",
  "changed_fields": {
    "full-name-mpoc3dn1": { "old": "Sara Ali", "new": null },
    "email-k2x9p0zq": { "old": "sara.ali@acme.com", "new": null },
    "phone-r7w1c4ma": { "old": null, "new": null }
  },
  "full_record": {
    "full-name-mpoc3dn1": "Sara Ali",
    "email-k2x9p0zq": "sara.ali@acme.com",
    "phone-r7w1c4ma": null,
    "slug": "5c9696e92c84"
  }
}

Events without record values

When a record is moved to a different object, subscriptions on the old object receive record.deleted and subscriptions on the new object receive record.created. Both carry "changed_fields": {} and "full_record": null, and leave out record_slug.

Keys and values

  • Keys in changed_fields and full_record are field slugs, the same keys the records API uses. List an object's fields with GET /api/v1/object/field/active/:objectSlug.
  • Values are in stored form and can differ from what a GET of the same record returns. Select-type fields, such as select, status, priority, multi-select and tag, can arrive as option ids or as option objects that carry an id, depending on the event, so compare them by option id. Dates and times are not converted to a user's timezone.
  • When a record's owner or assignees change, changed_fields can contain admin_id, assignee or assignees. They carry internal user ids that the API does not accept. Fetch the record to read assignees with slugs.
  • Ignore keys you do not recognise. New keys can be added; see API versions and changes.

Delivery and retries

  • A delivery succeeds when your endpoint answers with any 2xx status within 5 seconds.
  • A delivery is tried up to 3 times in total. The second attempt starts about 2 seconds after the first fails, the third about 4 seconds after the second.
  • A timeout, a connection failure, a 5xx, a 408 or a 429 is retried. Any other 4xx stops the retries at once.
  • Each attempt is signed again, with a new t, timestamp and X-Corteksa-Delivery. Nothing in the request stays the same across attempts of one event, so make your handler safe to run twice. One way: treat each delivery as a signal and fetch the record by record_slug before you act.
  • Deliveries are sent independently. They can arrive out of order; a retried record.updated can arrive after a later one.
  • A change made by a request with an API key or OAuth token is not sent to that credential's own subscriptions, so a two-way sync does not loop. Changes that follow from the request are not the request itself and can reach those subscriptions: an automation it triggers, or a bulk-actions or bulk-duplicate request for more than 100 records, which runs in the background.
  • To stop new deliveries, delete the subscription with DELETE /api/v1/webhooks/subscriptions/:slug.

Verify the signature

X-Corteksa-Signature has the form t=<unix seconds>,v1=<hex>. v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw body>, keyed with the subscription's signing secret. Use the whole secret as the key, including its whsec_ prefix.

  1. Read the raw request body as bytes, before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the check.
  2. Split the header on , and each part on the first = to get t and v1.
  3. Compute the HMAC over t, a ., and the raw body.
  4. Compare it to v1 in constant time.
  5. Reject a t that is too far from your clock. Retries are signed again, so a tolerance of a few minutes does not reject them.

Node.js

ts
import { createHmac, timingSafeEqual } from 'crypto';

const TOLERANCE_SECONDS = 300;

export function isValidCorteksaSignature(
  rawBody: Buffer,
  header: string,
  signingSecret: string,
): boolean {
  const parts = new Map(
    header.split(',').map((part) => {
      const i = part.indexOf('=');
      return [part.slice(0, i), part.slice(i + 1)];
    }),
  );
  const timestamp = Number(parts.get('t'));
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', signingSecret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.get('v1') ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

With Express, keep the body raw on the webhook route:

ts
app.post(
  '/corteksa/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('X-Corteksa-Signature') ?? '';
    if (!isValidCorteksaSignature(req.body, header, signingSecret)) {
      return res.status(401).end();
    }
    queue.add(JSON.parse(req.body.toString('utf8')));
    res.status(200).end();
  },
);

Python

python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def is_valid_corteksa_signature(raw_body: bytes, header: str, signing_secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Test your check

Run your function on these values, with the clock check turned off. It must return true, and false if you change one byte of the body.

  • Signing secret: whsec_4f1c9e2b7a6d3508c1e94b2f7d6a0c3e58b1f9d2a7c4e6b0
  • Header: t=1789380000,v1=bcac4d481381c626d25ce6f78df6463d53b6e306e737fcc0a4b8e3e4549ca2d4
  • Raw body (one line, no trailing newline):
json
{"event":"record_updated","event_type":"update","timestamp":"2026-09-14T10:00:00.000Z","object":"contacts","record_id":4821,"record_slug":"5c9696e92c84","changed_fields":{"email-k2x9p0zq":{"old":"sara@acme.com","new":"sara.ali@acme.com"}},"full_record":{"email-k2x9p0zq":"sara.ali@acme.com","slug":"5c9696e92c84"}}
Errors< PreviousAPI versions and changesNext >

Powered by heyo-docs

On this page

EventsWhat sends an eventThe requestThe bodyrecord.createdrecord.updatedrecord.deletedEvents without record valuesKeys and valuesDelivery and retriesVerify the signatureNode.jsPythonTest your check