Messages
Send, receive, and manage email messages and threads. Agent tokens use the token's inbox and scopes; session callers pass inbox_id and are limited to inboxes in their tenant.
Timezones:
received_at and sent_at timestamps are returned in the inbox's default_timezone (set via ) as LocalDateTime strings. If no default_timezone is set, they are returned as UTC (Z suffix). The after/before filter params accept any format — naive inputs (no timezone offset) are treated as default_timezone.POST /v1/messages/ Send a message scope: email:send → message_id, thread_id
Request — to reply, pass
reply_to_message_id; to and subject are derived automatically{
"reply_to_message_id": "<message-id>",
"subject": "Hello Bob",
"to": [{"email": "bob@example.com", "name": "Bob"}],
"cc": [],
"bcc": [],
"reply_to": [{"email": "other@example.com"}],
"text_body": "Hi Bob,\n\nPlain text.",
"html_body": "<p>Hi Bob,</p>",
"attachments": [{"blob_id": "G123abc..."}]
}
| Field | Type | Required | Description |
|---|---|---|---|
| reply_to_message_id | string | optional | Set to reply; omit for new message. |
| subject | string | optional | Derived from original on reply. |
| to | array | conditional | Required unless replying. |
| cc | array | optional | — |
| bcc | array | optional | — |
| reply_to | array | optional | — |
| text_body | string | conditional | At least one of. |
| html_body | string | conditional | At least one of text_body or html_body is required. |
| attachments | array | optional | — |
201 Created
{
"message_id": "<message-id>",
"thread_id": "<thread-id>"
}
{ "queued": true, "approval_id": "<action-id>" }
POST /v1/messages/broadcast/ Broadcast to a contact label scope: email:send
Request
{
"label_id": "<label-id>",
"subject": "Monthly update",
"text_body": "Hello everyone...",
"html_body": "<p>Hello...</p>"
}
| Field | Type | Required | Description |
|---|---|---|---|
| label_id | string | required | Must be type contact or any. |
| subject | string | optional | — |
| text_body | string | conditional | At least one of text_body / html_body required. |
| html_body | string | optional | — |
202 Accepted
{
"queued": true,
"broadcast_id": "<broadcast-id>",
"label_id": "<label-id>",
"label_name": "newsletter"
}
poll GET /v1/messages/broadcasts/{id}/ for delivery status.
GET /v1/messages/broadcasts/{broadcast_id}/ Broadcast delivery status scope: email:read
{
"broadcast_id": "<broadcast-id>",
"label_id": "<label-id>",
"subject": "Monthly update",
"status": "sending",
"sent_count": 487,
"suppressed_count": 13,
"delivered_count": 471,
"bounced_count": 16,
"spam_complaint_count": 2,
"open_count": 312,
"click_count": 87,
"created_at": "2026-05-30T10:00:00Z",
"updated_at": "2026-05-30T10:05:22Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
| open_count | number | optional | Pixel loads — see note below. |
| click_count | number | optional | Tracked link clicks — not deduplicated. |
Counters update asynchronously as SMTP delivery events arrive.
delivered_count + bounced_count converges to sent_count over minutes to hours depending on recipient mail servers. spam_complaint_count increments when a recipient marks a message from this broadcast as spam.Open and click tracking: When an HTML body is provided, a 1×1 tracking pixel is automatically injected and all links are rewritten to go through
GET /v1/track/click/{link_id}/ before redirecting to the original destination. open_count reflects pixel loads (not unique opens — email prefetch and privacy proxies inflate this figure). click_count reflects link clicks, also not deduplicated. Unsubscribe links are never rewritten. Both counters are 0 for text-only broadcasts.GET /v1/messages/suppressed/ List suppressed addresses scope: email:read
[{
"address": "alice@example.com",
"source": "bounce:hard",
"reason": "hard_bounce",
"suppressed_at": "2026-05-28T14:22:00Z"
}]
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | One of: hard_bounce, unsubscribed, spam_complaint. |
Hard-bounce suppressions are added automatically when a permanent SMTP failure (5xx) is received for an address. Unsubscribe suppressions are added when a recipient clicks the unsubscribe link in a broadcast.
DELETE /v1/messages/suppressed/ Remove a suppression scope: email:write
Request — address in body, not URL
{
"address": "alice@example.com"
}
204 No Content
Removes the inbox-wide hard-bounce entry for the address — e.g. after a contact updates their email. The address goes in the request body rather than the URL to keep it out of server access logs. Unsubscribe suppressions (label opt-outs) are unaffected.
GET /v1/track/open/{broadcast_id}/{token}/ Open-tracking pixel
# Returns a 1×1 transparent GIF. Called automatically by email clients when the
# HTML body is rendered. Increments open_count on the broadcast record.
# Token is an HMAC-SHA256 signature — generated at send time, not guessable.
# Always returns 200 with the pixel regardless of token validity to prevent
# broadcast-existence enumeration.
GET /v1/track/click/{link_id}/ Click-tracking redirect
# 302 redirect to the original link destination.
# link_id is a UUID generated per link at send time — stored server-side so
# the destination URL is never in the tracking URL (no open-redirect risk).
# Increments click_count on the broadcast record before redirecting.
# Returns 404 if the link_id is unknown.
GET /v1/messages/ List messages scope: email:read
Query params
| Parameter | Description | |
|---|---|---|
| q | Full-text search (overrides other filters when set). | |
| folder | optional | Any custom folder name — resolved automatically to the matching folder ID. |
| mailbox_id | Filter by raw folder ID (for custom folders without a standard role). | |
| after | optional | Datetime string — naive inputs assumed to be in default_timezone. |
| unread | True to return only unread messages. | |
| starred | optional | Zero-based offset Default: 0.. |
| limit | Page size 1–200 Default: 50.. |
200 OK
{
"results": [
{
"id": "msg_01HX...",
"thread_id": "thr_01HX...",
"from": [{ "email": "alice@example.com", "name": "Alice" }],
"to": [{ "email": "agent@example.com" }],
"subject": "Hello",
"received_at": "2026-05-20T08:14:00",
"size": 4096,
"flags": { "read": true, "starred": false },
"label_suggestions": [],
"event_suggestion": null
}
],
"total": 142,
"position": 0,
"limit": 50
}
| Field | Type | Required | Description |
|---|---|---|---|
| event_suggestion | optional | Optional | // non-null when calendar_suggest feature detects a meeting/appointment. |
| total | number | optional | Total matching messages across all pages. |
| position | number | optional | Zero-based offset of first result returned. |
GET /v1/messages/{id}/ Get a message scope: email:read
{
"id": "msg_01HX...",
"thread_id": "thr_01HX...",
"from": [{ "email": "alice@example.com", "name": "Alice" }],
"to": [{ "email": "agent@example.com" }],
"subject": "Hello",
"text_body": "Hi there,\n\n...",
"html_body": "<p>Hi there,</p>...",
"attachments": [],
"received_at": "2026-05-20T08:14:00",
"size": 4096,
"flags": { "read": true, "starred": false },
"label_suggestions": [{
"label_id": "<label-id>",
"label_name": "billing",
"confidence": 0.84
}],
"event_suggestion": {
"title": "Team standup",
"start": "2024-06-03T10:00:00",
"duration": "PT30M",
"location": "Conference Room B",
"description": "Weekly standup",
"participants": [{ "name": "Alice", "email": "alice@example.com" }]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| event_suggestion | object | optional | Null when no suggestion; populated when calendar_suggest feature is enabled. |
GET /v1/messages/threads/{id}/ Get a thread scope: email:read
{
"id": "thr_01HX...",
"email_ids": ["msg_01HX...", "msg_02HX..."]
}
GET /v1/messages/threads/{thread_id}/summary/ AI thread summary scope: email:read
200 OK — cached summary available
{
"summary": "2-3 sentence description of what was discussed",
"decisions": ["explicit decision 1"],
"action_items": [{ "owner": "email or name", "action": "what they need to do" }],
"status": "open",
"stale": false,
"updated_at": "2026-05-20T08:14:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
| status | string | optional | One of: open, resolved. |
| stale | boolean | optional | True when new messages arrived after last summarization. |
202 Accepted — no summary yet; task enqueued
{
"status": "pending",
"thread_id": "<thread-id>"
}
Requires the thread_summary feature to be enabled via
PUT /v1/enrichment/. stale: true means the summary predates new messages — a fresh run is enqueued automatically. Retry after a few seconds when 202 is returned.GET /v1/messages/ticketing/reference/ Ticketing builder contract no auth required
200 OK
{
"resource": "messages.ticketing",
"label_groups": [...],
"statuses": ["open", "pending_sender", "pending_internal", "resolved", "closed"],
"priorities": ["low", "normal", "high", "urgent"],
"class_config": {
"required_fields": ["label_key"],
"fields": [...],
"matching": {...}
},
"endpoints": {...}
}
Use this contract to build lightweight email ticketing controls around inbox threads. It is not a full help desk workspace: ticket state stays attached to the email thread, assignment uses inbox ownership, and activity appears in the contact timeline.
Ticket class labels use keys such as
ticket/class/billing. Class labels can open ticket workflow manually; status and priority labels are system-managed and should be changed through ticket endpoints or workflow rules.GET /v1/messages/threads/ List ticket threads scope: email:read
Query params
| Parameter | Description |
|---|---|
| ticket | Required. Set to true; current thread listing is ticket-focused. |
| status | Optional ticket status filter. |
| limit | Optional page size. |
200 OK
{
"results": [{
"thread_id": "<thread-id>",
"status": "open",
"priority": "normal",
"class_label_key": "ticket/class/billing"
}],
"total": 1,
"limit": 50
}
GET /v1/messages/threads/{thread_id}/labels/ List thread labels scope: email:read
{ "results": [...] }
POST /v1/messages/threads/{thread_id}/labels/ Apply a thread label scope: labels:write
Request body
{
"label_key": "ticket/class/billing",
"source": "manual",
"confidence": 1.0
}
Pass either
label_id or label_key. Source is one of manual, rule, cluster, llm, or system.DELETE /v1/messages/threads/{thread_id}/labels/{label_id}/ Remove a thread label scope: labels:write
Returns
204 No Content. Ticket status and priority labels are managed by ticket workflow endpoints.GET /v1/messages/threads/{thread_id}/links/ List linked threads scope: email:read
{ "results": [...] }
POST /v1/messages/threads/{thread_id}/fork/ Start a related thread scope: email:send
Request body
{
"to": [{ "email": "customer@example.com" }],
"subject": "New request",
"text_body": "Opening a new related thread.",
"note": "Split from previous closed ticket",
"ticket": { "class_label_key": "ticket/class/support", "priority": "normal" }
}
At least one of
text_body or html_body is required. The new thread is linked back to the source thread. Use this when a closed ticket receives a separate new request or a different class should be handled as a new ticket.GET /v1/messages/threads/{thread_id}/ticket/ Get thread ticket scope: email:read
{
"thread_id": "<thread-id>",
"status": "open",
"priority": "normal",
"class_label_key": "ticket/class/support"
}
POST /v1/messages/threads/{thread_id}/ticket/ Open or update a ticket scope: email:write
Request body
{
"class_label_key": "ticket/class/billing",
"priority": "high",
"assignee_inbox_id": "<inbox-id>",
"due_at": "2026-06-20T17:00:00Z"
}
PATCH /v1/messages/threads/{thread_id}/ticket/ Update ticket fields scope: email:write
Request body — all fields optional
{
"status": "pending_sender",
"priority": "urgent",
"class_label_key": "ticket/class/billing",
"assignee_inbox_id": "<inbox-id>",
"due_at": "2026-06-20T17:00:00Z"
}
Use this endpoint for active workflow changes and ticket reclassification. Use the dedicated resolve and close endpoints when setting final states because those require a resolution note.
POST /v1/messages/threads/{thread_id}/ticket/resolve/ Resolve a ticket scope: email:write
Request body
{ "resolution_note": "Customer confirmed this is fixed." }
200 OK
Adds a closing note and transitions the ticket to
resolved. If no note is needed, use an empty body.POST /v1/messages/threads/{thread_id}/ticket/close/ Close a ticket scope: email:write
Request body
{ "resolution_note": "No further action needed." }
200 OK
Adds a closing note and transitions the ticket to
closed. If no note is needed, use an empty body.POST /v1/messages/threads/{thread_id}/ticket/reopen/ Reopen a ticket scope: email:write
Request body — optional
{ "reason": "Customer replied with a new issue." }
The endpoint accepts an optional reason for client-side audit context. The current ticket record stores the reopened status, timestamp, and reopen count; add a thread note if you need the reason retained as ticket history.
GET /v1/alerts/reference/ List notification delivery options no auth required
200 OK
{
"version": "2026-06-06",
"resource": "alerts",
"payload": {
"create_rule": { "method": "POST", "path": "/v1/alerts/" },
"test_delivery": { "method": "POST", "path": "/v1/alerts/test/" }
},
"fields": [...],
"delivery_builder": {
"type_field": "delivery.type",
"target_field": "delivery.target",
"fields_by_type": { "webhook": [...], "task": [...] }
},
"delivery_types": [
{
"type": "webhook",
"description": "Send an HTTP notification to a URL.",
"required_fields": ["target"],
"target_type": "url"
}
],
"backoff_strategies": [
{ "strategy": "exponential", "description": "Retry delay doubles after each failed attempt." }
],
"offset_units": ["minutes", "hours", "days"]
}
Event type metadata is exposed separately by
GET /v1/events/reference/. This endpoint is the builder contract for alert forms and keeps legacy option arrays for compatibility.GET /v1/messages/blobs/{blob_id}/ Download a blob scope: files:read
# Binary file content — Content-Type mirrors the uploaded type
# Content-Disposition: attachment; filename="download"
DEL /v1/messages/{id}/ Delete a message scope: email:delete
# 204 No Content — action completed immediately
# — or, if the token has requires_approval: ["email:delete"] —
# 202 Accepted
{ "queued": true, "approval_id": "<action-id>" }
GET /v1/messages/folders/ List folders scope: email:read → role / mailbox_id
[{
"id": "mbox_01...",
"name": "Inbox",
"role": "inbox",
"parentId": null,
"totalEmails": 142,
"unreadEmails": 3
}]
Pass the
role value as ?folder=inbox when listing messages. Use id as mailbox_id for custom folders or PATCH moves. Also available at GET /v1/folders/ (legacy).GET /v1/folders/ List folders — legacy alias scope: email:read
Legacy alias for
GET /v1/messages/folders/. Returns the same response. Prefer GET /v1/messages/folders/. This path is kept for backward compatibility.POST /v1/messages/upload/ Upload a file scope: files:write → blob_id
Request — multipart/form-data, field name:
file, max 25 MB# curl example
curl -X POST https://api.gent.mx/v1/messages/upload/ \
-H "Authorization: Bearer gent_..." \
-F "file=@report.pdf"
201 Created
{
"blob_id": "G123abc...",
"type": "application/pdf",
"size": 204800
}
Delivery tracking
PATCH /v1/messages/{id}/ Update a message scope: email:write
Request — all fields optional
{
"mark_read": true,
"mark_starred": true,
"mailbox_id": "<mailbox-id>"
}
| Field | Type | Required | Description |
|---|---|---|---|
| mark_read | boolean | optional | Optional bool — mark read/unread. |
| mark_starred | boolean | optional | Optional bool — star/unstar. |
| mailbox_id | string | optional | Move to mailbox. |
204 No Content
# Empty body — success indicated by 204 status code.
from is always set to the inbox address — it cannot be overridden. List endpoints return up to 50 results by default.
Attachments
POST /v1/messages/{id}/suggested-labels/{label_id}/ Confirm a label suggestion scope: labels:write
# Applies the label to the message and removes the suggestion.
# 204 No Content — empty body
DEL /v1/messages/{id}/suggested-labels/{label_id}/ Dismiss a label suggestion scope: labels:write
# Discards the suggestion without applying the label.
# 204 No Content — empty body
POST /v1/messages/{id}/suggested-events/ Confirm an event suggestion scope: calendar:write
Request
{
"calendar_id": "<calendar-id>"
}
| Field | Type | Required | Description |
|---|---|---|---|
| calendar_id | string | optional | Calendar to create the event in. |
201 Created — the created calendar event
{
"event_id": "<event-id>",
"calendar_id": "<calendar-id>",
"title": "Team standup",
"start": "2024-06-03T10:00:00",
}
DEL /v1/messages/{id}/suggested-events/ Dismiss an event suggestion scope: calendar:write
# Discards the suggestion without creating an event.
# 204 No Content — empty body