> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payload format

> The envelope, the headers, and the one thing about field values that will surprise you.

Every delivery is the same envelope. Only `data` changes shape between events.

## The envelope

```json theme={null}
{
  "id": "whd_3JAx8Qm2rL9pKd",
  "event": "record.created",
  "createdAt": "2026-09-11T14:18:54.201Z",
  "data": {
    "recordId": "rec_3Ab9xK2mQ7",
    "objectId": "obj_2Zc7pL",
    "record": {"name": "Ravenna Foods", "stage": ["opt_negotiation"]}
  }
}
```

<ResponseField name="id" type="string" required>
  The delivery id, prefixed `whd_`. Unique per delivery, **stable across
  retries** of that delivery. This is the key to deduplicate on.
</ResponseField>

<ResponseField name="event" type="string" required>
  The event name, for example `record.updated`. Also sent as the
  `X-Custral-Event` header so you can route before parsing the body.
</ResponseField>

<ResponseField name="createdAt" type="string" required>
  ISO-8601 timestamp, generated at the moment of **this attempt**. It is not the
  time the underlying event happened, and it moves between retries.
</ResponseField>

<ResponseField name="data" type="object" required>
  The event payload. Each event's fields are documented in the
  [event catalog](/dev/webhooks/events/overview).
</ResponseField>

## Headers

| Header                | Value                                                                                |
| --------------------- | ------------------------------------------------------------------------------------ |
| `Content-Type`        | `application/json`                                                                   |
| `User-Agent`          | `Custral-Webhooks/1.0`                                                               |
| `X-Custral-Event`     | The event name, matching `event` in the body                                         |
| `X-Custral-Signature` | `t=<unix seconds>,v1=<hex hmac>`, see [Verifying signatures](/dev/webhooks/security) |

## Absent is not the same as cleared

Payloads are built field by field, and a field with no value is **omitted**
rather than sent as `null`. So a key that is missing means Custral had nothing
to put there; a key present and `null` means the value was genuinely emptied.

On [`record.updated`](/dev/webhooks/events/record-updated) that distinction is
the whole point of the event: `"previous": null` says the field used to be
empty, while no `previous` key at all says the emitter could not read a prior
value.

## Field values are STORED values, not display values

This is the one that catches people. A record's fields arrive exactly as Custral
stores them, which for several property types is a list of ids rather than
anything you would show a person.

| Property type                          | Arrives as                     |
| -------------------------------------- | ------------------------------ |
| Text, long text, email, phone, website | The string                     |
| Number, currency                       | The number                     |
| Date                                   | The stored date value          |
| Checkbox                               | The boolean                    |
| **Select, status**                     | `["opt_3Kd8sM"]`, an option id |
| **Relation**                           | `["rec_9Lm2pQ"]`, a record id  |
| **User**                               | `["user_5Nc1rT"]`              |

```json theme={null}
"record": {
  "name": "Ravenna Foods",
  "annual_value": 48000,
  "stage": ["opt_3Kd8sM"],
  "owner": ["user_5Nc1rT"]
}
```

To turn `opt_3Kd8sM` into `"Negotiation"`, read the object's schema once and
cache it:

```ts theme={null}
const object = await custral.objects.retrieve("deals");
const labels = new Map(
  (object.properties ?? []).flatMap((p) => (p.options ?? []).map((o) => [o.id, o.value])),
);

labels.get("opt_3Kd8sM"); // "Negotiation"
```

<Note>
  Resolving these to labels inside the payload needs a bulk lookup on every
  event, so it is a deliberate follow-up rather than something quietly added.
  Until it lands, the ids are what arrive, and
  [`GET /v1/objects/{id}`](/dev/api-reference/objects) is how you name them.
</Note>

## Keys are property keys

The `record` object on both record events is keyed by each property's **key**
(`annual_value`), not its display name and not its `prop_…` id. Keys are stable
across renames, which is what makes them safe to write code against. See
[How the schema works](/dev/api-reference/schema).

A field whose property key cannot be resolved is dropped from `record` rather
than included under an empty key.

## Typing the payload

`CustralEvent` is generic over `data`, so you can narrow per event:

```ts theme={null}
import type {CustralEvent} from "@custral/sdk";

interface RecordCreated {
  recordId: string;
  objectId?: string;
  record?: Record<string, unknown>;
}

custral.on("record.created", (event: CustralEvent<RecordCreated>) => {
  event.data.recordId;
});
```


## Related topics

- [Records](/dev/webhooks/events/records.md)
- [Event catalog](/dev/webhooks/events/overview.md)
- [Troubleshooting](/dev/webhooks/troubleshooting.md)
- [Webhooks](/dev/webhooks/overview.md)
- [record.created](/dev/webhooks/events/record-created.md)
