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:
| Event | Sent when |
|---|---|
record.created | A record is created in the object. |
record.updated | A record in the object is changed and at least one value the write sent is different afterwards. |
record.deleted | A 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:
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, andPOST /api/v1/object/data/:objectSlug/bulk-actionswithkind: "update". - Bulk deletes through
POST /api/v1/object/data/:objectSlug/bulk-deleteandDELETE /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.
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | The body is JSON. |
X-Corteksa-Event | record.updated | The event name, as in the table above. |
X-Corteksa-Delivery | 58213 | A number, unique for each delivery attempt. A retry of the same event carries a new value. |
X-Corteksa-Signature | t=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.
| Key | Type | Meaning |
|---|---|---|
event | string | record_created, record_updated or record_deleted. It uses underscores; the X-Corteksa-Event header uses dots. |
event_type | string | create, update or delete. |
timestamp | string | ISO 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. |
object | string | The object's slug. |
record_id | number | An internal number. Do not store it or send it to the API; use record_slug. It can be 0. |
record_slug | string | The record's slug. Fetch the record with GET /api/v1/object/data/:objectSlug/:dataSlug. Left out when the event carries no record values. |
changed_fields | object | Field slug to { "old": …, "new": … }. What it holds depends on the event; see below. |
full_record | object or null | Field 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.
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.
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_fieldsandfull_recordare field slugs, the same keys the records API uses. List an object's fields withGET /api/v1/object/field/active/:objectSlug. - Values are in stored form and can differ from what a
GETof 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 anid, 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_fieldscan containadmin_id,assigneeorassignees. They carry internal user ids that the API does not accept. Fetch the record to readassigneeswith 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
2xxstatus 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, a408or a429is retried. Any other4xxstops the retries at once. - Each attempt is signed again, with a new
t,timestampandX-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 byrecord_slugbefore you act. - Deliveries are sent independently. They can arrive out of order; a retried
record.updatedcan 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-actionsorbulk-duplicaterequest 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.
- Read the raw request body as bytes, before any JSON parsing. Parsing and re-serializing changes the bytes and breaks the check.
- Split the header on
,and each part on the first=to gettandv1. - Compute the HMAC over
t, a., and the raw body. - Compare it to
v1in constant time. - Reject a
tthat is too far from your clock. Retries are signed again, so a tolerance of a few minutes does not reject them.
Node.js
With Express, keep the body raw on the webhook route:
Python
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):