Skip to content

Contact events

List of the webhook events Turbo Notify sends when something changes in a contact. contact.* events are delivered to the webhook URL configured in the dashboard, together with the other events: see Webhook: setup and envelope for setup, signature validation and the envelope format.

Table

EventWhen
contact.createdFirst time the trio (organization, number, phone) is seen: the contact identity is per number, so the same phone reaching main and an extra number generates two contact.created events, one per number (see Contacts: overview)
contact.name_changedname changed from the last known name
contact.picture_changedWhatsApp reported that the picture changed, or an interaction with the contact detected a new picture, and it was stored in your BYO Storage
contact.first_messagedThe first message sent by this number through Turbo Notify reached the contact on WhatsApp. Fires once per contact and number (a signal of acquiring a new contact; the quota is consumed per contact reached in the evaluated billing cycle, not by this event)
contact.quota.warningCrossed the 80, 90 or 95% threshold of the cycle’s contact quota
contact.quota.exceededAttempt rejected because the quota is exhausted (HTTP 402)

The data structure: contact trio + standard context fields

The data of a contact.* event mirrors the data of message.*: the contact trio {id, name, number} plus the standard context fields (direction, channel, origin). The id is the public id of the contact itself (ct_{hex32}), like the data.id of message.*.

{
"id": "ct_a1b2c3d4...",
"name": "João Silva",
"number": "5511988887777",
"direction": "inbound",
"channel": "whatsapp",
"origin": "tn"
}
  • id: public id of the contact (ct_{hex32}), the subject of the event.
  • name: the contact’s display name (push name) on WhatsApp.
  • number: full phone number in digits, without the + (e.g. 5511988887777). In the contact REST endpoints, the same number comes in phone_number with the +.
  • direction: always "inbound" (the contact becomes known through the WhatsApp channel). Absent in the quota events.
  • channel: always "whatsapp".
  • origin: always "tn" (Turbo Notify is the one that recorded the contact).

The id here is the subject of the event. The contact_id that appears in the from/to object of a message is different: it refers to the counterpart contact and keeps the contact_id key.

The number appears in full, as in the from/to of every message. The name (push name) is personal data: Turbo Notify does not write it to logs, and what you do with it after receiving it through the webhook (including storing it) is your responsibility.

created_at vs at

  • created_at (envelope): moment Turbo Notify emitted the event.
  • at (in data): moment of the actual action on WhatsApp.

They are distinct values. The events generated by Turbo Notify itself (contact.created, contact.quota.warning, contact.quota.exceeded) do not carry at (only the envelope created_at), and neither does contact.picture_changed (Turbo Notify notices the new picture, without a single moment of action on WhatsApp). The real-action events contact.name_changed and contact.first_messaged carry at.

Every contact.* event carries channel: "whatsapp" and origin: "tn". The contact lifecycle events carry direction: "inbound"; the quota events do not carry direction.

number_alias in the envelope

All contact.* events, both the lifecycle ones (contact.created, contact.name_changed, contact.picture_changed, contact.first_messaged) and the quota ones (contact.quota.warning, contact.quota.exceeded), carry, right below organization_id, the number_alias field: the identifier of the Turbo Notify number the contact (or the contact quota) belongs to, "main" for the main number or the alias of an extra number. Use it to route the event to the right inbox.

The contact quota is per number: each number has its own count of distinct contacts, so the contact.quota.warning/contact.quota.exceeded events identify the affected number through number_alias in the envelope. The data of the quota event carries the fields threshold_percent (only in warning), used, quota, cycle_start, cycle_end, resets_at, tier, channel, origin.

used/quota are measured against the organization’s evaluated billing cycle, represented by cycle_start/cycle_end (ISO 8601 UTC). resets_at equals cycle_end.

number_alias sits in the envelope, never inside data. Among the account events, the field is omitted in storage.* and in media.unavailable (the events without a single number). The whole *.quota.* family (both contact.quota.* and message.quota.*, per number) carries number_alias.

Example: contact.created

Generated by Turbo Notify: no at and no source.

{
"id": "evt_36cd28f516fd65fc58d84081cfa17dcb",
"type": "contact.created",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-05-15T12:00:00Z",
"data": {
"id": "ct_a1b2...",
"name": "João Silva",
"number": "5511988887777",
"direction": "inbound",
"channel": "whatsapp",
"origin": "tn"
}
}

Example: contact.name_changed

name is the new push name; old_name is the previous one.

{
"id": "evt_...",
"type": "contact.name_changed",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-05-15T12:00:03Z",
"data": {
"id": "ct_a1b2...",
"name": "João S.",
"number": "5511988887777",
"old_name": "João Silva",
"direction": "inbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-05-15T12:00:00Z"
}
}

created_at (emitted by Turbo Notify) is distinct from data.at (moment of the action on WhatsApp): here Turbo Notify emitted the event 3 s after the name change.

Example: contact.picture_changed

{
"id": "evt_...",
"type": "contact.picture_changed",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-05-15T12:00:02Z",
"data": {
"id": "ct_a1b2...",
"name": "João Silva",
"number": "5511988887777",
"picture": {
"kind": "available",
"url": "https://<bucket>.r2.cloudflarestorage.com/contacts/.../picture.jpg?...",
"url_expires_at": "2026-05-15T12:15:00Z",
"hash": "abc123..."
},
"direction": "inbound",
"channel": "whatsapp",
"origin": "tn"
}
}

This event does not carry data.at (use the envelope created_at to know when it was emitted) or picture.fetched_at: the URL validity is already given by picture.url_expires_at. fetched_at only exists when reading the contact (GET /v1/numbers/{ref}/contacts/{contact_id}), never in this webhook.

The picture block follows the same lifecycle as message media: when the picture is stored in your bucket it comes as kind: "available" with a presigned, time-limited URL (url_expires_at, ~15 min), never a WhatsApp URL. When the URL expires, fetch the picture again through the GET endpoint. The event fires only when the picture reaches your bucket, including when the contact was seen without a picture and it becomes available later (after enabling BYO Storage, on the next interaction with the contact). To read the picture on demand, use GET /v1/numbers/{ref}/contacts/{contact_id}/profile-picture, where {ref} is the number_id received in this event’s envelope (it never changes) or the number’s current alias.

Example: contact.first_messaged

{
"id": "evt_...",
"type": "contact.first_messaged",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-05-15T12:00:01Z",
"data": {
"id": "ct_...",
"name": "João Silva",
"number": "5511988887777",
"tier": "unknown",
"direction": "inbound",
"channel": "whatsapp",
"origin": "tn",
"at": "2026-05-15T12:00:00Z"
}
}

The event is only sent when the message reaches WhatsApp: an accepted send that fails before that (for example with number_not_connected) does not fire contact.first_messaged, and the next send that gets through does. data.at is the moment that first message reached WhatsApp and matches the first_messaged_at of the contact.

The tier field is not populated in this event: contact.first_messaged always emits "unknown". Do not use this field to infer the organization’s plan: the actual tier is delivered in the contact.quota.warning/contact.quota.exceeded events.

Example: contact.quota.warning

{
"id": "evt_...",
"type": "contact.quota.warning",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-20T12:00:00Z",
"data": {
"threshold_percent": 90,
"used": 90,
"quota": 100,
"cycle_start": "2026-06-01T00:00:00Z",
"cycle_end": "2026-07-01T00:00:00Z",
"resets_at": "2026-07-01T00:00:00Z",
"tier": "SOLO",
"channel": "whatsapp",
"origin": "tn"
}
}

threshold_percent is an integer with the percentage reached: 80, 90 or 95 (80 means 80%). Quota exhaustion is not sent with threshold_percent: 100: it is signaled by the separate contact.quota.exceeded event, which does not have the threshold_percent field. Code that branches on threshold_percent === 100 never runs and misses the real exhaustion signal.

This event is generated by Turbo Notify: it carries channel and origin, no direction (it is not an exchange of messages) and no at. cycle_start and cycle_end are the evaluated billing cycle against which used and quota were measured. resets_at is the moment the quota count starts over (equal to cycle_end), not the moment of an action.

Example: contact.quota.exceeded

{
"id": "evt_...",
"type": "contact.quota.exceeded",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-20T12:00:00Z",
"data": {
"used": 100,
"quota": 100,
"cycle_start": "2026-06-01T00:00:00Z",
"cycle_end": "2026-07-01T00:00:00Z",
"resets_at": "2026-07-01T00:00:00Z",
"tier": "SOLO",
"channel": "whatsapp",
"origin": "tn"
}
}

Generated by Turbo Notify: it carries channel/origin but no direction, no at and no request_id in data; resets_at is kept.

Idempotency

Each event carries a unique id (evt_…). Use id as the deduplication key on your side.