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
| Event | When |
|---|---|
contact.created | First 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_changed | name changed from the last known name |
contact.picture_changed | WhatsApp 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_messaged | The 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.warning | Crossed the 80, 90 or 95% threshold of the cycle’s contact quota |
contact.quota.exceeded | Attempt 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 inphone_numberwith 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
idhere is the subject of the event. Thecontact_idthat appears in thefrom/toobject of a message is different: it refers to the counterpart contact and keeps thecontact_idkey.
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(indata): 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/quotaare measured against the organization’s evaluated billing cycle, represented bycycle_start/cycle_end(ISO 8601 UTC).resets_atequalscycle_end.
number_aliassits in the envelope, never insidedata. Among the account events, the field is omitted instorage.*and inmedia.unavailable(the events without a single number). The whole*.quota.*family (bothcontact.quota.*andmessage.quota.*, per number) carriesnumber_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.