# About Chert Source: https://docs.trychert.com/about The team behind the messaging layer. Chert Technologies is a Y Combinator-backed company building the messaging layer between modern go-to-market stacks and iMessage. We integrate into the CRMs sales teams already use, identify iMessage-qualified leads, and run outbound conversations that convert into meetings. ## Founders > **[Gary Gao](https://www.linkedin.com/in/garygao333/)** > > ![Gary Gao](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/founders/gary.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=a6fd226a7d3ea62f51343d3631816609) > > Co-Founder & CEO > **[Ian Fong](https://www.linkedin.com/in/ianyfong)** > > ![Ian Fong](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/founders/ian.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=45839a6dc5cd88f1c531e2bc1a2ab506) > > Co-Founder & CTO · [@yannaner](https://x.com/yannaner) ## Backed by Y Combinator (P26) · Z Fellows · Betafund · LinkedIn · Grafana ## Where we are Seattle, Washington. ## Talk to us > **[Email](mailto:contact@trychert.com)** > > [contact@trychert.com](mailto:contact@trychert.com) > **[Book a call](https://cal.com/team/chert/chert-call)** > > cal.com/team/chert > **[Website](https://trychert.com)** > > trychert.com > **[Y Combinator](https://www.ycombinator.com/companies/chert)** > > Company profile # Attachments Source: https://docs.trychert.com/api/attachments Upload media, then send it as a message part. Attachments use a two-step upload flow: 1. Create an upload slot. 2. PUT the bytes to `upload_url`. 3. Send a chat message with a `media` part through `POST /api/v1/chats/{id}/messages`. > [!NOTE] > > To send audio as a native iMessage voice-note bubble, use the separate > [Voice notes](/api/voice-notes) workflow. Do not upload it here or put a > `voice_note_id` in `attachments[]`; generic audio attachments may render as > ordinary files or media attachments. You can submit an ordinary uncompressed > WAV to the voice-note endpoint; Chert performs the compatible Opus-in-CAF > conversion server-side. ## 1. Create an upload slot ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/attachments \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "filename": "invoice.pdf", "content_type": "application/pdf", "size_bytes": 204800 }' ``` ```json theme={null} { "attachment_id": "9f3b27a8c14d4a6f8e2b91d4c5e6f7a8", "upload_url": "https://console.trychert.com/api/v1/attachments/9f3b27a8c14d4a6f8e2b91d4c5e6f7a8/upload", "required_headers": { "authorization": "Bearer ", "content-type": "application/pdf" }, "expires_at": "2026-05-02T18:15:00Z" } ``` ## 2. Upload bytes ```bash theme={null} curl -X PUT "$UPLOAD_URL" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/pdf" \ --data-binary @invoice.pdf ``` HMAC is not supported on the binary upload route. Use bearer auth. > [!NOTE] > > Single PUTs accept up to **100 MB** per file. Chert streams the body straight through to storage — there is no in-memory buffering bottleneck, so large uploads scale with your network throughput. Set `Content-Length` on the PUT so we can fast-reject a body that exceeds the size you registered. ## 3. Send the attachment ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/chats/CHAT_ID/messages" \ -H "Authorization: Bearer YOUR_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [ { "type": "text", "value": "See attached." }, { "type": "media", "attachment_id": "att_abc..." } ] } }' ``` ## Send multiple images as one bubble For multiple uploaded media parts that should arrive as **one grouped delivery** rather than N back-to-back bubbles, send a normal chat message and set `parallel_attachments: true`. Chert requests grouped delivery; Apple devices decide the final collage, stack, or attachment presentation. The shape is the same `parts[]` you use for every other chat send. The only difference is the top-level `parallel_attachments: true` flag. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/chats/$CHAT_ID/messages" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "parallel_attachments": true, "message": { "parts": [ { "type": "media", "attachment_id": "att_aaa..." }, { "type": "media", "attachment_id": "att_bbb..." }, { "type": "media", "attachment_id": "att_ccc..." } ] } }' ``` ```json theme={null} { "message": { "id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "status": "sent", "delivered_at": "2026-05-27T20:18:28.857Z" } } ``` You can include `parallel_attachments` on the first touch too — `POST /api/v1/chats` accepts it inside `message`. ### Rules | Rule | Value | | - | - | | Count | 2 or more media parts | | Supported image types | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif` | | Per-file size | 100 MB max for registered uploads | | Rendering | Chert requests grouped delivery. Apple devices decide the final collage or stack presentation. | | 1 image | Drop `parallel_attachments` — a single media part renders as a normal image bubble. | | Large batches | Split into multiple sends when you want predictable recipient UX. | | Mixed media (image + video) | Delivery is provider/device-dependent. Send separately when presentation matters. | | Optional caption | A `text` part is sent as its own text bubble; same-bubble captions are not guaranteed. | | Without `parallel_attachments` | Multiple `media` parts render as N separate bubbles. | ### Errors | Code | HTTP | When | | - | - | - | | `1003` | 400 | Missing required field. | | `1002` | 400 | Invalid part shape or MIME mismatch. | | `3002` | 503 | No eligible sender line available in your project. | | `4001` | 502 | Delivery failed downstream. | ## Supported uploads | Type | MIME examples | | - | - | | Images | `image/jpeg`, `image/png`, `image/gif`, `image/heic`, `image/heif`, `image/webp` | | Video | `video/mp4`, `video/quicktime` | | Audio | `audio/mpeg`, `audio/m4a`, `audio/wav`, `audio/aac` | | Documents | `application/pdf` | The public API validates the declared file size and rejects unsupported MIME types. Upload slots expire after 15 minutes. ## Metadata and download Use `GET /api/v1/attachments/{id}` to check the registered attachment. ```json theme={null} { "attachment": { "attachment_id": "9f3b27a8c14d4a6f8e2b91d4c5e6f7a8", "content_type": "application/pdf", "size_bytes": 204800, "filename": "invoice.pdf", "uploaded_at": "2026-05-02T18:02:00Z", "expires_at": "2026-05-02T18:15:00Z" } } ``` Use `GET /api/v1/attachments/{id}/content` to stream the binary content. This works for attachments you uploaded and for inbound media ids received in `message.received` webhooks. ```bash theme={null} curl "https://console.trychert.com/api/v1/attachments/$ATTACHMENT_ID/content" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "x-chert-tenant: $SLUG" \ --output attachment.bin ``` Inbound images and files arrive in `data.message.parts[]` as `media` parts: ```json theme={null} { "type": "media", "attachment_id": "in_536f2fe3cafef93946d0bf9b4ac2e3ef", "filename": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 790394 } ``` Inbound attachment metadata is best effort. A part may omit `filename` or `size_bytes`, and `mime_type` may be the generic `application/octet-stream`. The authenticated content response can provide a more useful filename in `Content-Disposition`; inspect the downloaded file signature and media metadata rather than trusting the webhook MIME type alone. Apple voice memos use this normal inbound attachment flow—there is no separate inbound voice-note endpoint. A verified recording downloaded as `Audio_Message.caf`, a playable CAF container with Opus audio, even though its event MIME type was `application/octet-stream` and the event did not include a filename. This observed CAF/Opus combination is not guaranteed across every device or OS version. See [Receiving inbound voice memos](/api/receiving-replies#receiving-inbound-voice-memos). ## Caveats | Caveat | What to do | | - | - | | Group create with media or rich links | Create the group with text first, then send media or rich links with `POST /api/v1/chats/{id}/messages`. | ## See also * [Usage examples](/api/usage-examples) * [Create a chat](/api/chats/create) * [Voice notes](/api/voice-notes) # Authentication Source: https://docs.trychert.com/api/authentication HMAC-SHA256 request signing with a five-minute replay window. Every request to `/api/v1/*` is authenticated against a tenant's `signing_secret`. Two modes are supported: HMAC signatures (recommended) and bearer tokens (simpler, equivalent strength when transport is TLS). ![Signature verification flow](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/api/diagrams/signature.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=c15e8f70fc2ba3b03078b8bce4cf736b) ## Required headers | Header | Required | Description | | - | - | - | | `x-chert-tenant` | Conditional | Your `tenant_slug` from registration. Required for tenants registered with `multi_tenant: true` (strict routing) and required whenever `x-chert-signature` is sent. Optional for default single-tenant accounts using bearer auth — the bearer token alone identifies the workspace. | | `x-chert-signature` | One of | `v1,,` — preferred. Always send `x-chert-tenant` alongside it. | | `authorization` | One of | `Bearer ` — single-tenant default. The bearer token resolves the tenant when `x-chert-tenant` is omitted. | | `content-type` | On `POST` | `application/json` | If both `x-chert-signature` and `authorization` are present, the signature is checked first. ## Signature format ``` x-chert-signature: v1,, ``` | Field | Description | | - | - | | `v1` | Format version. | | `unix_seconds` | Request timestamp. Must be within 5 minutes of server time. | | `hmac_hex` | Lowercase hex of `HMAC-SHA256(signing_secret, ".")`. | ### What gets signed The HMAC input is the literal string `` + `.` + ``. * **For `POST` requests**, `raw_body` is the exact bytes you transmit. Do not re-serialize, do not strip whitespace, do not reorder keys after signing. * **For `GET` requests**, `raw_body` is the empty string. The HMAC input ends in a trailing dot. The server reads the request body byte-for-byte before parsing, so any reformatting on the client invalidates the signature. ### Replay window Timestamps more than **5 minutes** off in either direction are rejected with `401` and the distinct code `2013 AUTH_TIMESTAMP_SKEW` — separate from a genuinely bad signature (`2004`). Sync your clock and resign on retry — never reuse a signature. ## Reference implementations ```bash curl theme={null} TS=$(date +%s) BODY='{"phone":"+14155551234","body":"Hi"}' SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}') curl https://console.trychert.com/api/v1/send \ -H "x-chert-tenant: $SLUG" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d "$BODY" ``` ```js Node.js theme={null} import { createHmac } from "node:crypto" function sign(secret, body) { const ts = Math.floor(Date.now() / 1000) const hex = createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex") return `v1,${ts},${hex}` } ``` ```python Python theme={null} import hmac, hashlib, time def sign(secret: str, body: str) -> str: ts = str(int(time.time())) hex_ = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() return f"v1,{ts},{hex_}" ``` ## Bearer token fallback For scripts, prototypes, and trusted server-to-server calls, send the secret directly: ``` authorization: Bearer ``` The server compares with a constant-time check. Bearer is fine when the request never leaves a controlled server environment — TLS 1.2+ is the only protection between the secret and the network. Use HMAC wherever the signing process could be exposed (e.g. edge functions with third-party dependencies, client-side code, shared build pipelines). ## Errors Auth and signature failures return **distinct numeric codes** — they are no longer collapsed into one opaque "unauthorized". Branch on the numeric `code`: | Status | Code | Name | Cause | Action | | - | - | - | - | - | | 401 | `2012` | `AUTH_MISSING` | No credentials supplied at all — both `x-chert-signature` and `authorization` are absent. | Add a signature or bearer header. | | 401 | `2004` | `AUTH_INVALID` | Credentials supplied but rejected — malformed or mismatched signature, or bad bearer token. | Re-sign with the current timestamp / check the token. | | 401 | `2013` | `AUTH_TIMESTAMP_SKEW` | Signature timestamp outside the 5-minute replay window. | Sync your clock and re-sign with a fresh timestamp. | | 403 | `2007` | `EMAIL_NOT_VERIFIED` | Tenant email not yet confirmed. | Click the link in the verification email. | | 404 | `2001` | `TENANT_NOT_FOUND` | `tenant_slug` not recognized or tenant suspended. | Verify `x-chert-tenant` matches your registration response. | Error responses carry the standard envelope — `{ success, error: { status, code, message, retryable }, trace_id }` — with `retryable: false` on every auth failure. The `message` is generic; the crypto-step detail (which header was missing, which verification step failed) is logged server-side under `trace_id` and is no longer leaked in the response. See [Errors](/api/errors) for the full table. > [!NOTE] > > `POST /api/v1/send` is a legacy-shaped endpoint and still emits a flat `{ error, code: "auth_failed" }` body for auth failures rather than the numeric envelope above. All other `/api/v1/*` routes use the distinct numeric codes. ## Rotating the signing secret The `signing_secret` is shown exactly once at registration. To rotate it, contact your Chert administrator. Re-registering the same email returns the existing tenant and does not regenerate the secret. ## Webhook signatures Inbound webhook events are signed independently with the webhook subscription `secret` (returned once when the subscription is created). Two signature header formats are emitted simultaneously: | Header | Format | Notes | | - | - | - | | `x-chert-signature` | `v1,,` | Legacy format. Supported for backward compatibility. | | `X-Webhook-Signature` | `t=,v1=` | New format. Recommended for new integrations. | Both compute the same HMAC: `HMAC-SHA256(subscription_secret, ".")`. The raw body is the exact bytes received — do not parse and re-serialize before verifying. See [Receiving replies](/api/receiving-replies) for the consumer-side verification snippet that handles both formats. Additional headers on every webhook delivery: | Header | Description | | - | - | | `X-Webhook-Event` | Event type, e.g. `message.received` | | `X-Webhook-Event-Id` | Stable dedup key | | `X-Webhook-Timestamp` | Unix seconds | | `X-Webhook-Subscription-Id` | Id of the subscription that triggered this delivery | ## See also * [Quickstart](/api/quickstart) * [Receiving replies](/api/receiving-replies) * [Webhook subscriptions](/api/webhook-subscriptions) * [Errors](/api/errors) # Create a chat Source: https://docs.trychert.com/api/chats/create POST /api/v1/chats - create a chat and send the first message. Creates a 1:1 chat or group chat and sends the first message. Use this when your app wants an addressable `chat.id` for follow-ups, history, typing, media, and group operations. > [!NOTE] > > Text parts use `value`. The request must include `message.parts`. `rich_link` parts use the same public shape on every Chert sender line: ```json theme={null} { "type": "rich_link", "url": "https://example.com" } ``` Optional `title`, `summary`, `image_url`, and `icon_url` fields are accepted. When those fields are omitted, Chert fetches preview metadata when the sender line supports native rich-link previews. If a rich preview cannot be generated, the URL may be delivered as plain text instead of a preview bubble. ## Request ```bash theme={null} curl https://console.trychert.com/api/v1/chats \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "from": "+15555550100", "to": ["+14155551234"], "message": { "parts": [ { "type": "text", "value": "Hi Sam - got a minute?" }, { "type": "rich_link", "url": "https://example.com/demo" } ], "idempotency_key": "chat-2026-05-01-001" } }' ``` ## Group chat Use 2-31 recipients in `to`. ```json theme={null} { "from": "+15555550100", "to": ["+14155551234", "+14155559876"], "message": { "subject": "Launch follow-up", "parts": [{ "type": "text", "value": "Starting this group here." }] } } ``` > [!WARNING] > > Media attachments and rich links are not supported in the first group-create call. Create the group with text, then send attachments or rich links with `POST /api/v1/chats/{id}/messages`. ## Response ```json theme={null} { "chat": { "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "display_name": "Sam Chen", "handles": [{ "service": "imessage", "address": "+14155551234" }], "is_group": false, "service": "imessage", "health_score": 87, "created_at": "2026-05-01T12:00:00Z" }, "message": { "id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "parts": [{ "type": "text", "value": "Hi Sam - got a minute?" }], "status": "sent", "direction": "outbound", "created_at": "2026-05-01T12:00:00Z", "delivered_at": null, "read_at": null, "service": "imessage" } } ``` ## Fields | Field | Required | Notes | | - | - | - | | `from` | Yes | Sender phone number from [Phone numbers](/api/phone-numbers). Common formatting is normalized before matching the assigned line. | | `to` | Yes | One recipient for a DM, 2-31 for a group. Each recipient may be an E.164 phone or Apple ID email handle. Common US phone formatting is normalized; emails are lowercased. | | `message.parts` | Yes | Array of `text`, `media`, `rich_link`, or legacy `link` parts. `rich_link` uses `{ "type": "rich_link", "url": "https://..." }`. | | `message.idempotency_key` | No | Reuse the same key and body to safely retry. | | `message.subject` | No | Used as the group display name for multi-recipient chat creation. | ## Ids | Field | Use it with | | - | - | | `chat.id` | Chat-scoped routes such as `POST /api/v1/chats/{id}/messages` and `GET /api/v1/chats/{id}/messages`. | | `message.id` | Message-scoped routes such as `POST /api/v1/messages/{id}/react` and `POST /api/v1/messages/{id}/edit`. | ## Errors | Code | When | | - | - | | `1001` | Missing `from`, `to`, or `message.parts`. | | `1003` | Invalid phone number or Apple ID email handle. | | `1005` | URL in a first-message text part, or a legacy `link` part in a first message. Use `rich_link` for previewed URLs. | | `2006` | `from` is not assigned to this tenant. | | `4001` | Downstream delivery failed. | ## See also * [Send a follow-up](/api/chats/send-message) * [Create a group](/api/group-chats/create) * [Phone numbers](/api/phone-numbers) # List messages Source: https://docs.trychert.com/api/chats/list-messages GET /api/v1/chats/:id/messages — list messages in a chat, oldest first. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** Yes (read-only). Returns the 200 most recent messages in a chat, oldest first. This is a chat-scoped route: `{id}` is the `chat_id`, not a `message_id`. Chert combines saved conversation rows with durable inbound event rows so recent inbound messages still appear if a best-effort conversation append was missed. `has_more` is `true` if older messages were truncated. If the chat has no conversation yet, returns an empty array. ## Request `GET /api/v1/chats/{id}/messages` ```bash cURL theme={null} curl "https://console.trychert.com/api/v1/chats/0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f/messages" \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = "" const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/messages`, { method: "GET", headers: { "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, }, ) ``` ```python Python theme={null} import hmac, hashlib, time, requests ts = int(time.time()) body = "" sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.get( f"https://console.trychert.com/api/v1/chats/{chat_id}/messages", headers={ "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, ) ``` ## Response ```json 200 OK theme={null} { "messages": [ { "id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "parts": [{ "type": "text", "value": "Hi Sam — got a minute?" }], "status": "delivered", "direction": "outbound", "created_at": "2026-05-01T12:00:00Z", "delivered_at": "2026-05-01T12:00:00Z", "read_at": null, "service": "imessage", "reactions": { "from_them": "love" } }, { "id": "abf41d8b6c2f4f9e9c1a3d5e7f8a9b0c", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "parts": [{ "type": "text", "value": "Sure, what time?" }], "status": "delivered", "direction": "inbound", "created_at": "2026-05-01T12:05:00Z", "delivered_at": "2026-05-01T12:05:00Z", "read_at": null, "service": "imessage" } ], "has_more": false } ``` Message `id` values are opaque. For messages returned by this endpoint, pass the listed `id` to message-scoped actions such as [`POST /api/v1/messages/{id}/react`](/api/messages/react). ### Path parameters **id** `string` *required* Chat identifier — the `chat.id` returned by [`POST /api/v1/chats`](/api/chats/create). Do not pass `message.id` or `convo_id` here. ### Response fields **messages** `object[]` Array of message rows, oldest first. Capped at the 200 most recent messages. **messages\[].reactions** `object` Tapback reactions on this message; omitted when there are none. `from_us` is the reaction your side sent via [`POST /api/v1/messages/{id}/react`](/api/messages/react); `from_them` is the other side's reaction to your message. Values are canonical tapback keys: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. **has\_more** `boolean` `true` when the underlying conversation has more than 200 messages and only the most recent 200 are included in this response. Cursor pagination on this endpoint is not yet supported. ## Errors | Code | HTTP | When | | - | - | - | | `2002` | 404 | Chat not found in your tenant. | See the full [error code reference](/api/errors). ## See also * [Send a message](/api/chats/send-message) # Mark a chat as read Source: https://docs.trychert.com/api/chats/mark-read POST /api/v1/chats/:id/mark-read - clear unread inbound messages and send a read receipt. > [!NOTE] > > **Auth.** HMAC v1 or bearer token - see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** Yes (re-marking an already-read chat is a no-op). Mark unread inbound messages in a chat as read and send a read receipt from the chat's line. The request has no body. > [!NOTE] > > Use this when an operator opens a conversation, or after your agent finishes handling inbound messages. ## Request `POST /api/v1/chats/{id}/mark-read` No request body. ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/mark-read \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/mark-read`, { method: "POST", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, }, ) ``` ```python Python theme={null} import hmac, hashlib, time, requests ts = int(time.time()) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/chats/{chat_id}/mark-read", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "status": "read" } ``` ### Path parameters **id** `string` *required* The Chert `chat_id` of the chat to mark as read. ### Response fields **chat\_id** `string` Echoed-back `chat_id`. **status** `string` Always `"read"` on success. ## Errors | Code | HTTP | When | | - | - | - | | `2002` | 404 | Chat not found, or not owned by this tenant. | | `1002` | 422 | Chat has no resolvable recipient. | | `4001` | 502 | Delivery failed downstream. | | `4099` | 501 | Mark-read is not available on this phone line. | See the full [error code reference](/api/errors). ## Notes | Behavior | What it means | | - | - | | Pinned line required | The receipt is emitted from the same line the chat is pinned to. | | Existing chats only | Create or receive a chat first, then mark-read. | | Read receipt visibility | The recipient only sees a receipt when read receipts are enabled for the conversation. | ## See also * [List messages in a chat](/api/chats/list-messages) * [Typing indicators](/api/chats/typing) * [Get a chat](/api/chats/create) # Send a message Source: https://docs.trychert.com/api/chats/send-message POST /api/v1/chats/:id/messages - send a follow-up in an existing chat. Sends a follow-up in an existing chat. This is a chat-scoped route: `{id}` is the `chat_id`, not a `message_id`. > [!WARNING] > > The body is wrapped in `message`. Top-level `parts` will be rejected. For group chats, [create the group](/api/group-chats/create) with a text-only `POST /api/v1/chats` call first. Send media and rich links through this follow-up endpoint after the group chat exists. ## Request ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/messages \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [{ "type": "text", "value": "How about 3pm PT?" }], "idempotency_key": "followup-2026-05-01-002" } }' ``` ## Send media Upload first, then reference the `attachment_id`. ```json theme={null} { "message": { "parts": [ { "type": "text", "value": "See attached." }, { "type": "media", "attachment_id": "9f3b27a8c14d4a6f8e2b91d4c5e6f7a8" } ] } } ``` ## Send a rich link The `rich_link` part shape is the same on every Chert sender line. The rich link is rendered as its own preview bubble. If a rich preview cannot be generated, the URL may be delivered as plain text instead. ```json theme={null} { "message": { "parts": [ { "type": "text", "value": "Leaving this here." }, { "type": "rich_link", "url": "https://example.com/demo" } ] } } ``` Optional fields: `title`, `summary`, `image_url`, `icon_url`. ## Send media with a rich link This is the recommended shape for review-request messages that include a personalized image and a previewed URL. ```json theme={null} { "message": { "parts": [ { "type": "media", "attachment_id": "9f3b27a8c14d4a6f8e2b91d4c5e6f7a8" }, { "type": "text", "value": "Hi Marcus, this is Todd from Texas Prime Plumbing. I would really appreciate a Google review." }, { "type": "rich_link", "url": "https://search.google.com/local/writereview?placeid=..." } ] } } ``` ## Response ```json theme={null} { "message": { "id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "parts": [{ "type": "text", "value": "How about 3pm PT?" }], "status": "sent", "direction": "outbound", "created_at": "2026-05-01T14:30:00Z", "delivered_at": null, "read_at": null, "service": "imessage" } } ``` The returned `message.id` is the id to pass to message-scoped actions such as [`POST /api/v1/messages/{id}/react`](/api/messages/react). ## Fields | Field | Required | Notes | | - | - | - | | Path `id` | Yes | The `chat.id` returned by [`POST /api/v1/chats`](/api/chats/create). Do not pass `message.id` or `convo_id` here. | | `message.parts` | Yes | Array of `text`, `media`, and `rich_link` parts. Text parts use `value`; media parts reference an uploaded `attachment_id`; rich-link parts use `{ "type": "rich_link", "url": "https://..." }`. | | `message.idempotency_key` | No | Safe retry key scoped to the tenant. | ## See also * [Create a chat](/api/chats/create) * [Attachments](/api/attachments) * [List messages](/api/chats/list-messages) # Typing indicators Source: https://docs.trychert.com/api/chats/typing POST /api/v1/chats/:id/typing - show or clear typing dots in an existing chat. Use typing indicators as a UI signal before an agent or automation sends a follow-up. They are best-effort and should not be treated as delivery confirmation. `POST /api/v1/chats/{id}/typing` ## Request ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/typing \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "state": "typing" }' ``` Use `"stopped"` to clear the indicator: ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/typing \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "state": "stopped" }' ``` ## Fields | Field | Required | Notes | | - | -: | - | | `state` | Yes | `typing` or `stopped`. | ## Response ```json theme={null} { "status": "sent", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "state": "typing" } ``` ## Notes | Behavior | What it means | | - | - | | Existing chats only | Create or receive a chat first, then send typing indicators. | | Pinned line required | Chert sends the signal from the line already attached to the chat. | | Best-effort | Recipients may not always see typing dots, depending on device state and network conditions. | ## See also * [Create a chat](/api/chats/create) * [Send a follow-up](/api/chats/send-message) # Send a contact card Source: https://docs.trychert.com/api/contact-sharing/send-card POST /api/v1/chats/:id/send-contact — send a structured vCard as a tappable contact bubble. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** No. Builds a vCard from the supplied fields and sends it as a tappable contact bubble in the chat. The recipient sees a normal contact card they can tap to save. Works in both DMs and groups. Wrap the card details in a top-level `contact` object (or supply raw `vcard_text`). Include at least one identifying field inside `contact` — `full_name`, `given_name`, `family_name`, `organization`, or a non-empty `phones` / `emails`. This is independent of the line's own saved contact card — use [`share-contact-info`](/api/contact-sharing/share-info) to share the line's "Me" card. ## Addressing the recipient Pick the form that matches your state: 1. **By chat id** — `POST /api/v1/chats/{id}/send-contact` once a chat exists. 2. **By phone** — pass `to` in the request body. Use this on first-touch when no chat exists yet; Chert resolves or creates the chat in-line. ```jsonc theme={null} { "to": "+14155551234", // <- E.164 phone (alternative to {id} in the URL) "contact": { "full_name": "Sean Franklin", "given_name": "Sean", "family_name": "Franklin", "organization": "Acme Inc", "phones": [{ "value": "+14155551234", "label": "mobile" }], "emails": [{ "value": "sean@acme.example", "label": "work" }] } } ``` ## Request `POST /api/v1/chats/{id}/send-contact` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats/ch_01H.../send-contact \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{ "contact": { "full_name": "Sean Franklin", "given_name": "Sean", "family_name": "Franklin", "organization": "Acme Inc", "phones": [{ "value": "+14155551234", "label": "mobile" }], "emails": [{ "value": "sean@acme.example", "label": "work" }] } }' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ contact: { full_name: "Sean Franklin", given_name: "Sean", family_name: "Franklin", organization: "Acme Inc", phones: [{ value: "+14155551234", label: "mobile" }], emails: [{ value: "sean@acme.example", label: "work" }], }, }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/send-contact`, { method: "POST", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, body, }, ) ``` ```python Python theme={null} import hmac, hashlib, json, time, requests ts = int(time.time()) body = json.dumps({ "contact": { "full_name": "Sean Franklin", "given_name": "Sean", "family_name": "Franklin", "organization": "Acme Inc", "phones": [{"value": "+14155551234", "label": "mobile"}], "emails": [{"value": "sean@acme.example", "label": "work"}], }, }) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/chats/{chat_id}/send-contact", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "ch_01H...", "status": "sent" } ``` ### Path parameters **id** `string` Chat identifier (`ch_…`). Omit and use `body.to` for first-touch recipients with no chat yet. ### Body Provide either a `contact` object (canonical) **or** a raw `vcard_text` string. **to** `string` Recipient phone in E.164 format. Use instead of the `{id}` path segment when no chat exists yet. Ignored when `{id}` is supplied. **contact** `object` Canonical contact wrap. Must include at least one identifying field. ```jsonc theme={null} { "full_name": "Sean Franklin", "given_name": "Sean", "family_name": "Franklin", "organization": "Acme Inc", "phones": [{ "value": "+14155551234", "label": "mobile" }], "emails": [{ "value": "sean@acme.example", "label": "work" }] } ``` | Field | Type | Notes | | - | - | - | | `full_name` | string | Formatted display name. | | `given_name` | string | First name. | | `family_name` | string | Last name. | | `organization` | string | Org / company name. | | `phones` | object\[] | Each `{ value: , label?: string }`. | | `emails` | object\[] | Each `{ value, label?: string }`. | **vcard\_text** `string` Raw vCard payload (begins with `BEGIN:VCARD`). Use this when you already produce vCards elsewhere. ### Response fields **chat\_id** `string` The chat the card was sent into. **status** `string` Always `"sent"` on success. ## Errors | Code | HTTP | When | | - | - | - | | `1001` | 400 | Missing `contact` and `vcard_text` (request body needs one of them). | | `1002` | 400 | `contact` has no identifying field, or fields were supplied in an unsupported shape. | | `1004` | 400 | Invalid JSON body. | | `2002` | 404 | Chat not found. On first-touch with no existing chat, address the call with `body.to` instead of `{id}`. | | `4001` | 502 | Delivery failed. | | `4099` | 501 | No pinned line, or the line does not support contact bubbles. | See the full [error code reference](/api/errors). ## See also * [Share line's contact info](/api/contact-sharing/share-info) * [Errors](/api/errors) # Share line's contact info Source: https://docs.trychert.com/api/contact-sharing/share-info POST /api/v1/chats/:id/share-contact-info — share the line's saved 'Me' card. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Line-dependent · **Idempotent:** No. Shares the pinned line's contact information with an existing chat. Set `share_native: true` for native name-and-photo sharing on enabled lines. Omitting the body retains the line's legacy contact-card behavior. For sharing another person's contact details, use [`send-contact`](/api/contact-sharing/send-card); it is the more predictable vCard flow. ## Addressing the recipient | Form | Use when | | - | - | | `POST /api/v1/chats/{id}/share-contact-info` | A chat already exists for this recipient. | ## Request `POST /api/v1/chats/{id}/share-contact-info` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats/ch_01H.../share-contact-info \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "x-chert-tenant: $TENANT" \ -H "Content-Type: application/json" \ -d '{"share_native":true}' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ share_native: true }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/share-contact-info`, { method: "POST", headers: { "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, "Content-Type": "application/json", }, body, }, ) ``` ```python Python theme={null} import hmac, hashlib, time, requests ts = int(time.time()) body = '{"share_native":true}' sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/chats/{chat_id}/share-contact-info", headers={ "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", "Content-Type": "application/json", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "ch_01H...", "status": "shared", "method": "nickname_handshake" } ``` ### Path parameters **id** `string` Chat identifier. The chat must already exist and have a pinned sender line. ### Body Native name-and-photo sharing is available on enabled lines. Request it explicitly: ```json theme={null} { "share_native": true } ``` Optional boolean fields: * `force`: request sharing even if the line considers the contact already shared. * `dry_run`: check readiness without invoking the sharing operation. When true, either option requires `share_native:true`. Lines that do not support these options return `4099` without sharing. On lines supporting the native option, omitting it retains the existing contact-card attachment behavior; it does not request the native name-and-photo handshake. Invalid JSON or non-boolean flags return HTTP 400. A dry run fails if readiness reports an unavailable operation or missing prerequisites. A successful native response can have `status: "shared"`, `"already_shared"`, or `"dry_run_ok"`, with `method: "nickname_handshake"`. `shared` means the native operation returned successfully; it does not confirm that the recipient saw or accepted a name-and-photo prompt. A subsequent outgoing message may be needed. ### Response fields **chat\_id** `string` The chat the line's "Me" card was shared into. **status** `string` `"shared"`, `"already_shared"`, or `"dry_run_ok"`, depending on the requested operation. ## Errors | Code | HTTP | When | | - | - | - | | `1002` | 400 | Chat has no recipient phone. | | `2002` | 404 | Chat not found. | | `4001` | 502 | Delivery failed. | | `4099` | 501 | No pinned line, or the line does not support contact sharing. | See the full [error code reference](/api/errors). ## See also * [Send a contact card](/api/contact-sharing/send-card) * [Errors](/api/errors) # Errors Source: https://docs.trychert.com/api/errors Every error code the Messaging API returns, with recommended actions. All errors return a structured JSON envelope: ```json theme={null} { "success": false, "error": { "status": 400, "code": 1001, "message": "Missing required field: phone", "retryable": false }, "trace_id": "a1b2c3d4e5f6" } ``` Some codes attach extra context fields next to `code`/`message`/`retryable`: | Field | When | | - | - | | `retry_after` | May be present on rate-limit and capacity errors (`1007`, `4002`) — not guaranteed. Seconds to wait; when present, also mirrored in the `Retry-After` header. `retryable` remains the authoritative retry signal. | | `attachment_index` | `1011`/`1002` / `5xxx` raised by a specific entry in an `attachments` or `images` array. 0-based index of the offending entry. | | `allowed_effects` | `4098 UNKNOWN_EFFECT`. The current list of valid `effect` values. | **error.status** `integer` The HTTP status code, repeated in the body for convenience. Note that the HTTP status is **not** derivable from the `code` range — e.g. `1011` is a `1xxx` code returned with HTTP `409`. **error.code** `integer` A stable integer — build your error-handling logic against it. Never changes for a given failure class. **error.message** `string` Human-readable detail. May change between releases — do not branch on it. Internal delivery names, env var names, raw database errors, and crypto-step detail are deliberately **not** included; the full detail is logged server-side under `trace_id`. **error.retryable** `boolean` **Always present and authoritative — branch on this, not on the code or HTTP status.** `true` for `3xxx` server/infra errors, most `4xxx` delivery errors, and `1007` rate-limiting; `false` for everything else (including `4098 UNKNOWN_EFFECT` and `4099 NOT_SUPPORTED`). A few routes override the default — notably `3003 PROVIDER_NOT_CONFIGURED` is sent with `retryable: false` because a missing server-side key won't fix itself. Codes can shift between retryable and not at the route level; always trust the field, never re-derive it. **error.retry\_after** `integer` May be present on rate-limited / capacity responses (`1007`, `4002`) — not guaranteed. Seconds to wait before retrying; when present, mirrored in the `Retry-After` header. When absent, fall back to your own exponential backoff; branch on `retryable`, which is always present. **trace\_id** `string` Correlation id for this response, also returned in the `X-Trace-ID` header (on both success and error responses). Quote it when contacting support. The `code` field is a stable integer — build your error-handling logic against it. The `message` is human-readable and may change between releases. > [!NOTE] > > Every error path across all four customer-facing API surfaces — `/api/v1/*`, `/api/salesforce/*`, `/api/hubspot/*`, `/api/slack/*` — carries a numeric `code`, a human `message`, a `retryable` boolean, and a `trace_id`. Auth and signature failures return **distinct** codes (`2012 AUTH_MISSING`, `2004 AUTH_INVALID`, `2013 AUTH_TIMESTAMP_SKEW`) rather than one opaque "unauthorized". > [!NOTE] > > Some endpoints keep a **flat** shape rather than the `{success,error,trace_id}` envelope. `POST /api/v1/send` failures return `status: "failed"` with top-level `code` and `retryable`, plus a human-readable `reason` on `4xx` (or a generic `message` on `5xx`) and the targeted `lead_id`/`chat_id`/`convo_id` on `4xx` delivery failures — there is **no `trace_id`** on `/send` responses. `/api/salesforce/*` keeps an `{ ok: false, ... }` shape (the Apex client branches on `ok`) with `code` / `message` / `retryable` / `trace_id` added alongside. `/api/hubspot/*` webhook and workflow routes return HTTP `200` for logical failures (HubSpot's Events API contract) with the failure detail in `{ok:false,...}` / `outputFields:{ok:false,...}`. In every case the numeric `code` scheme is the one documented here, so error-handling logic keyed on `code` works across all shapes. See [Sending → Delivery failures](/api/sending#delivery-failures). *** The numeric `code` is the stable contract. The HTTP status is chosen per-route and is **not** derivable from the code range. The `Retryable` column below is the default `error.retryable` value for that code; a route may override it (see `3003`). ## 1xxx — request errors Fix the request before retrying. | Code | HTTP | Retryable | Meaning | Action | | - | - | - | - | - | | 1001 | 400 | No | Missing required field | Check the request body for the named field. | | 1002 | 400 | No | Invalid field value — also returned for malformed `attachments` arrays | Correct the field to a valid value. | | 1003 | 400 | No | Invalid phone number | Use E.164 format, e.g. `+15555550100`. | | 1004 | 400 | No | Invalid JSON | Check `content-type: application/json` and serialization. | | 1005 | 400 | No | First message in a new chat cannot contain URLs in text parts or legacy `link` parts | Use a `rich_link` part for previewed URLs, or send a URL-free first text and add links in a follow-up. | | 1006 | 400 | No | Too many parts in a single message | Reduce to 10 parts or fewer per message. | | 1007 | 429 | **Yes** | Rate limited | Back off and retry; respect `retry_after` / `Retry-After` when present. | | 1008 | 400 | No | Consecutive text parts not permitted | Merge adjacent text parts into one. | | 1009 | 400 | No | Text too long | Shorten the text or split into multiple parts. | | 1010 | 400 | No | Unsupported messaging service | Only `imessage` is supported in v1. | | 1011 | 409 | No | Resource kind mismatch — wrong resource type for the operation, **or an attachment ref mixes `url` and `attachment_id`, or is missing `attachment_id`** | Address the operation at a resource of the correct type. For attachment errors the response includes `attachment_index`; the message hints "register via POST /api/v1/attachments first" when the id is missing. | | 1012 | 409 | No | Resource state invalid — the resource exists but is not in a usable state | Wait for / move the resource into a valid state before retrying. | | 1013 | 409 | No | Contact has no usable phone number | The addressed contact/record carries no phone; enrich it or pick another. | ## 2xxx — auth / resource errors Fix credentials or the target before retrying. | Code | HTTP | Retryable | Meaning | Action | | - | - | - | - | - | | 2001 | 404 | No | Tenant not found | Verify `x-chert-tenant` matches your registration response. | | 2002 | 404 | No | Chat not found | Verify the chat id belongs to your tenant. | | 2003 | 404 | No | Message not found | Verify the message id belongs to your tenant. | | 2004 | 401 | No | Invalid auth — credentials supplied but wrong (bad signature / bad token) | Re-sign with the current timestamp. See [Authentication](/api/authentication). | | 2005 | 403 | No | Forbidden | Tenant suspended or you lack permissions for this resource. Contact your Chert administrator. | | 2006 | 400 | No | Phone number not assigned to your tenant | Use a number from `GET /api/v1/phone-numbers`. | | 2007 | 403 | No | Email not verified | Click the link in the verification email sent at registration. | | 2008 | 402 | No | Account limit reached | Contact your Chert administrator before retrying. | | 2010 | 403 | No | Integration not connected — a required OAuth integration is not linked | Connect the integration (Salesforce / HubSpot / Slack), then retry. | | 2011 | 404 | No | Resource not found — a generically addressed resource is missing (icp / list / sheet / sequence / lead / message) | Verify the id belongs to your tenant. | | 2012 | 401 | No | Auth missing — no credentials were supplied at all | Add the `x-chert-signature` header (or bearer token). See [Authentication](/api/authentication). | | 2013 | 401 | No | Auth timestamp skew — the signature timestamp is outside the allowed clock skew | Fix your system clock and re-sign with a fresh timestamp. | | 2014 | 400 | No | OAuth state invalid — the OAuth callback `state` is missing, expired, or mismatched | Restart the OAuth flow from the beginning. | ## 3xxx — server / infrastructure errors Retryable with exponential backoff — **except `3003`**. | Code | HTTP | Retryable | Meaning | Action | | - | - | - | - | - | | 3001 | 500 | **Yes** | Internal server error | Retry with exponential backoff. Contact support if persistent. | | 3002 | 503 | **Yes** | Delivery unavailable | Chert delivery infrastructure is unreachable. Retry after a short delay. | | 3003 | 500 / 503 | **No** | Delivery service not configured | Not retryable despite the `3xxx` range — the response carries `retryable: false`. Contact support. | | 3004 | 502 | **Yes** | Delivery service error | Retry after a short delay; the dependency may be transiently failing. | ## 4xxx — delivery errors Retryable except `4099`. | Code | HTTP | Retryable | Meaning | Action | | - | - | - | - | - | | 4001 | 502 | **Yes** | Delivery failed | The message was dispatched but the downstream rejected it. Check `message` (or top-level `reason` on the `/api/v1/send` flat response). | | 4002 | 503 | **Yes** | All available phone lines are saturated for this recipient | Retry later. The response includes diagnostic detail; healthy lines free up at the top of the next hour. | | 4098 | 400 | No | Unknown effect — the `effect` value is not in the whitelist | Response carries `allowed_effects: [...]` with the current valid set. Pick one of those values, or omit `effect`. | | 4099 | 422 / 501 | No | Feature not supported in this context | Returned when a route, line, or request shape does not support the requested feature, including group-chat endpoints missing chat key / pinned line and `/messages/{id}/edit` past the 15-min window or on lines without edit support. Status varies by route. Not retryable. | ## 5xxx — attachment errors Fix the request before retrying. | Code | HTTP | Retryable | Meaning | Action | | - | - | - | - | - | | 5001 | 404 | No | Attachment not found | Verify the `attachment_id` belongs to your tenant. | | 5002 | 400 | No | Attachment too large | Reduce file size below the limit. | | 5003 | 400 | No | Unsupported MIME type | Check the supported types list for your tier. | | 5004 | 400 | No | Attachment not yet uploaded | Complete the PUT upload step before referencing the attachment in a message. | *** ## Error envelopes — examples ```json 401 auth_missing theme={null} { "success": false, "error": { "status": 401, "code": 2012, "message": "No credentials supplied", "retryable": false }, "trace_id": "9f8e7d6c" } ``` ```json 502 delivery_service_error theme={null} { "success": false, "error": { "status": 502, "code": 3004, "message": "A delivery service returned an error", "retryable": true }, "trace_id": "b2c3d4e5" } ``` ```json 409 attachment_ref_invalid theme={null} { "success": false, "error": { "status": 409, "code": 1011, "message": "Attachment ref missing attachment_id — register via POST /api/v1/attachments first", "retryable": false, "attachment_index": 2 }, "trace_id": "c3d4e5f6" } ``` ```json 400 unknown_effect theme={null} { "success": false, "error": { "status": 400, "code": 4098, "message": "Unknown effect: 'fireworks'", "retryable": false, "allowed_effects": ["celebration", "balloons", "confetti", "fireball", "heart", "laser", "lasers", "shootingstar", "sparkles", "spotlight"] }, "trace_id": "d4e5f6a7" } ``` *** ## Retry guidance `error.retryable` is the authoritative signal — it is **always present** on every error response, and clients should branch on it directly rather than re-deriving retryability from the code range or HTTP status. The table below explains the strategy per class. | Class | `retryable` | Strategy | | - | - | - | | `1xxx` except `1007` (400/409) | `false` | Fix the request and resubmit. | | `1007` (429) | `true` | Respect `retry_after` / `Retry-After` when present. Exponential backoff otherwise. | | `2xxx` (401/402/403/404) | `false` | Fix credentials, account state, integration connection, or target id — then resubmit. | | `3001` (500), `3002` (503) | `true` | Exponential backoff: 1s, 2s, 4s. | | `3003` (500/503) | `false` | Server-side config gap — retrying won't help. Contact support. | | `3004` (502) | `true` | Delivery service failed transiently. Short delay, then retry. | | `4001` (502), `4002` (503) | `true` | Re-attempt after a short delay; check `message` for the reason. | | `4098` (400) | `false` | Pick a value from `allowed_effects` in the response, or omit the field. | | `4099` (422/501) | `false` | Feature not live in this context; do not retry. | | `5xxx` (400/404) | `false` | Fix the attachment reference and resubmit. | Use `idempotency_key` on `POST /api/v1/send` and `POST /api/v1/chats` to make retries safe. ## See also * [Authentication](/api/authentication) * [Sending](/api/sending) * [Create a chat](/api/chats/create) ### SMS retry failure `4104` (HTTP 502, `retryable: false`) means a confirmed send failed and automatic SMS retry was unavailable or also failed. Check the message status and line configuration before initiating a new send. An uncertain result is reported separately and must not be treated as proof that no message was sent. # Events Source: https://docs.trychert.com/api/events Inspect, stream, and replay webhook deliveries. Every webhook delivery is persisted to a tenant-scoped event log before any HTTP attempt. The endpoints below let you read, stream, and replay events without leaving the API. | Endpoint | Use when | | - | - | | `GET /api/v1/events` | List events with filters and cursor pagination. | | `GET /api/v1/events/{id}` | Inspect one event including full attempt history. | | `POST /api/v1/events/{id}/replay` | Re-fire a previously-recorded event. | | `GET /api/v1/events/stream` | Server-Sent Events feed of new events. | | `GET /api/v1/dev-inbox` | Pull events when you have not yet wired up a webhook. | All endpoints require the standard `x-chert-tenant` + signature/bearer auth. ## List events ``` GET /api/v1/events ``` | Query | Type | Description | | - | - | - | | `type` | string | Exact event type, e.g. `message.received`. | | `status` | enum | `pending`, `delivered`, `failed`, `dev_inbox`, or `no_destination` (no webhook destination configured — delivery not applicable). | | `lead_id` | string | Only events tied to this lead. | | `since` | ISO 8601 | Events with `created_at > since`. | | `limit` | int | 1–200, default 50. | | `cursor` | string | Opaque cursor from a prior `next_cursor`. | ```json Response theme={null} { "events": [ { "id": "evt_01H...", "type": "message.received", "event_id": "chert:msg:0D36042D-C551-4CB4-9BE7-853832D6F2FE", "lead_id": "ld_01H...", "convo_id": "cv_01H...", "payload": { /* full event envelope */ }, "delivery": { "status": "delivered", "url": "https://your-app.example/hooks/chert", "attempts": [ { "at": "2026-04-29T18:15:26Z", "status": 200, "duration_ms": 142, "subscription_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f" } ], "delivered_at": "2026-04-29T18:15:26Z" }, "created_at": "2026-04-29T18:15:25Z" } ], "next_cursor": "2026-04-29T18:14:11Z|evt_01H...", "count": 1 } ``` Pass `next_cursor` back as `cursor` to read the next page. When `next_cursor` is `null`, you have reached the end. ## Get one event ``` GET /api/v1/events/{id} ``` Returns the same shape as a list row, with the full `delivery.attempts` array. Each attempt records the HTTP status, duration, and any error string. ## Replay an event ``` POST /api/v1/events/{id}/replay ``` Re-POSTs the original payload to matching active webhook subscriptions, appends fresh attempts, and updates `delivery.status`. Use this after fixing a bug in your webhook handler. To replay to one subscription, add `?subscription_id=`. ```json 200 OK theme={null} { "ok": true, "downstream_status": 200, "message": "event re-delivered" } ``` ```json 502 Bad Gateway theme={null} { "ok": false, "error": "non-2xx response", "downstream_status": 503, "code": 3004, "message": "non-2xx response", "retryable": true, "trace_id": "d5e6f7a8" } ``` The replay route keeps its `{ ok, error, downstream_status }` shape and adds the canonical numeric `code`, a `message`, a `retryable` boolean, and a `trace_id`. A `404` (`code 2011` — event not found) and a `400` (`code 1002` — no delivery destination configured, or a dev-inbox tenant) are both `retryable: false`; a `502` delivery failure (`code 3004`) is `retryable: true`. See [Errors](/api/errors). ## Stream events ``` GET /api/v1/events/stream ``` Server-Sent Events feed. Useful when developing locally without a public URL — equivalent to `stripe listen`. * Connection lives 60 seconds, then closes with `event: close`. Clients should reconnect. * Heartbeat comments arrive every 15 seconds to keep middleboxes from idling out the socket. * Pass `?since=` to resume from a known cursor after a reconnect. ```bash theme={null} curl -N https://console.trychert.com/api/v1/events/stream \ -H "x-chert-tenant: $SLUG" \ -H "authorization: Bearer $SECRET" ``` ``` : connected 2026-04-29T18:15:25Z since=2026-04-29T18:15:25Z data: {"id":"evt_01H...","type":"message.received","payload":{...},"created_at":"..."} : heartbeat event: close data: {"reason":"ttl_reached","reconnect_with_since":"..."} ``` ## Dev inbox When you create a subscription with `url: "https://dev-inbox"` (the short sentinel), deliveries are recorded but no outbound POST is attempted. Pull them with: ``` GET /api/v1/dev-inbox?since=&limit= ``` Switch to a real URL before going to production. Dev-inbox events accumulate indefinitely. ## See also * [Receiving replies](/api/receiving-replies) * [Errors](/api/errors) # Add a participant Source: https://docs.trychert.com/api/group-chats/add-participant POST /api/v1/chats/:id/participants — add a phone number or email to a group chat. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** Effectively yes — adding the same address twice is a no-op. Adds a phone number or email handle to an existing iMessage group chat. The line pinned to this chat handles the operation. Direct messages return `1006`. ## Request `POST /api/v1/chats/{id}/participants` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats/ch_01H.../participants \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{"address": "+14155551234"}' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ address: "+14155551234" }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/participants`, { method: "POST", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, body, }, ) ``` ```python Python theme={null} import hmac, hashlib, json, time, requests ts = int(time.time()) body = json.dumps({"address": "+14155551234"}) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/chats/{chat_id}/participants", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "ch_01H...", "added": "+14155551234", "participants": ["+14155550100", "+14155550200", "+14155551234"] } ``` ### Path parameters **id** `string` *required* Chat identifier (`ch_…`). ### Body **address** `string` *required* E.164 phone number or email address to add to the group. `handle` is accepted as an alias. ### Response fields **chat\_id** `string` The chat the participant was added to. **added** `string` The address that was added (echoed back, normalized). **participants** `string[]` Full participant roster after the add. ## Errors | Code | HTTP | When | | - | - | - | | `1001` | 400 | `address` missing. | | `1006` | 400 | Chat is a direct message — participants endpoint only applies to groups. | | `2002` | 404 | Chat not found. | | `4001` | 502 | Delivery failed (add participant was rejected). | | `4099` | 422 | Group is missing its chat key or has no pinned line. | See the full [error code reference](/api/errors). ## See also * [Remove a participant](/api/group-chats/remove-participant) * [Rename a group](/api/group-chats/rename) * [Leave a group](/api/group-chats/leave) * [Create a group](/api/group-chats/create) # Create a group Source: https://docs.trychert.com/api/group-chats/create POST /api/v1/chats — create an iMessage group chat with 2-31 recipients. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** Yes when `message.idempotency_key` is supplied. Creates an iMessage group and returns a Chert `chat.id`. Use that `chat.id` for follow-up messages, history, mark-read, reactions to messages, and group operations. Typing indicators in group chats are not yet available on every phone line — where unsupported, `POST /api/v1/chats/{id}/typing` returns `4099 NOT_SUPPORTED` for the group instead of applying the indicator. Group creation uses the same route as direct chat creation: send `POST /api/v1/chats` with 2-31 recipients in `to`. ## Request `POST /api/v1/chats` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "from": "+15555550100", "to": ["+14155551234", "+14155559876"], "message": { "subject": "Launch follow-up", "parts": [ { "type": "text", "value": "Starting this group here." } ], "idempotency_key": "group-launch-follow-up-001" } }' ``` ```js Node.js theme={null} const res = await fetch("https://console.trychert.com/api/v1/chats", { method: "POST", headers: { "Authorization": `Bearer ${CHERT_SIGNING_SECRET}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "+15555550100", to: ["+14155551234", "+14155559876"], message: { subject: "Launch follow-up", parts: [{ type: "text", value: "Starting this group here." }], idempotency_key: "group-launch-follow-up-001", }, }), }) ``` > [!WARNING] > > Use a text-only first message when creating a group. Send media attachments or rich links afterward with `POST /api/v1/chats/{id}/messages`. ## Response ```json 200 OK theme={null} { "chat": { "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "display_name": "Launch follow-up", "handles": [ { "service": "imessage", "address": "+14155551234" }, { "service": "imessage", "address": "+14155559876" } ], "is_group": true, "service": "imessage", "health_score": 87, "created_at": "2026-05-01T12:00:00Z" }, "message": { "id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "parts": [{ "type": "text", "value": "Starting this group here." }], "status": "sent", "direction": "outbound", "created_at": "2026-05-01T12:00:00Z", "delivered_at": null, "read_at": null, "service": "imessage" } } ``` ### Body **from** `string` *required* Sender phone number from [Phone numbers](/api/phone-numbers). **to** `string[]` *required* 2-31 E.164 phone numbers or Apple ID email handles. **message.parts** `object[]` *required* First group-create message parts. Use one `text` part for the initial group creation. **message.subject** `string` Optional group display name. **message.idempotency\_key** `string` Optional retry key. Reuse the same key and body to safely retry the create call. ### Response fields **chat.id** `string` Chert chat id for this group. Use it with `/api/v1/chats/{id}/messages` and group-management routes. **chat.handles** `object[]` Known group participant handles. This is useful for display and debugging, but the durable group identity is the Chert `chat.id`. **message.id** `string` Message id for the first outbound group message. Use this id with message-scoped routes such as reactions. ## Externally-created groups If a Chert-managed number is added to a group outside the API, Chert creates a `chat.id` for that group when the number receives the first group event. The group is identified internally by the upstream group conversation key, then decorated with the participant handles and display name that are available on the event. This means silent membership changes are not visible until a message, reaction, or other group event reaches the Chert line. ## Errors | Code | HTTP | When | | - | - | - | | `1001` | 400 | Missing `from`, `to`, or `message.parts`. | | `1003` | 400 | Invalid phone number or Apple ID email handle. | | `2006` | 403 | `from` is not assigned to this tenant. | | `4001` | 502 | Downstream delivery failed. | | `1006` | 422 | More than 31 recipients. | | `4099` | 501 | Group creation included media/rich links in the first message. | See the full [error code reference](/api/errors). ## See also * [Send a follow-up](/api/chats/send-message) * [Rename a group](/api/group-chats/rename) * [Add a participant](/api/group-chats/add-participant) # Leave a group Source: https://docs.trychert.com/api/group-chats/leave POST /api/v1/chats/:id/leave — leave an iMessage group chat from the pinned line. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** No (leaving twice yields `1006` once the chat is no longer a group from your line's perspective). Leaves an iMessage group chat from the line pinned to this chat. The group must have at least 4 participants before leaving so 3 remain afterward. No body required. ## Request `POST /api/v1/chats/{id}/leave` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/chats/ch_01H.../leave \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = "" const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch(`https://console.trychert.com/api/v1/chats/${chatId}/leave`, { method: "POST", headers: { "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, }) ``` ```python Python theme={null} import hmac, hashlib, time, requests ts = int(time.time()) body = "" sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/chats/{chat_id}/leave", headers={ "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "ch_01H...", "status": "left" } ``` ### Path parameters **id** `string` *required* Chat identifier (`ch_…`). ### Response fields **chat\_id** `string` The chat that was left. **status** `string` Always `"left"` on success. ## Errors | Code | HTTP | When | | - | - | - | | `1002` | 400 | Group would have fewer than 3 members after leaving. | | `1006` | 400 | Chat is a direct message. | | `2002` | 404 | Chat not found. | | `4001` | 502 | Delivery failed. | | `4099` | 501 | Group is missing its chat key or has no pinned line. | See the full [error code reference](/api/errors). ## See also * [Remove a participant](/api/group-chats/remove-participant) * [Add a participant](/api/group-chats/add-participant) * [Create a group](/api/group-chats/create) # Remove a participant Source: https://docs.trychert.com/api/group-chats/remove-participant DELETE /api/v1/chats/:id/participants/:address — remove a participant from a group chat. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** No (removing the same address twice yields a "not in group" error). Removes a participant from an iMessage group chat. The `address` path parameter is URL-encoded (E.164 phone or email). Apple enforces a minimum group size of 3 — removals that would shrink the group below 3 are rejected with `1002`. ## Request `DELETE /api/v1/chats/{id}/participants/{address}` ```bash cURL theme={null} curl -X DELETE \ "https://console.trychert.com/api/v1/chats/ch_01H.../participants/%2B14155551234" \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = "" const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const address = encodeURIComponent("+14155551234") const res = await fetch( `https://console.trychert.com/api/v1/chats/${chatId}/participants/${address}`, { method: "DELETE", headers: { "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, }, ) ``` ```python Python theme={null} import hmac, hashlib, time, urllib.parse, requests ts = int(time.time()) body = "" sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() addr = urllib.parse.quote("+14155551234", safe="") r = requests.delete( f"https://console.trychert.com/api/v1/chats/{chat_id}/participants/{addr}", headers={ "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, ) ``` ## Response ```json 200 OK theme={null} { "chat_id": "ch_01H...", "removed": "+14155551234", "participants": ["+14155550100", "+14155550200"] } ``` ### Path parameters **id** `string` *required* Chat identifier (`ch_…`). **address** `string` *required* URL-encoded E.164 phone number or email of the participant to remove. ### Response fields **chat\_id** `string` The chat the participant was removed from. **removed** `string` The address that was removed. **participants** `string[]` Full participant roster after the remove. ## Errors | Code | HTTP | When | | - | - | - | | `1001` | 400 | `address` missing or not in the group. | | `1002` | 400 | Group is at the minimum size of 3 — cannot shrink further. | | `1006` | 400 | Chat is a direct message. | | `2002` | 404 | Chat not found. | | `4001` | 502 | Delivery failed. | | `4099` | 422 | Group is missing its chat key or has no pinned line. | See the full [error code reference](/api/errors). ## See also * [Add a participant](/api/group-chats/add-participant) * [Leave a group](/api/group-chats/leave) * [Rename a group](/api/group-chats/rename) # Rename a group Source: https://docs.trychert.com/api/group-chats/rename PUT /api/v1/chats/:id — change a group chat's display name or icon. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** Yes (last write wins). Renames a group chat or sets its group icon. Supplying both fields in one call applies both. The line pinned to this chat handles the operation. Direct messages do not support these fields and return `1006`. ## Request `PUT /api/v1/chats/{id}` ```bash cURL theme={null} curl -X PUT https://console.trychert.com/api/v1/chats/ch_01H... \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{ "display_name": "Acme launch crew", "group_chat_icon": "https://cdn.example.com/acme-icon.png" }' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ display_name: "Acme launch crew", group_chat_icon: "https://cdn.example.com/acme-icon.png", }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch(`https://console.trychert.com/api/v1/chats/${chatId}`, { method: "PUT", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, body, }) ``` ```python Python theme={null} import hmac, hashlib, json, time, requests ts = int(time.time()) body = json.dumps({ "display_name": "Acme launch crew", "group_chat_icon": "https://cdn.example.com/acme-icon.png", }) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.put( f"https://console.trychert.com/api/v1/chats/{chat_id}", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "chat": { "id": "ch_01H...", "display_name": "Acme launch crew", "group_chat_icon": "https://cdn.example.com/acme-icon.png", "is_group": true } } ``` ### Path parameters **id** `string` *required* Chat identifier (`ch_…`). ### Body **display\_name** `string` New display name for the group. Trimmed; must be non-empty if present. **group\_chat\_icon** `string` HTTPS URL of the new group icon image. ### Response fields **chat** `object` Refreshed chat object with the updated `display_name` and `group_chat_icon` fields. ## Errors | Code | HTTP | When | | - | - | - | | `1004` | 400 | Invalid JSON body. | | `1006` | 400 | Chat is a direct message — only group chats support display name + icon. | | `2002` | 404 | Chat not found. | | `4001` | 502 | Delivery failed (rename or icon update was rejected). | | `4099` | 501 | Group is missing its chat key or has no pinned line. | See the full [error code reference](/api/errors). ## See also * [Add a participant](/api/group-chats/add-participant) * [Create a group](/api/group-chats/create) # Send a location Source: https://docs.trychert.com/api/locations POST /api/v1/locations - send a one-time location pin. Sends a one-time location pin to a phone number when location sharing is enabled on the selected sender line. > [!WARNING] > > This is not Find My or ongoing Live Location sharing. It sends one fixed latitude/longitude. The recipient cannot follow movement from this endpoint. Use this when you already have coordinates and want the recipient to receive a Maps-style location card when their sender line supports it. With `fallback_mode: "auto"`, Chert can fall back to a normal Apple Maps URL after an enabled sender line accepts the request. If the selected sender line does not support location sends, Chert returns `4099`. ## Request ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/locations \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155551234", "latitude": 40.758896, "longitude": -73.985130, "label": "Times Square", "address": "Manhattan, NY", "fallback_mode": "auto", "expires_at": "2026-06-02T23:00:00Z", "idempotency_key": "loc-2026-06-02-001" }' ``` ## Advanced chat routing Most callers should send by `phone`. `chat_id` is only for Chert-provided delivery chat ids; it is not the UUID returned by `POST /api/v1/chats`. ```json theme={null} { "chat_id": "iMessage;+;chat42", "latitude": 40.758896, "longitude": -73.985130, "label": "Times Square" } ``` ## Response ```json theme={null} { "message_id": "msg_01H...", "status": "queued", "phone_line_id": "pl_15555550100", "delivery": "queued", "location": { "latitude": 40.758896, "longitude": -73.98513, "label": "Times Square", "address": "Manhattan, NY", "mode": "balloon", "fallback_mode_requested": "auto" } } ``` `202` means the location request was accepted by the sender line. The final recipient UI is device-dependent: supported iMessage clients may render a map card; fallback clients receive a tappable Apple Maps URL. ## Fields | Field | Required | Notes | | - | - | - | | `phone` | Yes, unless `chat_id` is set | Recipient in E.164 format. Pass either `phone` or `chat_id`, not both. | | `chat_id` | Yes, unless `phone` is set | Chert-provided delivery chat id. This is not the public UUID returned by `/api/v1/chats`. Pass either `chat_id` or `phone`, not both. | | `latitude` | Yes | Number from `-90` to `90`. | | `longitude` | Yes | Number from `-180` to `180`. | | `label` | No | Place name shown in the card or text fallback. | | `address` | No | Secondary place text. | | `url` | No | Custom map URL. Omit unless Chert asks you to override the default Apple Maps link. | | `fallback_mode` | No | `auto` is recommended. `balloon` requests a map-card style message; `geo_url` sends plain text; `vcard` is not supported. | | `expires_at` | No | Stored and echoed for your own bookkeeping. It does not create an ongoing or expiring Live Location share on the recipient device. | | `phone_line_id` | No | Use a specific assigned sender line. Most callers should omit this. | | `idempotency_key` | No | Safe retry key for the same location request. | | `external_id` | No | Your application record id. | ## Common errors | Code | When | | - | - | | `1001` | Missing `phone`/`chat_id`, or missing coordinates. | | `1002` | Both `phone` and `chat_id` were passed, or coordinates are out of range. | | `3002` | No eligible sender line is available for location sharing. | | `4001` | The sender line accepted the request but delivery failed downstream. | | `4099` | Location sharing is not available on the selected sender line, or the requested mode is unsupported. | ## See also * [Sending](/api/sending) * [Create a chat](/api/chats/create) * [Send a message](/api/chats/send-message) * [Errors](/api/errors) # Edit a message Source: https://docs.trychert.com/api/messages/edit POST /api/v1/messages/:id/edit — edit a previously-sent outbound message within Apple's 15-minute window. > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** No (each call posts a fresh edit; pass `new_text` you want the bubble to read). Replaces the text of a previously-sent outbound message. The recipient sees the bubble update in place with an "Edited" marker, matching native iMessage behavior. Apple enforces a **15-minute window from send time** — edits attempted after the window return an error. ### Which id goes in the URL This is a message-scoped route: `{id}` is a `message_id`, not a `chat_id`. Prefer the Chert-issued **`message_id`** returned by [`POST /api/v1/send`](/api/sending), or the `message.id` returned by [`POST /api/v1/chats`](/api/chats/create) and [`POST /api/v1/chats/{id}/messages`](/api/chats/send-message). Chert resolves that id to the underlying iMessage identifier internally before applying the edit. > [!NOTE] > > For outbound messages surfaced by [`GET /api/v1/chats/{id}/messages`](/api/chats/list-messages), the listed message `id` is also accepted. ## Request `POST /api/v1/messages/{id}/edit` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/messages/msg_01H.../edit \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{"new_text": "Updated copy — typo fix"}' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ new_text: "Updated copy — typo fix" }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/messages/${messageId}/edit`, { method: "POST", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, body, }, ) ``` ```python Python theme={null} import hmac, hashlib, json, time, requests ts = int(time.time()) body = json.dumps({"new_text": "Updated copy — typo fix"}) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/messages/{message_id}/edit", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "status": "edited", "message_id": "msg_01H...", "new_text": "Updated copy — typo fix", "date_edited": "2026-05-27T16:42:11.318Z", "edit_history": null } ``` ### Path parameters **id** `string` *required* The Chert `message_id` returned by a send endpoint, or the outbound message `id` surfaced on chat-message listings. ### Body **new\_text** `string` *required* The replacement bubble text. Empty strings are rejected. **edited\_part\_index** `integer` (default: 0) For multi-part messages, the zero-based index of the text part to edit. Default `0` (the first text part). Most sends have only one text part. ### Response fields **status** `string` Always `"edited"` on success. **message\_id** `string` Echoed-back `message_id` of the edited bubble. **new\_text** `string` The text that now appears in the bubble. **date\_edited** `string` ISO-8601 timestamp the edit was applied. **edit\_history** `array | null` Reserved for future use. May surface previous versions of the bubble when the underlying transport reports them. ## Errors | Code | HTTP | When | | - | - | - | | `1002` | 400 | `new_text` missing or empty. | | `1004` | 400 | Invalid JSON body. | | `2003` | 404 | Message not found, or not owned by this tenant. | | `4001` | 502 | Delivery failed — edit window may have expired, or the bubble is no longer editable. | | `4099` | 501 | Edit is not available on this phone line. | See the full [error code reference](/api/errors). ## Notes | Behavior | What it means | | - | - | | 15-minute window | Apple-enforced. Edits attempted past 15 minutes from the original send fail with `4001`. | | Outbound only | You can only edit messages **you** sent. Inbound messages cannot be edited. | | Recipient experience | The bubble updates in place with an "Edited" marker. No new bubble is created. | ## See also * [React to a message](/api/messages/react) * [Send a message](/api/sending) # React to a message Source: https://docs.trychert.com/api/messages/react POST /api/v1/messages/:id/react — send a tapback reaction (love, like, dislike, laugh, emphasize, question). > [!NOTE] > > **Auth.** HMAC v1 or bearer token — see [Authentication](/api/authentication). > **Status.** Live · **Idempotent:** No (each call posts a fresh tapback). Sends a tapback reaction to a message in the chat. The six valid values map to the standard iMessage tapbacks (`love` ❤, `like` 👍, `dislike` 👎, `laugh` 😂, `emphasize` ‼, `question` ❓). The reaction is sent from the same line the original message was sent from, so the recipient sees the tapback attached to the right bubble. ### Which id goes in the URL This is a message-scoped route: `{id}` is a `message_id`, not a `chat_id`. Prefer the Chert-issued **`message_id`** returned by [`POST /api/v1/send`](/api/sending), or the `message.id` returned by [`POST /api/v1/chats`](/api/chats/create) and [`POST /api/v1/chats/{id}/messages`](/api/chats/send-message). Chert resolves that id to the underlying iMessage identifier internally before sending the tapback. > [!NOTE] > > For inbound messages and history rows, the `id` from [`GET /api/v1/chats/{id}/messages`](/api/chats/list-messages) and inbound `message.received` webhooks is also accepted. ## Request `POST /api/v1/messages/{id}/react` ```bash cURL theme={null} curl -X POST https://console.trychert.com/api/v1/messages/msg_01H.../react \ -H "x-chert-tenant: $TENANT" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{"reaction": "love"}' ``` ```js Node.js theme={null} const ts = Math.floor(Date.now() / 1000) const body = JSON.stringify({ reaction: "love" }) const sig = crypto.createHmac("sha256", SIGNING_SECRET).update(`${ts}.${body}`).digest("hex") const res = await fetch( `https://console.trychert.com/api/v1/messages/${messageId}/react`, { method: "POST", headers: { "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": `v1,${ts},${sig}`, }, body, }, ) ``` ```python Python theme={null} import hmac, hashlib, json, time, requests ts = int(time.time()) body = json.dumps({"reaction": "love"}) sig = hmac.new(SIGNING_SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() r = requests.post( f"https://console.trychert.com/api/v1/messages/{message_id}/react", headers={ "Content-Type": "application/json", "x-chert-tenant": TENANT_SLUG, "x-chert-signature": f"v1,{ts},{sig}", }, data=body, ) ``` ## Response ```json 200 OK theme={null} { "status": "sent", "message_id": "msg_01H...", "reaction": "love" } ``` ### Path parameters **id** `string` *required* The Chert `message_id` returned by a send endpoint, or the message `id` surfaced on chat-message listings and inbound `message.received` webhook payloads. ### Body **reaction** `enum` *required* One of: `love` | `like` | `dislike` | `laugh` | `emphasize` | `question`. Maps to the six standard iMessage tapbacks. ### Response fields **status** `string` Always `"sent"` on success. **message\_id** `string` The Chert `message_id` the reaction was attached to. **reaction** `enum` Echoed-back normalized reaction value. ## Errors | Code | HTTP | When | | - | - | - | | `1002` | 400 | Invalid reaction value. | | `1004` | 400 | Invalid JSON body. | | `2003` | 404 | Message not found. | | `4001` | 502 | Delivery failed downstream. | See the full [error code reference](/api/errors). ## See also * [Edit a message](/api/messages/edit) * [Send a message](/api/chats/send-message) * [List messages in a chat](/api/chats/list-messages) # Overview Source: https://docs.trychert.com/api/overview Send and receive iMessage from any system over HTTP. The Chert Messaging API is a tenant-scoped HTTP API for iMessage. Use it when your product already has a UI and you want Chert to handle lines, delivery, replies, and conversation state. > [!NOTE] > > Chert may route a tenant through different managed delivery infrastructure, but the public API contract stays the same. These docs only cover endpoints Chert supports through the shared API surface. ## The model | Object | What it means | | - | - | | Tenant | Your isolated workspace. Authenticated by bearer token or HMAC signature. | | Phone number | A Chert-managed sender line assigned to your tenant's project. | | Chat | A 1:1 or group conversation id used with chat-scoped routes such as `/chats/{id}/messages`. Chat-scoped sends use the chat's current sender line. | | Message | One outbound or inbound item in a chat. Message-scoped routes such as `/messages/{id}/react` use the message id, not the chat id. | | Event | Durable reply/delivery record delivered by webhook, polling, or SSE. | ## Route ids | Route family | `{id}` means | | - | - | | `/api/v1/chats/{id}/...` | `chat_id` | | `/api/v1/messages/{id}/...` | `message_id` | ## Common flows | Goal | API path | | - | - | | Send one text message fast | `POST /api/v1/send` | | Send a native voice note | `POST /api/v1/voice-notes`, then `POST /api/v1/send` with `voice_note` on the same `phone_line_id` | | Send a one-time location pin | `POST /api/v1/locations` | | Build a conversation UI | `POST /api/v1/chats`, then `POST /api/v1/chats/{id}/messages` | | Create a group | `POST /api/v1/chats` with 2-31 recipients in `to` | | Look up one message | `GET /api/v1/messages/{id}` | | Send a file | `POST /api/v1/attachments`, `PUT upload_url`, then send a `media` part | | Send multiple images | Upload attachments, then send them through a chat message with `parallel_attachments: true` | | React to a message | `POST /api/v1/messages/{id}/react` | | Receive replies in production | `POST /api/v1/webhook-subscriptions` | | Develop without a public webhook | `GET /api/v1/events/stream` | ## Supported today | Capability | Notes | | - | - | | 1:1 text sends | Use `/send` for the shortest path or `/chats` for first-class conversation objects. | | Follow-up messages | Use `/chats/{id}/messages`; the body must be `{ "message": { "parts": [...] } }`. | | Rich link previews | Use a `rich_link` part: `{ "type": "rich_link", "url": "https://..." }`. | | One-time location pins | Use `/locations` with `latitude` and `longitude` when enabled on the selected sender line. This is not ongoing Live Location sharing. | | Native voice notes | Stage genuine CAF or uncompressed WAV through `/voice-notes`, then send the temporary ID with `voice_note` on the same enabled sender line. Voice notes are not generic attachments. | | Attachments | Upload first, then reference `attachment_id` in a `media` part. Create group chats with text first; send media and rich links through `/chats/{id}/messages` after the group exists. | | Multiple-image attachments | Send multiple uploaded images through `/chats/{id}/messages` with `parallel_attachments: true`. It can render as one grouped iMessage on supported Apple devices. | | Group chats | Create with 2-31 recipients. Use text for the first group message; participant management requires a pinned group chat. | | Tapbacks | Six standard iMessage reactions: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. | | Typing indicator | Best-effort on existing pinned chats. Treat it as a UI signal, not a delivery guarantee. | | Webhooks and events | `message.received` is the recommended real-time inbound event. Legacy `lead_reply` remains available. | ## Where to go next | Goal | Read | | - | - | | First send | [Quickstart](/api/quickstart) | | Copy common flows | [Usage examples](/api/usage-examples) | | Auth | [Authentication](/api/authentication) | | Conversation API | [Create a chat](/api/chats/create) | | Group chats | [Create a group](/api/group-chats/create) | | Voice notes | [Voice notes](/api/voice-notes) | | Attachments | [Attachments](/api/attachments) | | Replies | [Receiving replies](/api/receiving-replies) | | Errors | [Errors](/api/errors) | # Phone numbers Source: https://docs.trychert.com/api/phone-numbers List sender numbers assigned to your tenant. Use this endpoint before `POST /api/v1/chats`. Pass one returned `phone_number` as the `from` value. Use `phone_line_id` when an endpoint lets you pin a send to a specific sender line. ## Request ```bash theme={null} curl https://console.trychert.com/api/v1/phone-numbers \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` ## Response ```json theme={null} { "phone_numbers": [ { "id": "pl_01H...", "phone_line_id": "pl_01H...", "phone_number": "+15555550100", "service": "imessage", "is_active": true, "capabilities": ["imessage", "send", "receive"] } ] } ``` ## Fields | Field | Meaning | | - | - | | `id` | Stable Chert phone-line id. Same value as `phone_line_id`. | | `phone_line_id` | Use this with request fields named `phone_line_id`. | | `phone_number` | E.164 sender number. Use this as `from` in Chats. | | `service` | Currently `imessage` for Chert-managed sends. | | `is_active` | Whether the number can currently participate in messaging. | | `capabilities` | `send`, `receive`, `imessage`, and future service flags. | Chert pins a sender line after the first successful conversation with a recipient. Follow-ups reuse that line automatically. For [native voice notes](/api/voice-notes), choose an active line whose `capabilities` includes `send` and that Chert has enabled for voice notes. Stage with its `phone_line_id`, then copy the exact `phone_line_id` returned by staging into `/send`. Staged voice-note IDs cannot move between lines. ## See also * [Create a chat](/api/chats/create) * [Sending](/api/sending) * [Voice notes](/api/voice-notes) # Polls Source: https://docs.trychert.com/api/polls Send a poll and read its votes. > [!NOTE] > > Polls are available on enabled lines. Send a message first, then use its `chat_id`. Unsupported lines return `422` with code `4099`. ### Create a poll `POST /api/v1/polls` ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/polls \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chat_id": "YOUR_CHAT_ID", "title": "What would you like help with?", "options": ["Getting started", "Finding people", "Something else"], "idempotency_key": "onboarding-question-1" }' ``` Use 2–12 distinct options (up to 100 characters each) and a title up to 256 characters. `idempotency_key` is required: reuse it for retries, and use a new key for each new poll. Include `x-chert-tenant` if your key requires it. ### Read answers `GET /api/v1/polls/{id}` — use the poll `id` returned by creation. Both endpoints return `id`, `chat_id`, `phone_line_id`, `message_guid`, `title`, `options` (`id`, `text`, `vote_count`), `votes` (`option_id`, `voter`), and `total_voters`. Repeating creation returns the original result; GET returns current votes, including changes and removals. Only your tenant's polls are accessible. For onboarding, check GET periodically while waiting for an answer. Vote-change webhooks and closing polls are not supported yet. Rendering depends on the recipient's Messages version. Invalid input returns `400`; an unknown chat or poll returns `404`; reusing a key with different content returns `409`. For retryable errors, retry with the **same** key. See [Errors](/api/errors). # Quickstart Source: https://docs.trychert.com/api/quickstart From signed-in console to a delivered iMessage in five minutes. This guide walks you from the Chert console to a live iMessage. By the end you'll have your tenant slug, signing secret, and one phone number from your project, plus a working `curl` send. If you'd rather hand the API to an LLM, [skip to the agent path](#hand-off-to-an-ai-agent) — copy one markdown blob from the console and paste it into Claude Code or Cursor. ## Before you begin * A Chert account with at least one project provisioned. Ask your Chert admin if you're not sure. * `curl` and a terminal. ## 1. Pull your credentials from the console Open the Chert console and select the project you want to send from. Three things you'll need, all visible from the project's sidebar: | What | Where to find it | Example | | - | - | - | | **Tenant slug** | API keys page → "Tenant slug" column | `acme-7a3f` | | **Signing secret** | API keys page → "Reveal" on the row matching your tenant | `8f2c9d…` (32 hex chars) | | **Phone number** | Phone numbers page → any active line in your project | `+14152165723` | > [!NOTE] > > The signing secret is shown in full only once at provisioning. If you've lost it, your admin can rotate via the API keys page — be aware rotation invalidates the previous secret immediately. You can also confirm your tenant identity any time: ```bash theme={null} curl https://console.trychert.com/api/v1/whoami \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` The response includes your slug, project id, active state, and `endpoints_available`. ## 2. Send your first message Bearer auth is the simplest mode — single-tenant accounts don't need any other header. ```bash curl theme={null} curl https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155551234", "body": "Hi Sam — testing Chert." }' ``` ```js Node.js theme={null} const res = await fetch("https://console.trychert.com/api/v1/send", { method: "POST", headers: { "authorization": `Bearer ${process.env.CHERT_SIGNING_SECRET}`, "content-type": "application/json", }, body: JSON.stringify({ phone: "+14155551234", body: "Hi Sam — testing Chert.", }), }) console.log(await res.json()) ``` ```python Python theme={null} import os, requests r = requests.post( "https://console.trychert.com/api/v1/send", headers={ "authorization": f"Bearer {os.environ['CHERT_SIGNING_SECRET']}", "content-type": "application/json", }, json={"phone": "+14155551234", "body": "Hi Sam — testing Chert."}, ) print(r.json()) ``` A successful send returns: ```json Response theme={null} { "status": "sent", "message_id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "lead_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "lead_created": true, "convo_id": "8c1a9d2e-3b4f-4a5e-b6c7-d8e9fa0b1c2d", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` Treat ids as opaque strings. `message_id` is the Chert send id to reuse with message-scoped endpoints such as delivery lookup, reactions, and edits. > [!NOTE] > > Route ids are scoped by the resource in the path: `/chats/{id}/messages` uses a `chat_id`; `/messages/{id}` and `/messages/{id}/react` use a `message_id`. In the `/send` response, `lead_id` is the chat id for chat-scoped routes. `convo_id` is kept for compatibility and is not the id to put in `/chats/{id}`. ## 3. Confirm delivery Look up one message by `message_id`: ```bash theme={null} curl "https://console.trychert.com/api/v1/messages/46eb1003c8b54b7ea8f1c2b03e9a7d12" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` You'll see status, timestamps, and the conversation it landed in. ## 4. Stream replies Open the SSE feed in another terminal. New events arrive as `data:` lines as they happen: ```bash theme={null} curl -N https://console.trychert.com/api/v1/events/stream \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` Reply from the recipient device. You'll see a `message.received` event within seconds. For production, register a real webhook URL — see [Receiving replies](/api/receiving-replies). ## Hand off to an AI agent If you're driving Chert from Claude Code, Cursor, or another agent loop: 1. In the console, open the **Documentation** tab inside your project. 2. Click **Copy markdown** at the top of the "Markdown reference" card. The blob already has your live tenant slug, signing secret, project id, and a curl example for listing your phone numbers — pre-substituted. 3. Paste it into your agent. Ask it to build whatever you want — first message, bot loop, attachment sender, group chat. Every endpoint in the blob has been verified end-to-end against this deployment. The same Documentation tab also exposes a **Test-agent prompt** card — a self-contained brief that stands up a working iMessage bot (tapbacks, typing, attachments, contact card) in under 10 minutes. Treat the embedded credentials as sensitive. ## What's next | Goal | Read | | - | - | | Sign requests with HMAC for production | [Authentication](/api/authentication) | | Send multi-part messages, group chats, attachments | [Create a group](/api/group-chats/create) | | Wire a production webhook receiver | [Receiving replies](/api/receiving-replies) | | Look up an error code | [Errors](/api/errors) | ## Programmatic registration (advanced) If you don't have a console account yet — for example, an end-user-driven agent — you can register a tenant over HTTP. See the unauthenticated `POST /api/v1/register` flow in [Authentication](/api/authentication). ## See also * [Authentication](/api/authentication) * [Sending](/api/sending) * [Phone numbers](/api/phone-numbers) # Receiving replies Source: https://docs.trychert.com/api/receiving-replies Webhook payload, signature verification, and retry semantics. When a lead replies from either a phone number or Apple ID email handle, Chert creates or matches the tenant-scoped chat and delivers a real-time `message.received` event to your webhook subscriptions. Events are durable, signed, and replayable. | Event type | When it fires | Latency | Use when | | - | - | - | - | | `message.received` | Real-time on every inbound iMessage | Seconds | Building a live agent or chat UI. Recommended default. | Subscribe by passing `events: ["message.received"]` on `POST /api/v1/webhook-subscriptions`. ![Event lifecycle](https://mintcdn.com/cherttechnologiesinc/sZbsoWFwCb6zxt3B/api/diagrams/event-lifecycle.svg?fit=max\&auto=format\&n=sZbsoWFwCb6zxt3B\&q=85\&s=a36246f2d8622d5453a6e518496c82fb) ## Configuring a webhook Two options: 1. **Console** — your Chert administrator configures a webhook endpoint for your tenant. 2. **API** — create a subscription programmatically with `POST /api/v1/webhook-subscriptions`. See [Webhook subscriptions](/api/webhook-subscriptions). The webhook subscription secret is independent from your API `signing_secret` — rotate either without touching the other. If you cannot expose a public URL during development, use the [SSE stream](/api/events) or [dev inbox](/api/events#dev-inbox) instead. ## `message.received` envelope Real-time, fires immediately when an inbound iMessage lands. ```json message.received theme={null} { "event_id": "chert:msg:7b7f4a1cc9d54809a1e4f1b2", "created_at": "2026-05-24T01:35:34.000Z", "api_version": "v1", "webhook_version": "2026-05-04", "event_type": "message.received", "trace_id": "...", "partner_id": "", "data": { "chat": { "id": "17f6e9ed-...", "is_group": false, "handles": [ { "handle": "+14152165723", "service": "imessage", "is_me": true }, { "handle": "+14155551234", "service": "imessage", "is_me": false } ], "owner_handle": { "handle": "+14152165723", "service": "imessage", "is_me": true } }, "message": { "id": "0D36042D-C551-4CB4-9BE7-853832D6F2FE", "direction": "inbound", "sender_handle": { "handle": "+14155551234", "service": "imessage", "is_me": false }, "parts": [ { "type": "text", "value": "Sure, what time?" }, { "type": "media", "attachment_id": "in_536f2fe3cafef93946d0bf9b4ac2e3ef", "filename": "photo.jpg", "mime_type": "image/jpeg", "size_bytes": 790394 } ], "sent_at": "2026-05-24T01:35:34.000Z", "delivered_at": null, "read_at": null, "service": "imessage" }, "crm_deep_link": "https://console.trychert.com/?lead=17f6e9ed-..." } } ``` ### Field map | You want | Path | | - | - | | Event type | `event_type` (also in the `X-Webhook-Event` header) | | Event identity and idempotency key | `event_id` (also in `X-Webhook-Event-Id`) | | Chat id (use with `/chats/{id}/...` endpoints) | `data.chat.id` | | Tenant slug | `partner_id` | | Sender phone or Apple ID email | `data.message.sender_handle.handle` | | Your line phone | `data.chat.owner_handle.handle` | | Chat handles / participants | `data.chat.handles` | | Inbound text | `data.message.parts.find(p => p.type === "text").value` | | Inbound attachment metadata | `data.message.parts.filter(p => p.type === "media")` | | Inbound attachment bytes | `GET /api/v1/attachments/{attachment_id}/content` | | Inbound message id — valid input to `/messages/{id}/react` | `data.message.id` | | Sender line | `data.chat.owner_handle.handle` | | Group flag | `data.chat.is_group` | > **Compat note.** Some legacy deliveries also include top-level `from`, `to`, `text`, `message_guid`, and `line_id` aliases. New integrations should prefer the `data.*` paths because they are richer and consistent across Chert-managed lines. ### Receiving images and attachments Inbound media is handled by the Chert API layer automatically. When a lead sends an image, recording, or file, `data.message.parts[]` carries an attachment part. A fetchable part includes `attachment_id`; `filename`, `mime_type`, and `size_bytes` are metadata hints and may be absent or generic. Fetch the bytes with the same tenant auth you use for other API calls: ```bash theme={null} ATTACHMENT_ID="in_536f2fe3cafef93946d0bf9b4ac2e3ef" curl "https://console.trychert.com/api/v1/attachments/$ATTACHMENT_ID/content" \ -H "Authorization: Bearer $SECRET" \ -H "x-chert-tenant: $SLUG" \ --output photo.jpg ``` The attachment id is tenant-scoped. If an inbound contains both text and media, keep the array order from `data.message.parts[]` when reconstructing the message. ### Receiving inbound voice memos There is no separate inbound voice-note endpoint. An Apple voice memo arrives through the normal `message.received` workflow and is retrieved through the same authenticated attachment-content route as other inbound media. The following representative event reflects a verified case where the event contained a generic MIME type and no filename: ```json message.received voice memo theme={null} { "api_version": "v1", "webhook_version": "2026-05-04", "event_type": "message.received", "event_id": "EVENT_ID", "created_at": "2026-08-25T14:30:00.000Z", "trace_id": "TRACE_ID", "partner_id": "TENANT_SLUG", "data": { "chat": { "id": "CHAT_ID", "is_group": false, "handles": [ { "handle": "PHONE_LINE_HANDLE", "service": "imessage", "is_me": true }, { "handle": "SENDER_HANDLE", "service": "imessage", "is_me": false } ], "owner_handle": { "handle": "PHONE_LINE_HANDLE", "service": "imessage", "is_me": true } }, "message": { "id": "MESSAGE_ID", "direction": "inbound", "sender_handle": { "handle": "SENDER_HANDLE", "service": "imessage", "is_me": false }, "parts": [ { "type": "media", "attachment_id": "ATTACHMENT_ID", "mime_type": "application/octet-stream" } ], "sent_at": "2026-08-25T14:29:58.000Z", "delivered_at": null, "read_at": null, "service": "imessage" }, "crm_deep_link": null } } ``` > [!NOTE] > > The event may describe an inbound recording as `media` or another > attachment-like part. Chert does not currently expose a universal provenance > flag that proves an audio attachment originated from Apple's native voice > memo UI. Use `attachment_id` to retrieve it, then inspect the response and > bytes. #### Download with cURL Bearer or HMAC authentication is required. For multi-tenant accounts, include `x-chert-tenant`; default single-tenant bearer requests may omit it. ```bash theme={null} : "${CHERT_ATTACHMENT_ID:?Set CHERT_ATTACHMENT_ID}" curl --fail-with-body --silent --show-error \ --remote-header-name --remote-name \ "https://console.trychert.com/api/v1/attachments/$CHERT_ATTACHMENT_ID/content" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "x-chert-tenant: $CHERT_TENANT_SLUG" ``` `--remote-header-name` tells cURL to use the filename from `Content-Disposition`. In the verified case, the response included headers equivalent to: ```http theme={null} Content-Type: application/octet-stream Content-Disposition: attachment; filename="Audio_Message.caf" ``` The generic `Content-Type` did not make the audio invalid. The downloaded bytes were a playable CAF container with Opus audio. #### Download with TypeScript ```ts theme={null} import { writeFile } from "node:fs/promises" import { basename } from "node:path" const secret = process.env.CHERT_SIGNING_SECRET const attachmentId = process.env.CHERT_ATTACHMENT_ID if (!secret || !attachmentId) { throw new Error("CHERT_SIGNING_SECRET and CHERT_ATTACHMENT_ID are required") } const headers: Record = { Authorization: `Bearer ${secret}`, } if (process.env.CHERT_TENANT_SLUG) { headers["x-chert-tenant"] = process.env.CHERT_TENANT_SLUG } const response = await fetch( `https://console.trychert.com/api/v1/attachments/${encodeURIComponent(attachmentId)}/content`, { headers }, ) if (!response.ok) { throw new Error(`Attachment download failed with HTTP ${response.status}`) } const disposition = response.headers.get("content-disposition") ?? "" const match = /filename="?([^";]+)"?/i.exec(disposition) const filename = basename(match?.[1] ?? "inbound-voice-note.bin") const bytes = Buffer.from(await response.arrayBuffer()) await writeFile(filename, bytes) console.log({ filename, contentType: response.headers.get("content-type"), sizeBytes: bytes.byteLength, }) ``` #### Download with Python ```python theme={null} import os import re from pathlib import Path import requests secret = os.environ["CHERT_SIGNING_SECRET"] attachment_id = os.environ["CHERT_ATTACHMENT_ID"] headers = {"Authorization": f"Bearer {secret}"} if os.environ.get("CHERT_TENANT_SLUG"): headers["x-chert-tenant"] = os.environ["CHERT_TENANT_SLUG"] response = requests.get( f"https://console.trychert.com/api/v1/attachments/{attachment_id}/content", headers=headers, timeout=60, ) response.raise_for_status() disposition = response.headers.get("Content-Disposition", "") match = re.search(r'filename="?([^";]+)"?', disposition, re.IGNORECASE) filename = Path(match.group(1) if match else "inbound-voice-note.bin").name Path(filename).write_bytes(response.content) print({ "filename": filename, "content_type": response.headers.get("Content-Type"), "size_bytes": len(response.content), }) ``` #### Inspect and convert the audio Inspect the file signature and stream metadata instead of trusting only the webhook MIME type: ```bash theme={null} file Audio_Message.caf ffprobe -v error \ -show_entries format=format_name,duration,bit_rate:stream=codec_name,sample_rate,channels \ -of default=noprint_wrappers=1 \ Audio_Message.caf ``` One verified sample was Apple CAF/Core Audio Format (`caff`) containing mono Opus audio, approximately four seconds long at about 32 kbps. Apple inspection reported 24 kHz Opus while FFmpeg exposed the decoded stream at 48 kHz. Do not assume every Apple device or OS version produces this exact combination. Convert CAF/Opus to a mono 16 kHz WAV for tools that do not accept the original container or codec: ```bash theme={null} ffmpeg -i Audio_Message.caf -ac 1 -ar 16000 voice-note.wav ``` Chert preserves the downloaded recording; it does not automatically convert inbound audio to MP3 or WAV. No transcript field is currently returned, and Chert does not perform automatic speech-to-text or semantic interpretation. Applications that need a transcript must send the downloaded audio to their chosen speech-to-text system. ### Replying Two working paths. Pick by what you need: ```bash theme={null} # Plain reply — phone-addressed, no chat-id lookup needed curl -X POST https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $SECRET" -H "x-chert-tenant: $SLUG" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155551234", "body": "Yes, works for me" }' # Chat-addressed reply — required for media parts, typing indicators, and contact cards curl -X POST https://console.trychert.com/api/v1/chats/$CHAT_ID/messages \ -H "Authorization: Bearer $SECRET" -H "x-chert-tenant: $SLUG" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [{ "type": "text", "value": "Yes, works for me" }] } }' ``` `$CHAT_ID` is `data.chat.id` from the webhook. `$MESSAGE_GUID` is `message_guid` (or `data.message.id`) and can be used for tapbacks with `/messages/{id}/react`. Use a normal chat follow-up for replies. ## Headers These headers are sent by Chert **to your webhook endpoint** during event delivery. Do not send `x-chert-tenant` in your webhook receiver — that header is only for requests your application makes **to Chert APIs**. ### Recommended webhook delivery headers | Header | Format | Description | | - | - | - | | `X-Webhook-Event` | string | Event type, e.g. `message.received`. | | `X-Webhook-Event-Id` | string | Stable identifier. Use as a dedup key. | | `X-Webhook-Timestamp` | unix seconds | Timestamp used in the signature payload. | | `X-Webhook-Subscription-Id` | string | Id of the subscription that triggered this delivery. | | `X-Webhook-Signature` | `t=,v1=` | HMAC signature computed with the subscription secret. | | `content-type` | `application/json` | Always `application/json`. | ### Legacy compatibility headers Chert also emits these older lowercase headers for existing integrations. New integrations should read the recommended `X-Webhook-*` headers above. | Header | Format | Description | | - | - | - | | `x-chert-event` | string | Same event type as `X-Webhook-Event`. | | `x-chert-event-id` | string | Same dedup key as `X-Webhook-Event-Id`. | | `x-chert-timestamp` | unix seconds | Same timestamp as `X-Webhook-Timestamp`. | | `x-chert-signature` | `v1,,` | Legacy HMAC signature format. | Both signature formats compute the same HMAC: `HMAC-SHA256(subscription_secret, ".")`. ## Verifying the signature Compute the HMAC over the **raw** request body (exact bytes as received) and compare with constant-time equality. Do not parse and re-serialize the JSON before verifying. ```js Node.js theme={null} import { createHmac, timingSafeEqual } from "node:crypto" // Handles both x-chert-signature (v1,,) // and X-Webhook-Signature (t=,v1=). export function verify(rawBody, header, secret) { let ts, hex const legacy = /^v1,(\d+),([a-f0-9]+)$/i.exec(header || "") const modern = /^t=(\d+),v1=([a-f0-9]+)$/i.exec(header || "") if (legacy) { [, ts, hex] = legacy } else if (modern) { [, ts, hex] = modern } else return false if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex") const a = Buffer.from(hex, "hex") const b = Buffer.from(expected, "hex") return a.length === b.length && timingSafeEqual(a, b) } ``` ```python Python theme={null} import hmac, hashlib, re, time def verify(raw_body: bytes, header: str, secret: str) -> bool: legacy = re.match(r"^v1,(\d+),([a-f0-9]+)$", header or "") modern = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header or "") m = legacy or modern if not m: return False ts, hex_ = m.group(1), m.group(2) if abs(time.time() - int(ts)) > 300: return False expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(hex_, expected) ``` ```bash curl theme={null} # Extract ts and hex from x-chert-signature: v1,, EXPECTED=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SUBSCRIPTION_SECRET" -hex | awk '{print $2}') [ "$EXPECTED" = "$HEX_FROM_HEADER" ] && echo "ok" || echo "mismatch" ``` Reject any request that fails verification with `401`. ## Retry behavior | Response from your endpoint | Chert action | | - | - | | `2xx` | Marked delivered. | | `5xx` or network error | Up to 3 total delivery attempts with short exponential backoff. | | `4xx` | Not retried during that delivery. Marked failed if no matching destination succeeds. | Failed events may be retried later by Chert. Pull events through `GET /api/v1/events` and replay manually after fixing your endpoint. ## At-least-once semantics You may receive the same event more than once during retries, replays, or attachment-metadata repairs. Use `event_id` (also exposed in the `X-Webhook-Event-Id` header) to make side effects idempotent, but merge a redelivery when it contains richer attachment or group metadata. ```js theme={null} const completedSideEffects = new Set() function handle(event) { upsertMessage(event.data.message) // Merge newly supplied metadata. if (!completedSideEffects.has(event.event_id)) { runSideEffects(event) completedSideEffects.add(event.event_id) } } ``` ## Replaying missed events If your endpoint was down, list events with `delivery.status = failed` and replay them: ```bash theme={null} curl https://console.trychert.com/api/v1/events?status=failed \ -H "x-chert-tenant: $SLUG" -H "authorization: Bearer $SECRET" curl -X POST https://console.trychert.com/api/v1/events/{id}/replay \ -H "x-chert-tenant: $SLUG" -H "authorization: Bearer $SECRET" ``` To replay to one subscription instead of every matching active subscription, add `?subscription_id=`. See [Events](/api/events) for the full debugging surface. ## See also * [Events](/api/events) * [Webhook subscriptions](/api/webhook-subscriptions) * [Voice notes](/api/voice-notes) * [Attachments](/api/attachments) * [Authentication](/api/authentication) * [Errors](/api/errors) # Sending Source: https://docs.trychert.com/api/sending POST /api/v1/send - shortest path to a 1:1 iMessage. Use `/send` when you need to send a 1:1 text message or native voice note to a phone number or Apple ID email handle. Use [Create a chat](/api/chats/create) when you need a durable `chat.id`, attachments by URL, or chat history. Use [Create a group](/api/group-chats/create) for group chats. ## Request ```bash theme={null} curl https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "phone": "+14155551234", "body": "Hi Sam - got a minute?", "idempotency_key": "send-2026-05-01-001" }' ``` ## Response ```json theme={null} { "status": "sent", "message_id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "lead_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "lead_created": true, "convo_id": "8c1a9d2e-3b4f-4a5e-b6c7-d8e9fa0b1c2d", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` ## Fields | Field | Required | Notes | | - | - | - | | `phone` | Yes | Recipient phone or Apple ID email handle. E.164 is preferred for phones; common US formatting is normalized. Email handles such as `user@icloud.com` are lowercased. The field remains named `phone` for compatibility. | | `body` | Unless `voice_note` is set | Text to send. Blank lines split into separate iMessage bubbles — see [Message text & line breaks](#message-text--line-breaks). | | `idempotency_key` | No | Safe retry key. Reusing the same key and body returns the original response. | | `name` | No | Optional lead display name. | | `external_id` | No | Your CRM or application record id. | | `phone_line_id` | No | Send from a specific assigned sender line. If it differs from the current pin, Chert allows the send and re-pins future sends to that line. | | `attachments` | No | Array of attachment references (see below). | | `effect` | No | Apple send-effect name (see [Effects](#effects)). | | `voice_note` | No | `{ "voice_note_id": "vn_..." }` returned by the [voice-note staging flow](/api/voice-notes). Native voice notes are available only on enabled phone lines. | ## Voice notes Voice notes use a separate two-step workflow: stage genuine CAF or uncompressed WAV through `POST /api/v1/voice-notes`, then send the temporary `voice_note_id` from the exact `phone_line_id` returned by staging. The staged ID expires after 15 minutes and is invalidated by a phone-line service restart. Voice-note sends must not include `body`, `attachments`, `effect`, or `rich_link`; send companion content separately. See [Voice notes](/api/voice-notes) for format requirements, complete cURL/JavaScript/Python examples, idempotency guidance, and troubleshooting. ## Message text & line breaks | Input | Result | | - | - | | Single newline (`\n`) | Stays inside one iMessage bubble. | | Blank line (two consecutive newlines) | Splits the text into separate iMessage bubbles. | | Windows-style `\r\n` or bare `\r` | Always normalized to `\n` before splitting. | | Literal escaped `\n` tokens | Converted to real line breaks only when the text contains **no** real newlines and includes an escaped blank-line pair (`\n\n`) — the signature of double-encoded JSON. Otherwise backslash sequences (file paths, regexes) pass through untouched. | Responses and message history echo the **normalized** text, not the raw bytes you sent. The same rules apply to `body` here and to text `parts[].value` on the chat routes. ## Attachments `/send` accepts three reference shapes interchangeably. Upload first via [`POST /api/v1/attachments`](/api/attachments), then reference the returned `attachment_id`: ```jsonc theme={null} { "phone": "+14155551234", "body": "See attached.", "attachments": [ "att_abc123", // bare id (primary form) { "id": "att_def456" }, // alias { "attachment_id": "att_ghi789" }, // explicit { "attachment_id": "att_live_photo", "live_photo": true } // HEIC + companion MOV pair ] } ``` | Rule | Detail | | - | - | | Mixing `url` + `attachment_id` in one entry | Rejected with `1011`; the response includes `attachment_index` so you know which entry to fix. | | URL refs on `/send` | Not accepted — `/send` rejects `{ "url": "..." }` references with `1011`. Use [`POST /api/v1/chats`](/api/chats/create) for URL-fetch flows. | | `live_photo: true` | Pairs an uploaded HEIC with its companion MOV so the recipient gets a real Live Photo. | | Malformed `attachments` array | Returns `1002 INVALID_FIELD`. | ## Effects Set `"effect": ""` to attach an Apple send-effect (the same fullscreen / bubble effects you get from Messages on iOS). Effects are whitelist-validated upstream — an unknown value returns `4098 UNKNOWN_EFFECT` with the full `allowed_effects` array in the error body, so callers can self-discover the current set. ```json theme={null} { "phone": "+14155551234", "body": "Congrats on the round!", "effect": "celebration" } ``` ## Behavior | Case | What happens | | - | - | | First send to a recipient | Chert creates a lead and pins a sender line. | | Follow-up to the same recipient | Chert reuses the pinned line by default. Passing `phone_line_id` can switch to another assigned sender line. | | Apple ID email recipient | Uses the same healthy assigned or pinned sender line selection as a phone recipient. | | Follow-up through chat routes | Use `chat_id` from this response as `{id}` in `/api/v1/chats/{id}/messages`. `lead_id` is the same id; `convo_id` is lower-level conversation storage. | | Duplicate body within the dedup window | Chert can return `duplicate` without sending again. | | URL in `body` | Chert attempts a rich preview bubble where the sender line supports it. If a rich preview cannot be generated, the URL may be delivered as plain text. | | Delivery failure | `/send` uses a legacy flat error shape — see [Delivery failures](#delivery-failures). Branch on `retryable`. | ## Delivery failures `/send` does **not** use the enveloped `{ success, error, trace_id }` shape documented in [Errors](/api/errors). Failures return a flat object: ```json theme={null} { "status": "failed", "error": "send_failed", "code": 4001, "reason": "recipient is not reachable on iMessage", "retryable": true, "lead_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "convo_id": "8c1a9d2e-3b4f-4a5e-b6c7-d8e9fa0b1c2d" } ``` | Field | Notes | | - | - | | `status` | Always `"failed"` on a failure. | | `error` | Always the string `"send_failed"` — a stable legacy anchor. | | `code` | Numeric Chert error code from the [error code registry](/api/errors). | | `reason` | `4xx` failures only. Human-readable failure detail. | | `message` | `5xx` failures only. A generic description replaces `reason` (and the ids below); the full detail is logged server-side. | | `retryable` | **Authoritative retry signal** — branch on this. | | `lead_id` / `chat_id` / `convo_id` | `4xx` delivery failures include the ids of the lead/chat the send targeted, so you can follow up through chat routes. | `/send` failure responses do **not** carry a `trace_id`. The enveloped `{ success: false, error: {...}, trace_id }` shape belongs to the other `/api/v1` routes. ## Common errors | Code | When | | - | - | | `1002` | Malformed `attachments` array. | | `1011` | Incompatible fields, including an attachment reference that mixes `url` and `attachment_id`, or a `voice_note` combined with body/attachments/effect. | | `4002` | Phone lines saturated for this recipient. | | `4098` | Unknown effect; response carries `allowed_effects`. | | `4099` | Feature not supported in this context. | See the full [error code reference](/api/errors). ## See also * [Usage examples](/api/usage-examples) * [Create a chat](/api/chats/create) * [Create a group](/api/group-chats/create) * [Voice notes](/api/voice-notes) * [Attachments](/api/attachments) * [Errors](/api/errors) ## Pending sends and line-enabled SMS retry On lines enabled for automatic SMS retry, eligible plain-text phone messages may retry once as SMS after a confirmed iMessage failure. Email recipients, groups, media and iMessage-only features are excluded. Keep the same `idempotency_key` when repeating a request. HTTP `202` with `status: "queued"` means the outcome is pending. Poll `GET /api/v1/messages/{message_id}` using the returned message ID. A final result reports the observed `service` (`imessage` or `sms`). `sent` does not imply recipient delivery; `delivered` is true only when delivery was observed. Use polling for pending retry results; do not assume a final webhook will follow. Code `4104` means the original send failed and the SMS retry failed or was unavailable. It is non-retryable. Code `4103` means the outcome is uncertain; do not start a new send with a different key merely because a request timed out. # Usage examples Source: https://docs.trychert.com/api/usage-examples Copy common Messaging API flows. These examples use bearer auth for readability. Use [HMAC signing](/api/authentication) when a request should be signed instead of carrying the secret directly. ## Send a first message ```bash theme={null} curl https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{"phone":"+14155551234","body":"Hi Sam - got a minute?"}' ``` ## Send a native voice note Stage an ordinary uncompressed WAV file, then send the returned `voice_note_id` through the exact `phone_line_id` returned by staging: ```bash theme={null} STAGED=$(curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/voice-notes \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "audio_url": "https://cdn.example.com/audio/intro.wav", "mime_type": "audio/wav", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" }') || exit 1 VOICE_NOTE_ID=$(jq -er '.voice_note_id' <<<"$STAGED") || exit 1 PHONE_LINE_ID=$(jq -er '.phone_line_id' <<<"$STAGED") || exit 1 curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg phone '+14155551234' \ --arg phone_line_id "$PHONE_LINE_ID" \ --arg voice_note_id "$VOICE_NOTE_ID" \ '{ phone: $phone, phone_line_id: $phone_line_id, voice_note: {voice_note_id: $voice_note_id}, idempotency_key: "voice-note-customer-127-001" }')" ``` Voice notes are not `attachments[]`. See [Voice notes](/api/voice-notes) for server-side WAV-to-Opus-CAF conversion, inline base64, JavaScript, Python, expiry, and retry guidance. Callers do not need Apple audio tools. ## Create a chat, then follow up ```bash theme={null} CHAT_ID=$( curl -s https://console.trychert.com/api/v1/chats \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "from": "+15555550100", "to": ["+14155551234"], "message": { "parts": [{ "type": "text", "value": "Hi Sam - got a minute?" }] } }' | jq -r '.chat.id' ) curl -X POST "https://console.trychert.com/api/v1/chats/$CHAT_ID/messages" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [{ "type": "text", "value": "Following up here." }] } }' ``` ## Send a rich link The `rich_link` part shape is the same on every Chert sender line. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/chats/$CHAT_ID/messages" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [ { "type": "text", "value": "Here is the review link." }, { "type": "rich_link", "url": "https://example.com/review" } ] } }' ``` ## Send image, text, and a rich link Use this follow-up endpoint for existing DMs and groups. For a new group, [create the group](/api/group-chats/create) with text first, then send this message after you have the `chat.id`. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/chats/$CHAT_ID/messages" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [ { "type": "media", "attachment_id": "att_review_image" }, { "type": "text", "value": "Hi Marcus, this is Todd from Texas Prime Plumbing. I would really appreciate a Google review." }, { "type": "rich_link", "url": "https://search.google.com/local/writereview?placeid=..." } ] } }' ``` ## Upload and send a file ```bash theme={null} UPLOAD=$( curl -s https://console.trychert.com/api/v1/attachments \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{"filename":"invoice.pdf","content_type":"application/pdf","size_bytes":204800}' ) UPLOAD_URL=$(echo "$UPLOAD" | jq -r '.upload_url') ATTACHMENT_ID=$(echo "$UPLOAD" | jq -r '.attachment_id') curl -X PUT "$UPLOAD_URL" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/pdf" \ --data-binary @invoice.pdf curl -X POST "https://console.trychert.com/api/v1/chats/CHAT_ID/messages" \ -H "Authorization: Bearer YOUR_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "message": { "parts": [ { "type": "text", "value": "See attached." }, { "type": "media", "attachment_id": "att_abc..." } ] } }' ``` ## Send multiple images Upload multiple images, then request grouped delivery from an existing chat. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/chats/$CHAT_ID/messages" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "parallel_attachments": true, "message": { "parts": [ { "type": "media", "attachment_id": "att_photo_1" }, { "type": "media", "attachment_id": "att_photo_2" }, { "type": "media", "attachment_id": "att_photo_3" }, { "type": "media", "attachment_id": "att_photo_4" } ] } }' ``` ## Stream replies while developing ```bash theme={null} curl -N https://console.trychert.com/api/v1/events/stream \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` For production, create a webhook subscription and listen for `message.received`. ```bash theme={null} curl -X POST https://console.trychert.com/api/v1/webhook-subscriptions \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example/webhooks/chert", "events": ["message.received"] }' ``` ## Download an inbound voice memo Read `attachment_id` from the attachment part in `message.received`, then use the authenticated content route. `--remote-header-name` preserves a useful filename from `Content-Disposition` when Chert has one: ```bash theme={null} : "${CHERT_ATTACHMENT_ID:?Set CHERT_ATTACHMENT_ID}" curl --fail-with-body --silent --show-error \ --remote-header-name --remote-name \ "https://console.trychert.com/api/v1/attachments/$CHERT_ATTACHMENT_ID/content" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "x-chert-tenant: $CHERT_TENANT_SLUG" file Audio_Message.caf ffprobe -v error -show_streams -show_format Audio_Message.caf ffmpeg -i Audio_Message.caf -ac 1 -ar 16000 voice-note.wav ``` The event may report `application/octet-stream` or omit a filename even when the downloaded bytes are valid audio. See [Receiving inbound voice memos](/api/receiving-replies#receiving-inbound-voice-memos) for TypeScript, Python, inspection, format variability, and transcription guidance. ## React to a message Use a message id from chat history or a `message.received` webhook. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/messages/$MESSAGE_ID/react" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{"reaction":"like"}' ``` # Voice notes Source: https://docs.trychert.com/api/voice-notes Stage audio and send it as a native iMessage voice note. Chert can deliver audio as a native iMessage voice-note bubble on supported Apple devices, with a waveform, playback controls, duration, and the normal Keep affordance. The workflow uses two API requests: **1. Stage the audio** Call `POST /api/v1/voice-notes` with an ordinary uncompressed WAV file or an already-compatible CAF file. Chert performs the Apple-compatible audio conversion on the selected phone line and returns a temporary `voice_note_id` and the `phone_line_id` that staged it. **2. Send from the same line** Call `POST /api/v1/send` with the returned `voice_note_id` and the exact same `phone_line_id` before the staged audio expires. > [!WARNING] > > Voice notes are not ordinary attachments. Do not upload them through the > generic attachment API or put a `voice_note_id` in `attachments[]`. The > `/voice-notes` plus `/send` workflow requests native voice-message > presentation. > [!NOTE] > > This two-step endpoint flow is for **outbound** native voice notes. Inbound > Apple voice memos arrive through the normal `message.received` attachment > flow—there is no separate inbound voice-note endpoint. See > [Receiving inbound voice memos](/api/receiving-replies#receiving-inbound-voice-memos). ## Prerequisites You need: * A Chert API signing secret. * A phone line assigned to the authenticated project. * Voice-note support enabled on the selected phone line. * A recipient phone number or Apple ID email address. * An ordinary uncompressed WAV file or a compatible Opus-in-CAF file. * One `phone_line_id` used for both staging and sending. List the lines assigned to your project with `GET /api/v1/phone-numbers`: ```bash theme={null} curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/phone-numbers \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` ```json theme={null} { "phone_numbers": [ { "id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d", "phone_number": "+15555550100", "service": "imessage", "is_active": true, "capabilities": ["imessage", "send", "receive"] } ] } ``` Select a line where `is_active` is `true` and `capabilities` includes `send`. The list does not currently expose a separate voice-note capability flag, so use a line Chert has enabled for voice notes. See [Phone numbers](/api/phone-numbers) for the complete response reference. ## Authentication The examples use bearer authentication for readability: ```http theme={null} Authorization: Bearer $CHERT_SIGNING_SECRET Content-Type: application/json ``` For a default single-tenant account, the bearer token identifies the workspace. For a multi-tenant account, also send `x-chert-tenant` with the tenant slug. HMAC signing is recommended when you do not want to transmit the secret as a bearer token. See [Authentication](/api/authentication) for both modes and the five-minute signature window. Never place a signing secret in client-side code, source control, logs, or an example payload. ## Stage a voice note ```http theme={null} POST https://console.trychert.com/api/v1/voice-notes ``` Provide exactly one audio source: `audio_url` or `audio_base64`. ### HTTPS audio URL ```json theme={null} { "audio_url": "https://cdn.example.com/audio/intro.wav", "mime_type": "audio/wav", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` ```bash theme={null} curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/voice-notes \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{ "audio_url": "https://cdn.example.com/audio/intro.wav", "mime_type": "audio/wav", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" }' ``` `audio_url` must use HTTPS and must remain reachable while the staging request runs. ### Inline base64 WAV ```json theme={null} { "audio_base64": "", "mime_type": "audio/wav", "duration_seconds": 2.5, "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` The following example builds the JSON with `jq` so the base64 payload is not printed or interpolated into shell history: ```bash theme={null} AUDIO_BASE64=$(base64 < voice-note.wav | tr -d '\n') jq -n \ --arg audio_base64 "$AUDIO_BASE64" \ --arg phone_line_id "$PHONE_LINE_ID" \ '{ audio_base64: $audio_base64, mime_type: "audio/wav", duration_seconds: 2.5, phone_line_id: $phone_line_id }' | curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/voice-notes \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ --data-binary @- unset AUDIO_BASE64 ``` ### Request fields | Field | Required | Description | | - | - | - | | `audio_url` | Exactly one source | Publicly reachable HTTPS URL for the audio. Do not send with `audio_base64`. | | `audio_base64` | Exactly one source | Base64-encoded audio bytes. Do not include a data-URL prefix or send with `audio_url`. | | `mime_type` | No | Declared source format. Allowed values are `audio/caf`, `audio/wav`, and `audio/x-wav`. Supplying it is strongly recommended. | | `duration_seconds` | No | Informational duration for early validation. Chert probes the audio and the measured value takes precedence. | | `phone_line_id` | No, but strongly recommended | Assigned sender line that will own the staged ID. If omitted, Chert selects an eligible line. Always use the returned `phone_line_id` for the send. | ## Supported formats The production allowlist is: | MIME type | Input requirements | Result | | - | - | - | | `audio/caf` | The bytes must be a genuine, decodable CAF container. | A compatible mono Opus CAF passes through; PCM or another decodable CAF codec is converted to compatible Opus CAF. | | `audio/wav` | Ordinary uncompressed PCM WAV. | Converted server-side to mono Opus in CAF before staging. | | `audio/x-wav` | Ordinary uncompressed PCM WAV. | Converted server-side to mono Opus in CAF before staging. | The final staged asset is always validated as CAF + Opus + mono with a valid duration and decodable packet data. Linux callers do not need Apple tooling, `afconvert`, or the ability to construct CAF files—submit ordinary WAV and let Chert perform the platform-specific conversion. Renaming or relabeling M4A, AAC, MP3, WebM, Ogg, compressed WAV, or another format as `audio/caf` does not turn it into a valid CAF file. Convert an unsupported source to uncompressed WAV before staging it: ```bash theme={null} ffmpeg -i input.m4a -ac 1 -ar 44100 -c:a pcm_s16le voice-note.wav ``` This produces a mono, 44.1 kHz, 16-bit PCM WAV suitable for staging. ## Duration and staging lifetime | Limit | Value | | - | - | | Minimum duration | 0.5 seconds | | Maximum duration | 300 seconds (five minutes) | | Staging lifetime | 15 minutes | Chert probes the actual audio duration. A caller-provided `duration_seconds` value is informational and does not override the measured duration. Staged IDs are temporary and line-local. A phone-line service restart invalidates them even if `expires_at_ms` has not passed. Stage only when your application is ready to send. If an ID expires or the selected line restarts, stage the audio again and use the new `voice_note_id`. ## Staging response ```json theme={null} { "voice_note_id": "vn_a1b2c3d4e5f60708", "uploaded": true, "transcoded": true, "duration_seconds": 2.5, "container": "caf", "expires_at_ms": 1787630000000, "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` | Field | Meaning | | - | - | | `voice_note_id` | Temporary, line-local ID to put in `voice_note.voice_note_id` on `/send`. | | `uploaded` | `true` when Chert staged the audio successfully. | | `transcoded` | `true` for WAV and for incompatible-but-decodable CAF converted to Opus CAF; `false` only when the submitted bytes were already compatible mono Opus CAF. | | `duration_seconds` | Duration measured from the staged audio. | | `container` | Final staged container. Currently always `caf`. | | `expires_at_ms` | Unix time in milliseconds after which the staged ID must not be used. | | `phone_line_id` | The line that owns the staged ID. Preserve this exact value for `/send`. | ## Send the staged voice note ```http theme={null} POST https://console.trychert.com/api/v1/send ``` ```json theme={null} { "phone": "+14155551234", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d", "voice_note": { "voice_note_id": "vn_a1b2c3d4e5f60708" }, "idempotency_key": "voice-note-customer-123-001" } ``` * Copy the exact `phone_line_id` from the staging response. * Send before `expires_at_ms`. * `phone` accepts an E.164 number or an Apple ID email handle; Chert lowercases email handles during normalization. * Use an `idempotency_key` for every intended send. > [!WARNING] > > A voice-note send is exclusive. Do not include `body`, `attachments`, > `effect`, or `rich_link` in the same request. Send companion text in a > separate `/send` request with its own idempotency key. ### Send response ```json theme={null} { "status": "sent", "message_id": "46eb1003c8b54b7ea8f1c2b03e9a7d12", "lead_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "lead_created": true, "convo_id": "8c1a9d2e-3b4f-4a5e-b6c7-d8e9fa0b1c2d", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` | Field | Meaning | | - | - | | `status` | `sent` means Chert accepted and submitted the message through the selected phone line. It does not prove the receiving device displayed it. | | `message_id` | Chert message ID for status lookup and support correlation. | | `lead_id` | Recipient lead ID. | | `chat_id` | Chat ID for subsequent chat-scoped operations. It is the same value as `lead_id` on `/send`. | | `lead_created` | Whether this request created the recipient lead. | | `convo_id` | Lower-level conversation identifier retained for compatibility. Prefer `chat_id`. | | `phone_line_id` | Sender-line metadata for the resolved conversation. For the two-step voice-note workflow, keep using the line from the staging response as the authoritative line-matching value. | ## Complete cURL walkthrough: HTTPS URL This walkthrough selects an assigned active send line, stages the audio, reads both returned IDs, and sends with one stable idempotency key. `curl --fail-with-body` stops each command on an HTTP error. ```bash theme={null} set -o pipefail LINES=$(curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/phone-numbers \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET") || exit 1 PHONE_LINE_ID=$(jq -er '[ .phone_numbers[] | select(.is_active == true and (.capabilities | index("send"))) | .phone_line_id ][0]' <<<"$LINES") || exit 1 STAGED=$(curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/voice-notes \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg audio_url 'https://cdn.example.com/audio/intro.wav' \ --arg phone_line_id "$PHONE_LINE_ID" \ '{audio_url: $audio_url, mime_type: "audio/wav", phone_line_id: $phone_line_id}')") || exit 1 VOICE_NOTE_ID=$(jq -er '.voice_note_id' <<<"$STAGED") || exit 1 STAGED_LINE_ID=$(jq -er '.phone_line_id' <<<"$STAGED") || exit 1 curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg phone '+14155551234' \ --arg phone_line_id "$STAGED_LINE_ID" \ --arg voice_note_id "$VOICE_NOTE_ID" \ --arg idempotency_key 'voice-note-customer-123-001' \ '{ phone: $phone, phone_line_id: $phone_line_id, voice_note: {voice_note_id: $voice_note_id}, idempotency_key: $idempotency_key }')" ``` ## Complete cURL walkthrough: inline base64 ```bash theme={null} set -o pipefail PHONE_LINE_ID=$(curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/phone-numbers \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" | jq -er '[ .phone_numbers[] | select(.is_active == true and (.capabilities | index("send"))) | .phone_line_id ][0]') || exit 1 AUDIO_BASE64=$(base64 < voice-note.wav | tr -d '\n') || exit 1 STAGED=$(jq -n \ --arg audio_base64 "$AUDIO_BASE64" \ --arg phone_line_id "$PHONE_LINE_ID" \ '{ audio_base64: $audio_base64, mime_type: "audio/wav", phone_line_id: $phone_line_id }' | curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/voice-notes \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ --data-binary @-) || exit 1 unset AUDIO_BASE64 VOICE_NOTE_ID=$(jq -er '.voice_note_id' <<<"$STAGED") || exit 1 STAGED_LINE_ID=$(jq -er '.phone_line_id' <<<"$STAGED") || exit 1 curl --fail-with-body --silent --show-error \ https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg phone 'voice-notes@example.com' \ --arg phone_line_id "$STAGED_LINE_ID" \ --arg voice_note_id "$VOICE_NOTE_ID" \ --arg idempotency_key 'voice-note-customer-124-001' \ '{ phone: $phone, phone_line_id: $phone_line_id, voice_note: {voice_note_id: $voice_note_id}, idempotency_key: $idempotency_key }')" ``` ## JavaScript / TypeScript ```ts theme={null} const baseUrl = "https://console.trychert.com" const secret = process.env.CHERT_SIGNING_SECRET if (!secret) throw new Error("CHERT_SIGNING_SECRET is required") const headers = { Authorization: `Bearer ${secret}`, "Content-Type": "application/json", } async function readJson(response: Response) { const value = await response.json() if (!response.ok) { throw new Error(`Chert ${response.status}: ${JSON.stringify(value)}`) } return value } const linesResponse = await fetch(`${baseUrl}/api/v1/phone-numbers`, { headers: { Authorization: `Bearer ${secret}` }, }) const lines = await readJson(linesResponse) const line = lines.phone_numbers.find( (item: { is_active: boolean; capabilities: string[] }) => item.is_active && item.capabilities.includes("send"), ) if (!line) throw new Error("No active assigned send line") const stageResponse = await fetch(`${baseUrl}/api/v1/voice-notes`, { method: "POST", headers, body: JSON.stringify({ audio_url: "https://cdn.example.com/audio/intro.wav", mime_type: "audio/wav", phone_line_id: line.phone_line_id, }), }) const staged = await readJson(stageResponse) const sendResponse = await fetch(`${baseUrl}/api/v1/send`, { method: "POST", headers, body: JSON.stringify({ phone: "+14155551234", phone_line_id: staged.phone_line_id, voice_note: { voice_note_id: staged.voice_note_id }, idempotency_key: "voice-note-customer-125-001", }), }) const sent = await readJson(sendResponse) console.log({ status: sent.status, message_id: sent.message_id }) ``` ## Python ```python theme={null} import base64 import os import requests base_url = "https://console.trychert.com" secret = os.environ["CHERT_SIGNING_SECRET"] headers = { "Authorization": f"Bearer {secret}", "Content-Type": "application/json", } lines_response = requests.get( f"{base_url}/api/v1/phone-numbers", headers={"Authorization": f"Bearer {secret}"}, timeout=30, ) lines_response.raise_for_status() line = next( ( item for item in lines_response.json()["phone_numbers"] if item["is_active"] and "send" in item["capabilities"] ), None, ) if line is None: raise RuntimeError("No active assigned send line") with open("voice-note.wav", "rb") as audio_file: audio_base64 = base64.b64encode(audio_file.read()).decode("ascii") stage_response = requests.post( f"{base_url}/api/v1/voice-notes", headers=headers, json={ "audio_base64": audio_base64, "mime_type": "audio/wav", "phone_line_id": line["phone_line_id"], }, timeout=60, ) stage_response.raise_for_status() staged = stage_response.json() send_response = requests.post( f"{base_url}/api/v1/send", headers=headers, json={ "phone": "+14155551234", "phone_line_id": staged["phone_line_id"], "voice_note": {"voice_note_id": staged["voice_note_id"]}, "idempotency_key": "voice-note-customer-126-001", }, timeout=30, ) send_response.raise_for_status() sent = send_response.json() print({"status": sent["status"], "message_id": sent["message_id"]}) ``` The examples log only the final status and message ID. They do not log the signing secret or full base64 payload. ## Retry and duplicate safety * Use a unique idempotency key for each intended voice note. * Reuse the same key only for the same logical send and identical request. * If a request times out, do not generate a new key merely to retry it. * If the outcome is ambiguous, inspect the message status and your controlled recipient before attempting another send. * A new key represents a new send and can create a duplicate voice note. * If staging expires or the line restarts, stage again. Then make a deliberate send decision while preserving the original key for the same logical send. Reusing a key with an identical request returns the recorded result. Reusing it with a different recipient, body, or `voice_note_id` returns HTTP `422` with the legacy string code `idempotency_collision`. > [!WARNING] > > Do not blindly retry an ambiguous send. `status: "sent"` means Chert submitted > the message, and a network timeout can occur after submission. Check > `GET /api/v1/messages/{message_id}` when you have a message ID and verify the > controlled recipient before creating a new logical send. ## Errors and troubleshooting `POST /api/v1/voice-notes` uses the standard error envelope: ```json theme={null} { "success": false, "error": { "status": 400, "code": 1002, "message": "mime_type must be one of: audio/caf, audio/wav, audio/x-wav", "retryable": false }, "trace_id": "a1b2c3d4e5f6" } ``` `POST /api/v1/send` uses its legacy flat failure shape: ```json theme={null} { "status": "failed", "error": "send_failed", "code": 5001, "reason": "voice_note_id not found or expired", "retryable": false, "lead_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "chat_id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "lead_created": false, "convo_id": "8c1a9d2e-3b4f-4a5e-b6c7-d8e9fa0b1c2d", "phone_line_id": "2a4b6c8d-0e1f-4a3b-8c5d-7e9f1a2b3c4d" } ``` `/send` failures do not include `trace_id`. On `5xx` responses, the route may replace `reason` and recipient IDs with a generic `message`. Always branch on `retryable` when it is present. | Problem | HTTP / code | Action | | - | - | - | | Neither `audio_url` nor `audio_base64` is present | `400` / `1001` | Add exactly one audio source. | | Both source fields are present | Invalid request | Send exactly one source. Do not rely on source precedence. | | `audio_url` is not HTTPS | `400` / `1002`; a line-side rejection can surface as `502` / `3004` | Change the URL to HTTPS and retry staging. | | `audio_base64` is invalid | `502` / `3004` | Encode the raw file bytes as base64 without a data-URL prefix. | | MIME type is outside the allowlist | `400` / `1002` | Convert to PCM WAV and use `audio/wav` or `audio/x-wav`. | | CAF label does not match the bytes | `502` / `3004` | Supply genuine CAF bytes or submit the source as ordinary PCM WAV. | | CAF contains PCM or another incompatible codec | Converted when safely decodable; otherwise `415` upstream (normally surfaced as `502` / `3004`) | Prefer ordinary PCM WAV if the source CAF cannot be decoded. Chert never stages a known-incompatible CAF as a voice note. | | Measured duration is under 0.5 seconds | `502` / `3004` | Record or generate a longer clip. | | Measured duration exceeds 300 seconds | `502` / `3004` | Shorten the clip to five minutes or less. | | Audio is corrupt or uses an unsupported codec | `502` / `3004` | Decode and convert it to PCM WAV before staging. | | No eligible voice-note-enabled line is available | `503` / `3002` | Choose an enabled active line or contact Chert to enable one. | | `phone_line_id` is not assigned to the authenticated project | `503` / `3002` | Use an ID returned by `GET /api/v1/phone-numbers`. | | Staging and sending use different lines | `404` / `5001` from `/send` | Stage again and send with the exact returned `phone_line_id`. | | `voice_note_id` is unknown or expired | `404` / `5001` from `/send` | Stage again and use the new ID. | | The phone-line service restarted after staging | `404` / `5001` from `/send` | Stage again after the line is available. | | Voice note is combined with `body`, `attachments`, or `effect` | `400` / `1011` from `/send` | Send the voice note alone; send companion content separately. | | Voice note is combined with `rich_link` | `501` / `4099` from `/send` | Remove `rich_link` and send it separately through a supported route. | | Voice-note support is not enabled on the selected line | `501` / `4099` from `/send` | Select an enabled line or contact Chert. Do not retry unchanged. | | Recipient is not reachable through iMessage | Usually `502` / `4001` from `/send` | Verify the phone or Apple ID handle and inspect `retryable`. | | Send times out with an ambiguous result | `502` / `4001` or `503` / `3002` | Keep the same idempotency key, inspect status and the controlled recipient, then decide whether to retry. | | Same idempotency key and same logical request | Original response, normally `200` | Treat it as a replay, not a second send. | | Same idempotency key with a different request | `422` / `idempotency_collision` | Use the old key only for the original logical send; use a new key only for a genuinely new send. | See [Errors](/api/errors) for the complete numeric registry and retry guidance. ## Receive an inbound voice memo Inbound and outbound voice notes use different API shapes: | Direction | Workflow | | - | - | | Outbound | Stage with `POST /api/v1/voice-notes`, then send with `voice_note` through `POST /api/v1/send`. | | Inbound | Receive a `message.received` event with an attachment part, then download its bytes with `GET /api/v1/attachments/{attachment_id}/content`. | There is no separate endpoint for receiving voice notes. In a verified production event, an Apple voice memo appeared through the ordinary attachment/media flow with an `attachment_id`, a generic `application/octet-stream` MIME type, and no filename in the event metadata. The authenticated content response supplied the useful `.caf` filename. > [!TIP] > > A generic webhook MIME type does not mean the recording is invalid. Inspect > the content response headers and downloaded bytes instead of relying only on > the event's MIME type or filename. ### Verified observed format One controlled Apple voice memo had the following characteristics: | Property | Observed value | | - | - | | Download filename | `Audio_Message.caf` | | Container | Apple CAF/Core Audio Format (`caff`) | | Codec | Opus | | Channels | Mono | | Duration | Approximately four seconds | | Bitrate | Approximately 32 kbps | | Sample rate reporting | Apple inspection reported 24 kHz; FFmpeg exposed the decoded Opus stream at 48 kHz. | This is a verified example, not a universal format guarantee. Apple devices and OS versions may produce different metadata or audio encodings. Inspect the download response, file signature, and media metadata before choosing a decoder. Chert preserves and exposes the inbound bytes. It does not automatically convert recordings to MP3 or WAV, and it does not currently return a transcript or perform speech-to-text. Applications that need transcription must download the attachment and submit it to their chosen speech-to-text system. See [Receiving replies](/api/receiving-replies#receiving-inbound-voice-memos) for a representative webhook and complete cURL, TypeScript, Python, `file`, `ffprobe`, and FFmpeg examples. ## Best practices * Prefer ordinary uncompressed PCM WAV; Chert converts it to the Apple-compatible voice-note asset. * Keep audio between 0.5 seconds and five minutes. * Stage immediately before sending. * Preserve the staging response's `phone_line_id`. * Use one stable idempotency key per intended send. * Keep secrets in environment variables. * Avoid logging raw base64. * Send companion text or links separately. * Do not blindly retry ambiguous sends. * Test native rendering on a controlled Apple recipient before a large rollout. ## Relationship to attachments Generic audio attachments and native voice notes are separate workflows: | Goal | Workflow | Recipient presentation | | - | - | - | | Send an audio file | Upload with `/api/v1/attachments`, then send a `media` part or `attachments[]` reference. | May appear as an ordinary downloadable file or media attachment. | | Send a native voice note | Stage with `/api/v1/voice-notes`, then send with `voice_note`. | Requests native voice-message presentation with waveform and playback controls. | Inbound Apple voice memos are a third case: they arrive as downloadable attachment parts in `message.received`. The inbound event does not reliably distinguish a native Apple voice memo from every other audio attachment, so do not infer provenance from `type`, MIME type, or filename alone. Do not put a voice-note ID in `attachments[]`. See [Attachments](/api/attachments) for the generic file workflow. ## See also * [Authentication](/api/authentication) * [Phone numbers](/api/phone-numbers) * [Sending](/api/sending) * [Attachments](/api/attachments) * [Receiving replies](/api/receiving-replies) * [Errors](/api/errors) * [Usage examples](/api/usage-examples) # Webhook subscriptions Source: https://docs.trychert.com/api/webhook-subscriptions Manage webhook endpoints programmatically. Webhook subscriptions let you register, update, and delete webhook endpoints via the API. Each subscription has its own secret and can filter to a specific set of event types. You can keep multiple active subscriptions for the same tenant. ## Endpoints | Method | Path | Description | | - | - | - | | `POST` | `/api/v1/webhook-subscriptions` | Create a subscription. | | `GET` | `/api/v1/webhook-subscriptions` | List all subscriptions. | | `GET` | `/api/v1/webhook-subscriptions/{id}` | Get a single subscription. | | `PATCH` | `/api/v1/webhook-subscriptions/{id}` | Update a subscription. | | `DELETE` | `/api/v1/webhook-subscriptions/{id}` | Delete a subscription. | *** ## POST /api/v1/webhook-subscriptions > [!NOTE] > > **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** No **What this does.** Validates that `url` is an HTTPS URL, generates a 32-byte random `secret`, and creates a webhook subscription. Returns the full subscription including `secret` (shown once — store immediately). Subsequent GET responses never include the secret. Multiple subscriptions per tenant are supported; each has its own secret and event filter. Create a new webhook subscription. The `secret` is shown **once** in the response — save it immediately. ```bash curl theme={null} curl -X POST https://console.trychert.com/api/v1/webhook-subscriptions \ -H "x-chert-tenant: $SLUG" \ -H "x-chert-signature: v1,$TS,$SIG" \ -H "content-type: application/json" \ -d '{ "url": "https://your-app.example/webhooks/chert", "events": ["message.received"] }' ``` ```json 201 Created theme={null} { "subscription": { "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "url": "https://your-app.example/webhooks/chert", "events": ["message.received"], "version": "2026-04-01", "is_active": true, "created_at": "2026-05-01T12:00:00Z", "secret": "1a2b3c4d5e6f7081a2b3c4d5e6f70819aabbccddeeff00112233445566778899" } } ``` ### Request body **url** `string` *required* HTTPS URL that Chert will POST events to. Must be publicly reachable. For local development, use `https://dev-inbox` to record events without sending outbound HTTP. **events** `string[]` Event types to subscribe to. Omit this field, or send an empty array, to receive all events. Use `message.received` for real-time inbound replies. Legacy `lead_reply` is still available for summary-style reply notifications; reaction events may appear as `reaction.added` and `reaction.removed`. **version** `string` Subscription payload version label. Defaults to the current stable value (`2026-04-01`). ### Response fields **id** `string` Stable subscription id. Also emitted in the `X-Webhook-Subscription-Id` header on every delivery. **secret** `string` Per-subscription HMAC secret. **Shown once.** Store it securely and use it to verify the `X-Webhook-Signature` (or `x-chert-signature`) header on inbound deliveries. See [Verifying signatures](/api/receiving-replies#verifying-the-signature). **is\_active** `boolean` Whether this subscription is currently enabled for delivery. Update via `PATCH`. *** ## GET /api/v1/webhook-subscriptions > [!NOTE] > > **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** Yes (read-only) **What this does.** Returns all webhook subscriptions for your tenant, ordered newest first. `secret` is never included. List all subscriptions for your tenant, ordered newest first. `secret` is **not** included in list or get responses. ```json 200 OK theme={null} { "subscriptions": [ { "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f", "url": "https://your-app.example/webhooks/chert", "events": ["message.received"], "version": "2026-04-01", "is_active": true, "consecutive_failures": 0, "last_success_at": "2026-05-01T13:42:11Z", "last_failure_at": null, "created_at": "2026-05-01T12:00:00Z", "updated_at": "2026-05-01T13:42:11Z" } ] } ``` `updated_at` reflects the last metadata change (URL, events, is\_active, etc). *** ## GET /api/v1/webhook-subscriptions/ > [!NOTE] > > **Status:** Live · **Auth:** Bearer or HMAC · **Idempotent:** Yes (read-only) **What this does.** Row lookup by `id` scoped to your tenant. Same shape as a list row. Return a single subscription. Same shape as a list row. *** ## PATCH /api/v1/webhook-subscriptions/ Update one or more fields. Only the fields you include are modified. ```json Request theme={null} { "url": "https://your-app.example/webhooks/chert-v2", "events": ["message.received"], "is_active": true } ``` Patch `"is_active": true` to enable a subscription. Fields accepted by `PATCH`: | Field | Notes | | - | - | | `url` | Replaces the destination URL. Must be HTTPS. | | `events` | Replaces the event allowlist. Use `[]` for all events. | | `version` | Replaces the subscription version label. | | `is_active` | Enables or disables delivery. Setting `true` also clears the failure counter. | *** ## DELETE /api/v1/webhook-subscriptions/ Permanently delete a subscription. Deliveries in-flight may still complete. Returns `200 OK` with the standard envelope: ```json 200 OK theme={null} { "deleted": true, "id": "0f2d3c1a-8b4e-4f6a-90d2-1a3b4c5d6e7f" } ``` *** ## Signature verification Each subscription generates its **own** secret, returned **once** in the `POST` response. Use that subscription `secret` to verify deliveries — **not** your tenant `signing_secret`. The two are different values. Every delivery carries both signature headers — verify whichever your stack reads more easily. Both compute the same HMAC: ``` HMAC-SHA256(subscription_secret, ".") ``` The separator between timestamp and body is a **period (`.`)**, not a colon. The body is the exact raw bytes Chert sent — do not parse-and-re-serialize before verifying (JSON middleware that mutates whitespace will silently invalidate the signature). ```js Node.js (Express) theme={null} import { createHmac, timingSafeEqual } from "node:crypto" // IMPORTANT: capture the raw body BEFORE any JSON middleware parses it. // app.use("/webhooks/chert", express.raw({ type: "application/json" })) export function verifyChertWebhook(rawBody, headers, subscriptionSecret) { // Prefer the modern header; fall back to the legacy one. const header = headers["x-webhook-signature"] || headers["x-chert-signature"] || "" const modern = /^t=(\d+),v1=([a-f0-9]+)$/i.exec(header) const legacy = /^v1,(\d+),([a-f0-9]+)$/i.exec(header) const m = modern || legacy if (!m) return false const ts = Number(m[1]) const provided = m[2].toLowerCase() // 5-minute replay window. if (Math.abs(Date.now() / 1000 - ts) > 300) return false const expected = createHmac("sha256", subscriptionSecret) .update(`${ts}.${rawBody}`) // <-- period, not colon .digest("hex") const a = Buffer.from(provided, "hex") const b = Buffer.from(expected, "hex") return a.length === b.length && timingSafeEqual(a, b) } ``` ```python Python (Flask) theme={null} import hmac, hashlib, re, time # IMPORTANT: read request.get_data() — NOT request.get_json() — # so you verify against the exact bytes Chert sent. def verify_chert_webhook(raw_body: bytes, headers, subscription_secret: str) -> bool: header = headers.get("X-Webhook-Signature") or headers.get("x-chert-signature") or "" m = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header) \ or re.match(r"^v1,(\d+),([a-f0-9]+)$", header) if not m: return False ts, provided = int(m.group(1)), m.group(2).lower() if abs(time.time() - ts) > 300: return False expected = hmac.new( subscription_secret.encode(), f"{ts}.".encode() + raw_body, # <-- period, then raw bytes hashlib.sha256, ).hexdigest() return hmac.compare_digest(provided, expected) ``` ```bash curl (debug only) theme={null} # Sanity-check a single delivery from your logs. TS=1715000000 # from X-Webhook-Timestamp BODY='{"event":"message.received",...}' # the exact bytes received HEX_FROM_HEADER=abcdef... # the v1=... portion EXPECTED=$(printf '%s.%s' "$TS" "$BODY" \ | openssl dgst -sha256 -hmac "$SUBSCRIPTION_SECRET" -hex \ | awk '{print $2}') [ "$EXPECTED" = "$HEX_FROM_HEADER" ] && echo "ok" || echo "mismatch" ``` The `X-Webhook-Subscription-Id` header identifies which subscription triggered the delivery, useful when multiple subscriptions point to the same endpoint with different secrets. This is a webhook delivery header from Chert to your endpoint; it is separate from `x-chert-tenant`, which your application sends when calling Chert APIs. ## Delivery and replay Chert records every event before outbound delivery. If multiple active subscriptions match an event, Chert attempts each destination and records one delivery attempt per subscription on the event row. | Case | Behavior | | - | - | | Matching subscription succeeds with `2xx` | Event delivery is marked `delivered`. | | Matching subscription returns `4xx` | Not retried for that delivery. The event is marked failed if no matching subscription succeeded. | | Matching subscription returns `5xx` or cannot be reached | Chert makes up to 3 total delivery attempts with short exponential backoff before recording the final failure. | | Subscription URL is `https://dev-inbox` | No outbound POST is attempted; the event is stored for `GET /api/v1/dev-inbox`. | Use `GET /api/v1/events` to inspect delivery status and `POST /api/v1/events/{id}/replay` to re-deliver a stored event. To replay to one subscription, pass `?subscription_id=`. ```bash theme={null} curl -X POST "https://console.trychert.com/api/v1/events/$EVENT_ROW_ID/replay?subscription_id=$SUBSCRIPTION_ID" \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" ``` ### Common pitfalls | Symptom | Cause | Fix | | - | - | - | | Signature always mismatches | Verifying against your tenant `signing_secret` | Use the per-subscription `secret` returned by `POST /webhook-subscriptions` | | Signature mismatches intermittently | JSON middleware re-serialized the body before your verifier saw it | Capture the raw body bytes before any parser runs | | Signature mismatches after a clock change | Server clock skewed > 5 min from Chert | Sync via NTP; we reject anything outside ±300 s | | Header value parses as undefined | Reading `X-Webhook-Signature` case-sensitively in lowercase-normalising frameworks | Read `x-webhook-signature` (lowercase) — most Node/Python frameworks normalise this way | *** ## See also * [Receiving replies](/api/receiving-replies) * [Authentication](/api/authentication) * [Events](/api/events) * [Errors](/api/errors) # Advanced connector commands Source: https://docs.trychert.com/facetime-cli/advanced-commands Common CLI commands and what their results mean. For custom callback or hook integrations only. For ordinary named LiveKit agents, use [the developer commands](/facetime-cli/commands). These recovery commands do not apply directly to a `chert dev` session. Run the installed CLI with `npx --no-install chert`. Always pass an explicit `--config` path to commands that use a configuration. Add `--json` for structured diagnostics. ## Manual connector commands | Command | Purpose | | - | - | | `chert --help` / `chert --version` | Show usage or the installed version. | | `chert init --mode byor` | Create a BYOR config and TypeScript callback scaffold. | | `chert init --mode create-room` | Configure the optional LiveKit room/dispatch adapter. | | `chert check` | Check configuration, health, storage, and outstanding recovery without allocating sessions. | | `chert byor` | Run the receiver wrapper with your application callbacks. | | `chert create-room` | Run the connector that creates rooms on incoming calls. | | `chert status` | Read saved local state; does not check live agent readiness. | | `chert projects` / `chert lines` / `chert bindings` | Inspect the projects, lines, and connector enrollments available to you. | | `chert enroll` | Create a standby binding and save connection credentials. | | `chert activate` | Explicitly route new calls to the enrolled connector. | | `chert drain` | Stop new assignments and restore the saved webhook route. | | `chert revoke` | Permanently revoke normal access; retain scoped recovery for previously owned calls. | These are command summaries, not complete invocations. See the [BYOR](/facetime-cli/byor), [create-room](/facetime-cli/create-room), and [enrollment](/facetime-cli/manual-enrollment) guides for required flags. `chert run` is only for legacy v1 configs; new configs use their explicit mode command. ## Check and inspect ```bash theme={null} npx --no-install chert check --config "$PWD/chert.enrolled.json" --json npx --no-install chert status --config "$PWD/chert.enrolled.json" --json ``` `check` currently exits **3** because its diagnostics are partial. Read individual results. `status` is a saved observation; a connected transport or healthy process is not proof of working call audio. ## Recover unfinished work After stopping the original writer, use its original config, state directory, and compatible application code: ```bash theme={null} npx --no-install chert byor --config "$PWD/chert.enrolled.json" --recovery --json ``` For create-room mode, substitute `create-room`. Recovery must settle the original operation and verify cleanup. There is no clear-debt command: unresolved cleanup blocks new admission, and deleting state loses the evidence needed to recover safely. ## Understand failures | Diagnostic | Meaning / next step | | - | - | | `mode_mismatch` | Use the runtime command matching the config's mode. | | `handler_invalid` | Check the BYOR module path, compiled output, and exported handler. | | `credentials_invalid` | Check the credential source and binding authorization. | | `deadline_exceeded` | Preparation missed its original deadline; inspect agent/session startup time. | | `acceptance_uncertain` | The decision outcome is unknown; reconcile instead of creating another session. | | `application_lifecycle_pending` | Application processing has not durably acknowledged a call event. | | `cleanup_pending` | Verify cleanup through the original application's recovery logic. | | Exit code | Meaning | | - | - | | `0` | Success | | `1` | Internal failure | | `2` | Configuration or usage error | | `3` | Partial diagnostics or outstanding recovery | | `4` | State directory or writer error | | `5` | Unsupported runtime or platform | | `6` | Transport or backend failure | # Build your first agent Source: https://docs.trychert.com/facetime-cli/build-agent Create a JavaScript LiveKit voice agent that you can call through Chert. This example is a voice agent. The CLI also supports FaceTime Video; use the [video starter and caller-vision guide](/facetime-cli/video) for that workflow. You are building a **LiveKit agent server**: a program that joins a room and runs a voice conversation. Chert connects that room to FaceTime. This example uses JavaScript and OpenAI Realtime; change its instructions to build a tutor, interview coach, or another voice experience. Use macOS or Linux with **Node 22.23.2 or 24.x** and npm. Check `node --version` and `npm --version`. Install Node from [nodejs.org](https://nodejs.org/en/download) or follow your organizer's setup instructions. If you already use nvm: ```bash theme={null} nvm install 22.23.2 nvm use 22.23.2 ``` ## 1. Create your accounts and choose a name 1. Create a [LiveKit Cloud account](https://cloud.livekit.io) and a project. Obtain its URL, API key and API secret from the project's settings. 2. Obtain a model API key with access and credits for OpenAI Realtime. This is separate from your LiveKit and Chert credentials; a ChatGPT subscription is not an API key. See the [LiveKit Realtime integration](https://docs.livekit.io/agents/models/realtime/plugins/openai/). 3. Choose a dispatch name, such as **`team-otter-agent`**. Use that exact name in the env file and in `chert dev --agent team-otter-agent`. 4. Get your Chert project access, UUID and assigned FaceTime address from your organizer. See [Projects and lines](/facetime-cli/connect). Your LiveKit project could be called `Otter Hackathon`; your agent can still be `team-otter-agent`. These labels do not need to match. Give other agents in the same LiveKit project different dispatch names unless you intentionally run interchangeable copies of the same agent. ## 2. Create the local project Use a new directory: ```bash theme={null} mkdir my-facetime-agent cd my-facetime-agent ``` Create the following files in your editor. This page includes all required source; you do not need access to a private GitHub repository. ### package.json ```json theme={null} { "name": "chert-student-agent", "version": "1.0.0", "private": true, "type": "module", "engines": { "node": "^22.23.2 || ^24.0.0" }, "scripts": { "start": "node --env-file=.env.local agent.mjs start", "check": "node --check agent.mjs", "help": "node agent.mjs --help" }, "dependencies": { "@livekit/agents": "1.8.0", "@livekit/agents-plugin-openai": "1.8.0" } } ``` ### .gitignore Create this before adding credentials or committing your project: ```gitignore theme={null} node_modules/ .env .env.* !.env.example *.log .DS_Store ``` ### .env.local Paste these placeholders into the file, then replace them privately in your editor: ```dotenv theme={null} LIVEKIT_URL=wss://YOUR_PROJECT.livekit.cloud LIVEKIT_API_KEY=YOUR_LIVEKIT_API_KEY LIVEKIT_API_SECRET=YOUR_LIVEKIT_API_SECRET OPENAI_API_KEY=YOUR_OPENAI_API_KEY AGENT_NAME=team-otter-agent ``` All three `LIVEKIT_*` values must come from the same project. Both your agent and Chert will use that project. Keep the file private: ```bash theme={null} chmod 600 .env.local ``` Never commit the file, paste its values into chat, or execute it with `source`. The startup command uses Node's env-file parser. Chert reads its LiveKit values; your agent uses the model key. Existing shell variables override env-file values, so avoid stale credentials in your terminal environment. ### agent.mjs ```javascript theme={null} import { fileURLToPath } from 'node:url'; import { AutoSubscribe, cli, defineAgent, ServerOptions, voice } from '@livekit/agents'; import { realtime } from '@livekit/agents-plugin-openai'; // Change these instructions to build your own agent. Keep replies conversational. const instructions = `You are a friendly AI study partner speaking over FaceTime. Help the caller understand a topic by asking one question at a time. Keep each reply to one or two short sentences. Be clear that you are an AI. Wait for the caller to speak first.`; export default defineAgent({ entry: async ctx => { await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY); // Chert prepares a room before a caller exists. Join promptly so Chert can // find us, but wait before opening a paid model session. Doctor jobs may end // without a caller ever joining. const caller = await ctx.waitForParticipant(); const session = new voice.AgentSession({ llm: new realtime.RealtimeModel({ model: 'gpt-realtime', voice: 'marin' }), }); ctx.addShutdownCallback(() => session.close()); await session.start({ room: ctx.room, agent: new voice.Agent({ instructions }), record: false, inputOptions: { participantIdentity: caller.identity, audioEnabled: true, videoEnabled: false, textEnabled: false, closeOnDisconnect: true, deleteRoomOnClose: false, }, outputOptions: { audioEnabled: true, transcriptionEnabled: false }, }); // Participant arrival is not FaceTime media readiness. No generateReply() // here: the caller says hello after connection, then the model responds. }, }); cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url), agentName: process.env.AGENT_NAME || 'student-agent', })); ``` Change the `instructions` text to define your agent's personality and purpose. Keep replies short enough for a spoken conversation. ## 3. Install and start ```bash theme={null} npm install npm run check npm start ``` This creates `package-lock.json`; keep that file with your project and use `npm ci` for subsequent fresh installs. Wait for successful LiveKit registration and leave this terminal running. This starter does not automatically reload edits. **`npm start` runs your agent and registers the dispatch name with LiveKit.** The starter reads `AGENT_NAME` and explicitly passes it as `ServerOptions.agentName`. There is no separate manual registration command for this local flow. ## 4. Connect it to FaceTime Open a second terminal, select the same Node version, and follow [Connect and test](/facetime-cli/quickstart) to install Chert, sign in, and check your connection. The CLI reads the LiveKit keys from this folder automatically; separate setup is optional. Your everyday Chert command will be: ```bash theme={null} chert dev --agent team-otter-agent ``` The agent program and Chert must both remain running. After the waiting-room message, call your assigned line using **FaceTime Audio** and say hello first. ## Why the starter waits Chert may dispatch a job before a caller exists, for prewarming or diagnostics. The agent joins promptly, waits for a participant, and then opens the model session. It does not automatically greet when a job starts: participant arrival alone does not prove FaceTime audio is ready. Letting the caller speak first avoids losing the greeting during connection. The starter listens to the caller's audio, leaves room deletion to Chert, and closes the model session on shutdown. Generic rooms allow two participants; adding an avatar or sidecar participant needs a separate integration. ## Already have a TypeScript agent? Keep your existing build/start commands. Set LiveKit's `ServerOptions.agentName` to your chosen dispatch name, use the same LiveKit project credentials as Chert, and allow jobs to connect promptly even when a caller has not arrived yet. Avoid an unconditional greeting on job startup. Then follow the same CLI steps. The [official LiveKit Node starter](https://github.com/livekit-examples/agent-starter-node) provides a TypeScript project structure if you prefer one. > [!NOTE] > > This is an audio starter. Installation, syntax and SDK compatibility checks do > not establish working FaceTime media. Complete the two-call test on your assigned > line before presenting it or building additional features. # Custom BYOR callbacks Source: https://docs.trychert.com/facetime-cli/byor Use your existing agent and session logic without building a receiver. **Advanced: use this when your application owns session creation and cleanup.** For a named LiveKit agent, start with [chert dev](/facetime-cli/quickstart). The CLI receives call requests from Chert and invokes your application callbacks. Your application returns a ready LiveKit session; the wrapper does not create rooms or dispatch agents. **Both inbound FaceTime Audio and FaceTime Video are supported without a webhook.** Chert passes the requested media to `prepare` as `call.media`. Use a line enabled for the video directions you need and configure your agent to publish or consume those tracks; see [Audio, video, and caller vision](/facetime-cli/video). ## Before you start Arrange connector access with Chert. Use Node **22.12+ (22.x), 24.x or 26.x**, macOS or Linux, and a durable local state directory. Start your agent using its normal command. **1. Install and initialize** From your application's directory, install the published package: ```bash theme={null} npm install --save-exact @trychert/facetime@0.1.0-beta.10 npx --no-install chert init --non-interactive --mode byor \ --config "$PWD/chert.json" --state-dir "$PWD/.chert/state" ``` This creates `chert.json` and `chert-handler.ts`. It refuses to overwrite existing files. Keep the config and state out of version control. **2. Connect the generated callbacks** Edit `chert-handler.ts` to call your application's existing session logic. No HTTP server is needed. | Callback | Your application does | | - | - | | `health()` | Reports `available`, `unavailable`, or `unknown` without creating a session. | | `prepare(call, context)` | Persists `call.operation_id`, then returns a ready session or declines before the deadline. | | `lifecycle(call, event, context)` | Processes each `event.event_id` idempotently; acknowledges after durable processing. | | `recover(call, context)` | Looks up the original operation, handles cancellation when required, and reports verified settlement and cleanup. | `call.media.audio` is `true`; `call.media.video` is `true` for FaceTime Video and `false` for FaceTime Audio. Preserve that choice in your session preparation. Return `ready: true` only after the application is ready for the requested media, or decline if it cannot support the call. The CLI does not add camera capture or vision processing to your agent. A ready-session response has this shape (values come from your application): ```typescript theme={null} return { action: "accept", ready: true, session_ref: session.reference, session_expires_at: session.expiresAtMs, livekit_url: session.livekitUrl, participant_token: session.bridgeToken, remote_participant_identity: session.agentIdentity, }; ``` Use a non-secret session reference and a short-lived, call-scoped token. Expiry is an absolute Unix timestamp in milliseconds and must fit the token lifetime. Return `{ action: "decline" }` when you cannot prepare the call. > [!NOTE] > > The generated handler deliberately declines until you implement it. A returned `accept` must mean the session is actually ready. Preparation must finish within the supplied deadline and `context.remaining_ms`; the CLI does not make slow agent cold starts faster. Honor `context.signal` and `context.remaining_ms`. Recovery must inspect the original operation, never repeat preparation. Report `settled: true` only when no further creation can complete, and `cleanup: "clean"` only after verifying resources are cleaned up or absent. `recover()` also runs during routine reconciliation of active or uncertain calls. A reconciliation read failure or unknown acceptance is not permission to delete an active room. Preserve resources until authoritative termination or applicable cancellation permits cleanup (including cancellation for expiry). Keeping an active session is not `cleanup: "clean"`; report settlement and cleanup separately and retain retries for failed deletion or late preparation. The installed package includes a complete session-store example in `node_modules/@trychert/facetime/examples/byor/`. Adapt it to your application's durable storage and cleanup logic. **3. Compile and check** Export the handler as an **ES module** and point the config at its compiled JavaScript. Use your application's existing ESM build. For a new standalone project, set `"type": "module"` in `package.json` and compile the generated file: ```bash theme={null} npm pkg set type=module npm install --save-dev --save-exact typescript@6.0.2 @types/node@24.13.6 npx tsc --module NodeNext --target ES2023 --skipLibCheck ./chert-handler.ts npx --no-install chert check --config "$PWD/chert.json" --json ``` `check` is non-allocating and currently exits **3** for partial diagnostics. Read the reported checks; this alone is not a failed installation or proof of a working FaceTime call. **4. Enroll, activate, and run** Follow [Manual connector enrollment](/facetime-cli/manual-enrollment) to create an enrolled config and activate your chosen line. Run the connector with that config: ```bash theme={null} npx --no-install chert byor --config "$PWD/chert.enrolled.json" --json ``` Keep your agent and connector running. Test a real inbound call on the intended line: confirm two-way audio and, for video, each required video direction. A caller-vision test must verify fresh frames reach your model or tool. End the call and verify cleanup before testing again. Missing or failed health is shown as unknown, not ready. Known-unavailable health declines calls; unknown health still allows bounded per-call preparation, which must explicitly establish readiness. Unresolved cleanup blocks new calls at the preview's capacity of one. # CLI commands Source: https://docs.trychert.com/facetime-cli/commands Sign in, load LiveKit credentials, diagnose your connection, and put a named agent on your line. Common developer commands for **0.1.0-beta.10**. Use command-specific help for the full reference. Start with [Connect and test](/facetime-cli/quickstart) for the complete sequence. | Command | Purpose | | - | - | | `chert login` | Authorize this computer through your browser. | | `chert new my-agent` / `chert new my-agent --video` | Create a voice or video starter; fill in its credentials and start it separately. | | `chert guide video` | Read the built-in video and avatar guide. | | `chert setup` | Optionally save a Chert project UUID and env-file path for use from anywhere. | | `chert doctor` | Check sign-in, line and LiveKit; add `--agent NAME` to test dispatch. | | `chert dev --agent NAME` | Connect your running named agent to the line until Ctrl+C. | | `chert whoami` | Confirm your account and project. | | `chert logout` | Revoke this computer's sign-in. Stop the connector first. | Use `chert --help` for short help, `chert dev --help` for one command, `chert --help --all` for advanced commands, and `chert --version` for your version. Developer commands support `--json` for machine-readable output. ## Credential loading Run `dev` and `doctor` from your agent folder. They automatically read `LIVEKIT_URL`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET` from `.env.local` or `.env` and report which file they used. The Chert project comes from browser sign-in unless you explicitly select one or have saved setup. LiveKit values are selected in this order: 1. Existing shell environment values take priority. 2. `--env-file PATH` selects an explicit file, if supplied. 3. Otherwise, an env-file path saved by `chert setup` is used. 4. Otherwise, the CLI checks `.env.local`, then `.env`, in the current folder. Folder discovery stops at the first readable file that fills a missing LiveKit value. It does not combine both files, search parent folders, or override shell values. Explicit or saved file errors are reported rather than silently falling back to another file. The CLI also reads `AGENT_NAME` for agent-name discovery. Chert sign-in, endpoint overrides and model keys are not imported. Files are parsed as data, never executed as shell code. `--project UUID` overrides a saved project; otherwise the signed-in project is used when no project is saved. This does not grant access to another project. If old saved settings point to another project or folder, stop the connector and run `chert setup --clear` to use folder loading and your signed-in project. ## chert login Sign this computer in to your Chert project. Opens your browser to approve this computer for one project. It then stays signed in for 30 days. In a terminal you choose with 1, 2, 3 or the arrow keys. ``` chert login chert login --device chert login --with-token < token.txt ``` | Flag | What it does | | - | - | | `--device` | Print the address and code instead of opening a browser, for a remote shell | | `--with-token` | Read a dashboard session token from standard input. It lasts about an hour | | `--backend ORIGIN` | Sign in to another Chert address, [https://HOST](https://HOST) | | Environment variable | What it does | | - | - | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | | `CHERT_CONTROL_ORIGIN` | Another Chert address, the same as --backend | Examples: ``` $ chert login # Sign in with the browser $ chert login --device # On a server: open the printed address on any device ``` ## chert new Create a new voice or video agent folder from the bundled starter: ```bash theme={null} chert new my-agent chert new my-video-agent --video ``` The command creates `.env.local` with an agent name; add your own LiveKit and model credentials. It refuses to overwrite a folder containing files. `--video` selects code that publishes a drawn face; it does not enable caller-camera input. See [Audio, video, and caller vision](/facetime-cli/video). ## chert dev Put your LiveKit agent on your line until you press Ctrl+C. Claims your project's FaceTime line, keeps a room ready with your agent in it, and answers inbound FaceTime Audio and Video calls with that agent, without a webhook. Start your agent first. Ctrl+C requests cleanup and line release; wait for confirmation. The caller chooses the call type. There is no `dev --video` flag. Configure your agent's publications and subscriptions as described in the [video guide](/facetime-cli/video); `--muse` is not a general video switch. ``` chert dev [--agent NAME] [flags] ``` | Flag | What it does | | - | - | | `--agent NAME` | The dispatch name your LiveKit agent registers with: agentName in Node.js. Defaults to AGENT\_NAME from your environment or env file | | `--env-file PATH` | Read the LiveKit variables from this file | | `--project UUID` | The project to use when you belong to several | | `--takeover` | Replace another session that holds the line | | `--no-prewarm` | Create the room when a call arrives instead of keeping one ready. Slower to answer | | `--muse` | Prepare one Muse session per run. Use only with a Muse-compatible agent; one prepared call per run | | `--backend ORIGIN` | Another Chert address, [https://HOST](https://HOST) | | `--relay-origin ORIGIN` | Another connector relay address, [https://HOST](https://HOST) | | Environment variable | What it does | | - | - | | `LIVEKIT_URL` | Your LiveKit project address, wss\://HOST | | `LIVEKIT_API_KEY` | LiveKit API key, the same one your agent uses | | `LIVEKIT_API_SECRET` | LiveKit API secret. It stays on this computer | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | | `CHERT_USER_ACCESS_TOKEN` | A credential to use instead of the saved sign-in, for scripts | | `CHERT_CONTROL_ORIGIN` | Another Chert address, the same as --backend | | `CHERT_RELAY_ORIGIN` | Another relay address, the same as --relay-origin | Examples: ``` $ chert dev --agent my-agent # Answer calls with the agent registered as my-agent $ chert dev --agent my-agent --env-file .env.local # Take the LiveKit keys from a file ``` ## chert doctor Check sign-in, LiveKit keys, the line and your agent. Runs each check chert dev depends on and says what to fix. With --agent it also sends your agent a test job and times how long it takes to join. ``` chert doctor [--agent NAME] [flags] ``` | Flag | What it does | | - | - | | `--agent NAME` | Also check that this agent joins a test room. This runs a real agent job | | `--env-file PATH` | Read the LiveKit variables from this file | | `--project UUID` | The project to check when you belong to several | | `--backend ORIGIN` | Another Chert address, [https://HOST](https://HOST) | | Environment variable | What it does | | - | - | | `LIVEKIT_URL` | Your LiveKit project address, wss\://HOST | | `LIVEKIT_API_KEY` | LiveKit API key, the same one your agent uses | | `LIVEKIT_API_SECRET` | LiveKit API secret. It stays on this computer | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | | `CHERT_USER_ACCESS_TOKEN` | A credential to use instead of the saved sign-in, for scripts | | `CHERT_CONTROL_ORIGIN` | Another Chert address, the same as --backend | Examples: ``` $ chert doctor # Check sign-in, the project, the line and the Mac behind it $ chert doctor --agent my-agent # Check that the agent joins; this does not verify conversation audio ``` ## chert whoami Show who is signed in, and to which project. Asks Chert, so it also tells you when a sign-in was revoked. Offline, it shows what is saved and says it could not confirm it. ``` chert whoami ``` This command has no flags of its own. | Environment variable | What it does | | - | - | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | | `CHERT_USER_ACCESS_TOKEN` | A credential to use instead of the saved sign-in, for scripts | Examples: ``` $ chert whoami # Signed in as you@example.com · project Acme (admin). ``` ## chert logout Sign out and revoke this computer. Removes the saved sign-in and revokes it at Chert, so it cannot be used again from anywhere. ``` chert logout ``` This command has no flags of its own. | Environment variable | What it does | | - | - | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | Examples: ``` $ chert logout # Signed out. The credential was revoked at Chert. ``` ## chert setup Optionally save a project and an env file as defaults for dev and doctor. Useful when running outside your agent folder. These defaults take priority over automatic folder loading. Only the project UUID and absolute file path are saved, never the keys. New users running from the agent folder can skip this command. ``` chert setup --env-file PATH --project UUID chert setup --clear ``` | Flag | What it does | | - | - | | `--env-file PATH` | The file holding the three LiveKit variables | | `--project UUID` | The project to use | | `--clear` | Remove the saved defaults. Sign-in is not changed | | Environment variable | What it does | | - | - | | `CHERT_HOME` | Where the sign-in and call state are kept (default \~/.chert) | Examples: ``` $ chert setup --env-file .env.local --project PROJECT_UUID # Save both $ chert setup --clear # Forget them ``` ## Exit codes * `0` success * `1` internal * `2` usage * `3` not ready or not signed in * `4` state * `5` unsupported runtime * `6` network or server * `130` cancelled ## Advanced commands The connector and enrollment commands (`init`, `check`, `byor`, `create-room`, `run`, `status`, `projects`, `lines`, `bindings`, `enroll`, `activate`, `drain`, `revoke`) are for operating a connector by hand. `chert dev` does all of that for you. Run `chert --help --all` to see them, and read [Advanced connector commands](/facetime-cli/advanced-commands). # Projects and lines Source: https://docs.trychert.com/facetime-cli/connect Know what your organizer sets up and what you configure on your own computer. You need **two separate projects**: a Chert project for FaceTime access and a LiveKit project for your agent's media. Their names and IDs do not need to match. ## Chert setup: organizer or project administrator Before a student starts, the organizer must arrange: 1. A Chert account with **owner or admin** access to the intended workshop project. The current CLI cannot claim a line with the `member` role. 2. **Exactly one assigned FaceTime line** in that Chert project. Ask Chert to provision or assign the line if none is available; installing the CLI does not create one. 3. In the [FaceTime dashboard](https://facetime.trychert.com), verify the selected project's **Workers & Lines** configuration: inbound policy **customer decides** (`customer_decides`) and **no default hosted assistant**. Coordinate this change with the line owner before using a line that serves other traffic. 4. Confirm the line is online, supports the intended audio and video directions, and is reserved for the team. An available line is not necessarily ready to answer. 5. Give the student the Chert project name and UUID, actual FaceTime address, and testing time slot. See the [organizer checklist](/facetime-cli/workshop). The student guide does not create a Chert organization, project, account grant, or physical line. Complete that provisioning with your organizer or Chert first. The [dashboard guide](/facetime/dashboard) explains the project surfaces. ## LiveKit setup: each team Create your own [LiveKit Cloud account](https://cloud.livekit.io), create a project, and obtain that project's URL, API key, and API secret. Put them privately in `.env.local` as shown in [Build your agent](/facetime-cli/build-agent). Use the same credentials for the agent and Chert CLI. A LiveKit project does not create a Chert line or grant Chert access. ## Connect your computer: each student/operator Once Chert access is assigned and CLI beta.10 is installed, run these from your agent folder, which holds `.env.local`: ```bash theme={null} chert login chert whoami chert doctor ``` Approve browser login for the assigned **Chert** project. Browser login lasts 30 days and is scoped to that project. `dev` and `doctor` read the three LiveKit values from this folder's `.env.local` (or `.env`); no separate setup is needed. A Chert UUID is not a LiveKit project name or API key. To run from another folder, optionally save defaults: ```bash theme={null} chert setup --env-file /absolute/path/to/.env.local --project PROJECT_UUID ``` Replace `PROJECT_UUID` with the approved Chert project's UUID. Saved defaults retain priority over folder loading. See [credential loading](/facetime-cli/commands#credential-loading). | Action | Effect | | - | - | | `chert login` | Authorizes this computer for the selected Chert project. | | `chert setup` (optional) | Saves the project UUID and absolute env-file path locally. It saves no credential values from that file and does not activate a line. | | `chert doctor` | Checks access, line availability, connectivity and LiveKit credentials. | | `chert dev --agent NAME` | Enrolls and activates a connector for that line, then prepares rooms for the named agent. | | Ctrl+C in `chert dev` | Stops admission, attempts to finish cleanup, and releases the line. Wait for the result. | No manual `enroll` or `activate` command is needed for `chert dev`, for either FaceTime Audio or FaceTime Video. Neither call type requires a webhook. Video still requires an appropriately configured line and agent; see the [video guide](/facetime-cli/video). ## Switch projects or hand off a line End the call and stop `chert dev`; wait for line release before another operator starts. To switch Chert projects, run `chert login` and approve the new project, then run from the new agent folder with its matching LiveKit credentials. If you have saved setup, clear it with `chert setup --clear` or replace it with the matching UUID and env-file path. Also remove stale shell LiveKit overrides. Changing `--project` alone cannot expand the scope of an existing browser login. Only one connected CLI and one active call can use a line at a time. Coordinate handoffs; `--takeover` replaces another session and is not a routine setup step. `chert setup --clear` removes saved defaults but does not sign you out or release an active line. Stop the connector before `chert logout`. For custom callback/hook connectors, use [manual enrollment](/facetime-cli/manual-enrollment). # Custom create-room adapter Source: https://docs.trychert.com/facetime-cli/create-room Let the connector create rooms for a compatible named LiveKit agent. **Advanced:** ordinary named LiveKit agents should use [chert dev](/facetime-cli/quickstart). Use this optional mode when you want the connector to create a LiveKit room, dispatch your agent, and clean up the call's resources. **`chert create-room` starts a long-running connector; it creates a room when a call arrives, not at startup.** > [!NOTE] > > This private-preview adapter requires a qualified agent integration. An arbitrary LiveKit agent or an existing webhook URL is not enough. For application-owned sessions, use the [Custom BYOR callbacks](/facetime-cli/byor). ## Before you start * Arrange adapter access with Chert; use Node **22.12+ (22.x), 24.x or 26.x** on macOS or Linux with durable local storage. * Run a named LiveKit agent with a verified `chert-adapter-v1` health/cancellation hook on the same host, listening on literal loopback (`127.0.0.1`). Confirm compatibility with Chert first. * Obtain the exact agent name, deployment identifier, boot identifier, LiveKit URL, and hook port from that integration. * Supply `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `CHERT_HOOK_SECRET` securely to the connector process. Do not put secret values in command arguments or source control. ## 1. Install and initialize Install the published package in your application: ```bash theme={null} npm install --save-exact @trychert/facetime@0.1.0-beta.10 ``` Replace the uppercase placeholders with your integration's values: ```bash theme={null} npx --no-install chert init --non-interactive --mode create-room \ --config "$PWD/chert-room.json" --state-dir "$PWD/.chert/room-state" \ --agent-name AGENT_NAME --deployment DEPLOYMENT_ID --boot BOOT_ID \ --livekit-url wss://YOUR_LIVEKIT_HOST \ --hook-origin http://127.0.0.1:HOOK_PORT ``` Use fresh config and state paths. The CLI does not start your agent or install its hook. ## 2. Check the integration Start your agent, then run: ```bash theme={null} npx --no-install chert check --config "$PWD/chert-room.json" --json ``` This checks the configuration and adapter health without allocating a room. Exit **3** currently means partial diagnostics; inspect the results before proceeding. Process liveness alone does not establish per-call readiness. ## 3. Enroll and start Follow [Manual connector enrollment](/facetime-cli/manual-enrollment), using `chert-room.json` as the base config and a fresh output config such as `chert-room.enrolled.json`. Use `create-room` instead of `byor` when starting the connector: ```bash theme={null} npx --no-install chert create-room --config "$PWD/chert-room.enrolled.json" --json ``` After activation, an incoming call triggers room creation and named dispatch. The adapter waits for the expected agent to become ready before accepting. Test inbound audio with Chert and verify cleanup after hangup. ## Current limits Preparation must finish within the supplied call deadline; the adapter enforces a maximum **60-second resource lifetime**, with one active call at a time. This adapter supports audio only; that restriction does not apply to [`chert dev`](/facetime-cli/video) or [`chert byor`](/facetime-cli/byor), which support audio and video. Use BYOR for application-managed session lifetimes; changing modes requires a fresh config, state directory, and enrollment. # Manual connector enrollment Source: https://docs.trychert.com/facetime-cli/manual-enrollment Enroll one project and line, then explicitly activate the connector. These steps apply only to custom `chert byor` and `chert create-room` configurations. With a named LiveKit agent, use [login, setup and dev](/facetime-cli/quickstart), which handles enrollment and activation for you. Enrollment creates a **standby** connection; activation changes where new calls are delivered. > [!NOTE] > > Enrollment is a coordinated private-pilot step. Ask Chert to confirm the enabled control-plane and relay origins and provide the owner/admin authentication procedure. The browser-based `chert login` flow is available for `chert dev`. This advanced management workflow uses the user access token described here. A FaceTime project API key cannot replace the required user access token. ## 1. Select your project and line Inject `CHERT_USER_ACCESS_TOKEN` securely into a management shell. Replace uppercase placeholders below with values confirmed by Chert. `CONTROL_ORIGIN` is the HTTPS origin, without `/api`. ```bash theme={null} npx --no-install chert projects --config "$PWD/chert.json" \ --backend https://CONTROL_ORIGIN --json npx --no-install chert lines --config "$PWD/chert.json" \ --backend https://CONTROL_ORIGIN --project PROJECT_UUID --json npx --no-install chert bindings --config "$PWD/chert.json" \ --backend https://CONTROL_ORIGIN --project PROJECT_UUID --json ``` For create-room mode, substitute `chert-room.json`. Confirm the exact project and line IDs. The preview requires **exactly one line in the project**; the CLI does not automatically choose one or activate every project. ## 2. Enroll in standby ```bash theme={null} install -d -m 700 "$PWD/.chert" npx --no-install chert enroll --config "$PWD/chert.json" \ --backend https://CONTROL_ORIGIN --project PROJECT_UUID --line LINE_UUID \ --relay-origin https://RELAY_ORIGIN \ --output-config "$PWD/chert.enrolled.json" \ --credentials-file "$PWD/.chert/credentials.json" \ --environment production --json ``` This writes a new config and protected connection/recovery credentials. Keep both private. If enrollment or saving the response fails, inspect `bindings` before retrying; do not blindly enroll again. ## 3. Check and connect In the runtime shell, remove `CHERT_USER_ACCESS_TOKEN` from the environment. The connector uses the saved credentials instead. Run your agent normally, then: ```bash theme={null} npx --no-install chert check --config "$PWD/chert.enrolled.json" --json npx --no-install chert byor --config "$PWD/chert.enrolled.json" --json ``` Use `chert create-room` for a create-room config. A successful welcome confirms transport connectivity. `check` currently exits **3** for partial diagnostics; neither command proves a real call works. ## 4. Activate the selected line In the management shell, refresh the line and binding listings from step 1. After confirming the target with Chert, supply the current assignment generation: ```bash theme={null} npx --no-install chert activate --config "$PWD/chert.enrolled.json" \ --expected-binding none --assignment-generation CURRENT_GENERATION --json ``` Use `none` only when there is no previous active binding. For a replacement, use the previous binding's UUID. A stale generation or mismatched binding is rejected. Activation closes the standby connection. Restart the same runtime command, or let its supervisor restart it, before making a test call. Installation, checks, and reconnects never activate routing by themselves. ## Keep it running Run the connector alongside your application under your host's process supervisor. Keep **one writer per state directory** on durable local disk, including across deployments. Never share one directory between replicas or delete it to clear an error. To stop new calls going to this binding: ```bash theme={null} npx --no-install chert drain --config "$PWD/chert.enrolled.json" --json ``` Drain returns future calls to the line's saved webhook route. Keep the original connector available to finish active calls and recovery. See [Advanced connector commands](/facetime-cli/advanced-commands) for saved status, revocation, and cleanup diagnostics. # FaceTime CLI Source: https://docs.trychert.com/facetime-cli/overview Connect a LiveKit audio or video agent to FaceTime without a webhook. **Build a LiveKit agent. Start it. Run `chert dev` to connect it to FaceTime.** You can make a study tutor, interview coach, language partner, or another voice agent using your own code and model provider, with audio or video. Install with `npm install -g @trychert/facetime@0.1.0-beta.10` for the version used in this guide. Browser sign-in, automatic LiveKit credential loading from your agent folder, room preparation, and diagnostics are available. Separate `chert setup` is optional; existing saved settings still take priority. **Both `chert dev` and `chert byor` support inbound FaceTime Audio and FaceTime Video without a webhook.** Use a video-enabled line and an agent configured for the media you need. | Workflow | Inbound media | Setup | | - | - | - | | `chert dev --agent NAME` | Audio and video | Browser login; enrollment and activation are automatic for an eligible line. | | `chert byor` | Audio and video | Your session callbacks and [manual connector enrollment](/facetime-cli/manual-enrollment). | | `chert create-room` | Audio only | Specialized agent hook and manual enrollment; this legacy adapter's limit does not apply to `dev` or `byor`. | The [voice starter](/facetime-cli/build-agent) is audio-only. For a video agent, use `chert new my-video-agent --video` and the [video guide](/facetime-cli/video). Showing an avatar and seeing the caller's camera are separate agent capabilities. > **[Build your first agent](/facetime-cli/build-agent)** > > Create a LiveKit account and a complete JavaScript voice agent. > **[Connect and make a call](/facetime-cli/quickstart)** > > Install Chert, sign in, run two terminals, and test your agent. > **[Audio, video, and caller vision](/facetime-cli/video)** > > Publish agent video and connect the caller's camera to your vision pipeline. > **[Projects and lines](/facetime-cli/connect)** > > Understand access, line assignment, and optional saved settings. > **[Run a hackathon](/facetime-cli/workshop)** > > Prepare accounts, allocate lines, and organize team handoffs. ## How the pieces fit together | Piece | What it does | Where it runs | | - | - | - | | Your LiveKit agent | Listens, reasons, speaks, and optionally publishes or consumes video. | Your laptop for this guide; it can also be hosted. | | LiveKit project | Carries media and dispatches your agent into a room. | LiveKit Cloud. | | Chert CLI | Connects your assigned FaceTime line to the named agent. | Your laptop, alongside the agent. | | Chert FaceTime line | Receives the caller and bridges the call to LiveKit. | Chert's separately operated FaceTime infrastructure. | Your laptop does not become the FaceTime line. You install the CLI on the computer running the connector; installing this npm package on every line computer is not part of this workflow. **The CLI does not start your agent code.** ## Project names and agent names are different | Name or credential | Example | Used for | | - | - | - | | Chert project UUID | The UUID supplied by your organizer | Selects your Chert access and assigned FaceTime line. | | LiveKit project display name | `Otter Hackathon` | A label in LiveKit Cloud; it does not need to match the agent name. | | LiveKit URL, API key, and secret | Private values from that LiveKit project | Connect both your agent and the CLI to the same LiveKit project. | | Agent dispatch name | `team-otter-agent` | Tells LiveKit which running agent should receive the job. | Agent servers, rooms, and **named dispatch are LiveKit concepts**. Chert uses LiveKit's dispatch API to connect a call to your code. In our starter, `AGENT_NAME=team-otter-agent` is read by the code and passed to LiveKit's `ServerOptions.agentName`. Starting the program with `npm start` registers that name with LiveKit. You do not separately create a permanent agent entry in the dashboard for this local workflow. Then run: ```bash theme={null} chert dev --agent team-otter-agent ``` Here `--agent` is the option and `team-otter-agent` is its value. **That value must exactly match the dispatch name registered by your running agent in the same LiveKit project.** `AGENT_NAME` is a convention used by this starter, not an automatic LiveKit setting for every program. See [LiveKit's named dispatch documentation](https://docs.livekit.io/agents/server/agent-dispatch/). ## What happens during a call 1. Your agent starts and registers its dispatch name with LiveKit. 2. `chert dev` claims the selected Chert line and prepares a waiting room with your agent. 3. Someone calls that line's FaceTime address. Chert uses the prepared room for the call. 4. The caller and agent exchange audio and, on video calls, their configured video tracks through LiveKit and FaceTime. 5. After hangup, the CLI removes the call's room and prepares a fresh one. 6. Ctrl+C stops the connector and releases the line. A waiting agent proves it joined the room. It does not prove model access or working call audio or video. Test conversation, each required video direction, and cleanup. ## Advanced integrations Most LiveKit developers should use `chert dev`. If your application must own session creation and cleanup, see [custom BYOR callbacks](/facetime-cli/byor). The separate [create-room adapter](/facetime-cli/create-room) requires a specific health/cancellation hook. Those integrations have their own manual enrollment and recovery workflow; students do not need them for this guide. # Give a personal agent a Chert identity Source: https://docs.trychert.com/facetime-cli/personal-agent Verified CLI and SDK surfaces, safe integration steps, and the capabilities that are still pending. This guide is for persistent personal-agent products such as OpenClaw, Hermes, and Muse. A coding agent may integrate Chert into one of those projects; it is not the personal agent. Chert does not currently provide a universal personal- agent plugin, always-on host, or unified iMessage/FaceTime identity. For the temporary integration fallback, see [trychert.com/setup](https://trychert.com/setup). > [!WARNING] > > The beta.10 line-allocation checks were fixture-based, not a live allocation. > The shared pool was still pending line qualification in the release evidence. > Do not claim an account, assigned line, delivery, or working call until the > service response and an explicitly authorized real-world check confirm it. ## Capability status This YAML is the machine-readable summary for agents. “Verified” describes the published package/help/fixture or loopback checks listed below; it does not mean that a real customer line, message, or call was exercised. ```yaml theme={null} guide_version: 1 audience: persistent-personal-agent-integrator cli_package: "@trychert/facetime@0.1.0-beta.10" sdk_package: "@trychert/sdk@0.1.0" available: cli_commands: [login, onboard, new, doctor, dev, "guide fix"] sdk_cli: [context, imessage-send, facetime-status, facetime-lines, facetime-workers] sdk_imessage_send: "queued receipt; no automatic retry or delivery guarantee" facetime_dev: "foreground incoming-call connector; Ctrl+C requests graceful release that must be confirmed" pending_or_unverified: live_owner_verification: true live_shared_line_allocation: true always_on_personal_agent_service: true automatic_restart_and_resume: true imessage_watch_or_receive_command: true outbound_facetime_start_watch_end: true chert_managed_owner_contact_policy: true line_allocation_release_command: true ``` ## Prerequisites and bootstrap * A macOS or Linux host and access to the intended Chert project. Native Windows is unsupported. * Node.js 22.12+ (22.x), 24.x, or 26.x for the CLI when using npm/pnpm/npx; the bundled starter supports Node 22.23.2 or 24.x. Bun 1.4.0 package commands were exercised locally. * For voice, a registered LiveKit agent and its existing LiveKit project values. * For the SDK channels, separate iMessage and FaceTime service credentials. A shared `CHERT_PROJECT_ID` is context; it does not bind or authorize the two credentials together. * Explicit owner approval before browser/device sign-in, phone verification, paid provider activity, messages, or calls. The following package-resolution and help commands were exercised against the published packages. At review, npm's `beta` tag pointed to beta.10 while `latest` pointed to beta.6. Use `@beta` intentionally in npm/Bun commands; do not omit the tag. The tested `pnpm add @trychert/facetime@beta` resolved beta.6, so the pnpm example pins beta.10 explicitly. ```bash theme={null} npm install @trychert/facetime@beta @trychert/sdk@0.1.0 pnpm add @trychert/facetime@0.1.0-beta.10 @trychert/sdk@0.1.0 bun add @trychert/facetime@beta @trychert/sdk@0.1.0 npx --yes --package @trychert/facetime@beta chert --help bunx --yes --package @trychert/facetime@beta chert --help npx --yes --package @trychert/sdk@0.1.0 chert-sdk --help bunx --yes --package @trychert/sdk@0.1.0 chert-sdk --help ``` The Chert CLI includes these supported entry points: ```bash theme={null} bunx --yes --package @trychert/facetime@beta chert login bunx --yes --package @trychert/facetime@beta chert onboard --skip-demo --no-browser --json --wait 0 bunx --yes --package @trychert/facetime@beta chert new my-agent bunx --yes --package @trychert/facetime@beta chert doctor --json bunx --yes --package @trychert/facetime@beta chert dev --agent AGENT_NAME --json bunx --yes --package @trychert/facetime@beta chert guide fix ``` `login` approves one project for this computer. Use it for an existing project. Use `onboard` only for a new setup after the owner approves the phone-verification step. A human must complete the browser and send any displayed verification code; an agent must not send it, ask the owner to paste it into chat, or claim verification succeeded while the command is pending. `--skip-demo` skips the hosted demo request, not verification or line allocation. If the service cannot allocate a line, stop and report that exact result. Do not create a second onboarding to retry an uncertain request. `new` creates a generic LiveKit voice-agent starter; it does not integrate an existing OpenClaw, Hermes, Muse, or other personal-agent runtime. It refuses to overwrite a non-empty target. A clean starter's `npm start` is separate from `chert dev`; neither command installs an always-on service. ## Temporary generated wrapper Until a universal personal-agent CLI ships, an integrating coding agent may create a project-local wrapper in a new, empty directory. The names below are **commands generated in that project**, not commands supplied by Chert: | Local wrapper command | Safe contract | | - | - | | `setup` | Inspect the explicit brief and relevant project files, summarize the intended files and behavior, and wait for approval before writing. Reuse the authenticated project, assigned line, and existing LiveKit environment. | | `start` | Start the selected personal-agent runtime and the foreground `chert dev` connector only after setup and health checks. It is not an always-on supervisor. | | `health` | Run read-only `chert doctor --json` and available SDK identity/line/worker reads. Report identity separately from media readiness. | | `message` | Send only after explicit approval of recipient and body. Use `ChertClient.sendText` or `chert-sdk imessage send`; a `queued` receipt is not delivery. | | `call` | No outbound call method is available in the published SDK. Return a structured `capability_pending` result; never invent an endpoint or start a call. Incoming calls are handled by `chert dev` when a ready line exists. | | `watch` / `status` | `status` may report `doctor`, identity, lines, workers, and local process state. A wrapper may relay events from its own `chert dev --json` process for incoming FaceTime calls; there is no SDK iMessage inbox watcher or remote FaceTime call watch API. | | `stop` | Send one SIGINT to wrapper-owned `chert dev`, then wait for confirmed binding release. `release_failed` or retained cleanup is not a release. This does not unassign the phone number/shared allocation. | `chert dev --json` emits newline-delimited JSON records with `schema_version: 1` and `command: "dev"`. Call events include `call_incoming`, `call_accepted`, `call_declined`, `call_ended`, `call_media`, and `call_bridge_ready`. The last means Chert wrote the bridge-ready room signal; it is not proof that audio/video worked or that the caller heard the agent. Session records include `dev_ready`, `dev_stopped`, `cleanup_pending`, and `release_failed`. On shutdown, a second interrupt reports that release is still underway; a third can exit without releasing. Do not claim cleanup until the terminal record confirms it. The wrapper should use a project-local `chert-agent` executable (for example, `./chert-agent health`) and emit JSON Lines with a documented schema. That output schema is local to the generated project, not a Chert API guarantee. Persist only non-secret project/line/agent identifiers and stable idempotency keys in an owner-only state file. Reuse `~/.chert` and the same onboarding intent. Automatic service restart, durable message watching, and recovery after host reboot remain unverified; do not promise them or invent a supervisor integration. ## Secrets, consent, and identity Create or edit `.env.local` locally and require mode `0600`. Add `.env.local` and `.env*` to `.gitignore` before writing secrets. Keep `LIVEKIT_API_SECRET`, `OPENAI_API_KEY`, `CHERT_API_KEY`, `CHERT_FACETIME_TOKEN`, and all login/device credentials out of prompts, command arguments, stdout/stderr, screenshots, logs, and version control. Never print an env file to diagnose it; report variable names and whether values are present, not their values. The CLI login credential, iMessage API key, FaceTime token, and LiveKit API secret are different credentials. The SDK's project ID is context only for the iMessage client. No Chert-managed owner-contact allowlist or cross-channel consent policy is currently provided. The personal-agent application must derive its persona and handlers from the owner's explicit brief and relevant repository, and enforce the approved recipient/contact policy locally. Before every real outbound message or call, show the exact recipient, content or call purpose, and expected cost/impact, then wait for that action's explicit confirmation. Setup consent is not send/call consent. `doctor --agent` dispatches a real agent job; ask before using it. Do not test against a real person or line without approval. ## Project, line, and LiveKit lifecycle Choose the project the owner approved. `chert login` signs this computer into that project. `chert-sdk facetime lines` and `workers` inspect existing access; they do not assign a line. `chert doctor --json` checks readiness predicates, but a healthy response does not prove two-way media. A project must already have the eligible line and worker required by `chert dev`. Run the personal agent using its own supported host/runtime. Put its LiveKit values in the same project's `.env.local`; Chert does not start or deploy the personal agent. Start the foreground connector with `chert dev --agent NAME --json`. It handles incoming calls on an assigned line. It is not an outbound FaceTime call command and is not a background service. The owner must accept or place a real test call only after explicit confirmation. Audio/video success must be confirmed during the call, not inferred from identity, health, or connection status. For shutdown, send SIGINT/Ctrl+C to the wrapper-owned `chert dev` process and wait for its terminal `dev_stopped` record. Preserve state if it reports `cleanup_pending` or `release_failed`. This drains the connector when cleanup is confirmed; it does not release the project allocation or phone number. There is no verified CLI command to release that allocation; ask the project owner/admin. ## iMessage and FaceTime API boundaries The SDK CLI reads existing FaceTime identity, lines, and workers: ```bash theme={null} chert-sdk context chert-sdk facetime status chert-sdk facetime lines chert-sdk facetime workers ``` `facetime status` checks identity, not media readiness. There is no published SDK call-start, call-watch, or call-end method. `chert dev` is the supported incoming-call connector path when its line is ready. An explicitly approved iMessage send may use: ```bash theme={null} chert-sdk imessage send --phone +15555550123 --body "Approved message" --idempotency-key stable-request-id ``` This returns a strict HTTP 202 `queued` receipt. It means accepted for queueing, not sent, delivered, or read. The client does not retry automatically. On timeout or an unconfirmed response, preserve the same idempotency key and reconcile with the service/operator before trying again. The published CLI has no generic iMessage `watch` or `receive` command; do not poll guessed endpoints or infer delivery from queue acceptance. ## Structured output and recovery `chert --json` emits one JSON record per line with `schema_version: 1` and `command`; `chert dev --json` is an event stream. Errors go to stderr as structured codes/actions. SDK channel commands emit JSON objects by default and sanitize errors. The local wrapper should preserve these streams, never parse human prose as a success signal, and keep its own stdout schema separate. | Error/state | Action | | - | - | | `login_required` | Pause for owner browser/device approval; do not request or store a browser token in chat. | | `forbidden` | Check `chert whoami`; ask the project owner/admin to grant the correct role. | | `line_in_use`, `line_in_call` | Wait for the current holder/call. Do not use takeover without explicit admin approval. | | `line_paused`, `shared_line_paused`, `shared_allocation_inactive`, `worker_unavailable` | Stop and ask the owner/admin to resolve readiness. Preserve onboarding state; do not create another allocation or force a call. | | `cleanup_pending`, `release_failed` | Preserve local state and stop new calls; ask the owner/admin to verify cleanup. | | `unconfirmed_text_outcome` | Do not send again automatically. Reconcile the original request with its same idempotency key. | For diagnostics, begin with `chert guide fix` and `chert doctor --json`. Capture timestamps, command names, project/line IDs only when safe, error codes, and redacted event records. Redact phone numbers, message bodies, tokens, URLs with credentials, `.env.local`, and provider responses. Never attach raw process environments or unfiltered logs. ## Ready-to-paste integrator prompt ```text theme={null} Integrate Chert as an identity/communications layer for my persistent personal agent. The personal agent (for example OpenClaw, Hermes, or Muse) is the product; you are only the one-time coding integrator. Use only my explicit brief and the relevant repository. Do not scrape unrelated conversation, environment files, credentials, or absolute project paths. First inspect the target: if it is not empty, make an additive integration plan and do not overwrite files. If it is empty, show me the planned files and behavior and wait for approval before writing a project-local `chert-agent` wrapper CLI. Use the verified packages only: `@trychert/facetime@beta` for `login`, `onboard`, `new`, `doctor`, and `dev`; use `@trychert/sdk` for its existing iMessage send and FaceTime identity/line/worker methods. Do not claim the SDK provides iMessage receive/watch or FaceTime call start/watch/end. The generated wrapper must expose `setup`, `start`, `health`, `message`, `call`, `status`, `watch`, and `stop`; unsupported call/watch behavior must return a structured pending result without contacting an invented endpoint. Reuse the owner's existing Chert project, assigned line, and LiveKit project. Use `chert login` for an existing project. Start `chert onboard --skip-demo` only after I approve browser/phone verification; stop for me to complete any human step, and resume the same onboarding rather than creating another. If no line is actually returned and healthy, stop and report that. Do not infer allocation from a successful install or fixture result. Derive persona, contact policy, and handlers only from my explicit brief and relevant project files. Keep the personal agent's existing runtime and identity. Create `.env.local` only after showing planned files; enforce mode 0600 and `.gitignore`. I will enter LiveKit/OpenAI/Chert secrets locally. Never print, echo, log, transmit, screenshot, commit, or ask me to paste secrets into chat. Persist only non-secret project/line/agent state and stable idempotency keys; preserve `~/.chert` and recover the same onboarding after timeouts. Require a fresh explicit confirmation immediately before each real outbound message or call, showing exact recipient and content/purpose. A queued message is not delivery. Do not retry an uncertain send automatically. `chert dev` only connects an existing LiveKit agent for incoming calls; it is foreground, not an always-on service. Do not invent service-manager, inbox-watcher, outbound-call, or line-release commands. Run read-only health checks first, preserve structured JSON/events, redact logs, and report every capability that remains pending. ``` # Connect and test Source: https://docs.trychert.com/facetime-cli/quickstart Install the CLI and test your agent with FaceTime Audio or FaceTime Video. Use this after [building your agent](/facetime-cli/build-agent), or with an existing named LiveKit audio or video agent. For a video starter or caller-camera input, follow [Audio, video, and caller vision](/facetime-cli/video). You need your organizer's Chert project UUID, owner/admin access, and an assigned FaceTime line. See [Projects and lines](/facetime-cli/connect). ## 1. Install and sign in once Use **macOS or Linux**. The CLI supports Node **22.12+ in the 22.x series, 24.x or 26.x**; the JavaScript starter still uses **22.23.2 or 24.x**. Node 26 CLI support does not qualify the starter or a real FaceTime call on that runtime. Native Windows is not supported. Check `node --version`; if you use nvm, select the same version in both terminals. ```bash theme={null} npm install -g @trychert/facetime@0.1.0-beta.10 chert --version chert login chert whoami ``` This guide pins **0.1.0-beta.10**, including the voice/video starters, browser sign-in, and automatic LiveKit credential loading. Confirm that version before continuing. Choose browser sign-in, compare the displayed code, select your assigned Chert project, and approve. In a remote shell, `chert login --device` prints the address and code. You do not need to copy a browser session token for normal sign-in. ## 2. Check the connection from your agent folder From your agent's folder, where you created `.env.local`: ```bash theme={null} chert doctor ``` No separate setup step is needed. The CLI reads `LIVEKIT_URL`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET` from `.env.local` (or `.env`) in this folder and uses the Chert project you approved at sign-in. It reports which file it used. Keep these values in the same LiveKit project as your agent. Existing shell `LIVEKIT_*` values override the file. If you previously ran `chert setup`, its saved project and file path still take priority. To switch to folder loading, run `chert setup --clear` after stopping any active connector. This keeps your sign-in. See [credential loading](/facetime-cli/commands#credential-loading) for explicit files and optional setup when running from elsewhere. Plain `doctor` is read-only. Check that login, project, line, online worker and LiveKit checks pass. The agent check is skipped until you supply `--agent`. Resolve busy/offline lines with your organizer before continuing. ## 3. Keep two terminals running ### Terminal A: agent In the agent folder: ```bash theme={null} npm run check npm start ``` These are the supplied starter's scripts. Use your own startup command for an existing agent. Wait for successful registration with LiveKit. Leave this terminal running; Chert does not start the program for you. ### Terminal B: Chert Open a second terminal in the same folder, using the same Node version: ```bash theme={null} chert doctor --agent team-otter-agent chert dev --agent team-otter-agent ``` Replace `team-otter-agent` with your exact registered dispatch name. `doctor --agent` runs a real agent job in a temporary room and may incur provider usage. It checks membership, not a full conversation. Keep this terminal running too. Wait for the CLI to report that it is connected and your agent has joined a waiting room before calling. Room membership does not verify audio or video. The same `chert dev` command handles both call types without a webhook. > [!NOTE] > > Generic `chert dev` automatically prepares and refills waiting rooms. Do not add > `--muse` to the student starter: that flag is for a separately integrated Muse > agent, with one prepared session per run. It is not a general avatar switch. ## 4. Test two real calls 1. Call the exact address assigned by your organizer using **FaceTime Audio** for voice, or **FaceTime Video** on a video-enabled line to exchange video. 2. After connection, say hello. The voice starter on the build-agent page waits for you to speak; the beta.10 generated starters can greet after bridge readiness. 3. Ask a question related to your agent's instructions and a follow-up. Confirm it responds to your actual words, then try interrupting once. For video, verify the agent picture changes. If the agent uses caller vision, change what your camera shows and confirm the agent responds to the new view. 4. Hang up from the caller's device and wait for Chert to report room cleanup. 5. Wait for the next waiting room, then make a second call. Confirm it begins a fresh conversation and cleans up again. “Connected” and a successful doctor check are not substitutes for this test. The [voice starter](/facetime-cli/build-agent) is audio-only; this is a property of that example, not a CLI limitation. The [video starter](/facetime-cli/video) publishes a face but needs additional integration for caller vision. If no reply arrives, check the agent terminal for model/key/credit errors before trying repeatedly. ## 5. Stop or change your agent End the call first. Press **Ctrl+C in Terminal B** and wait for confirmation that cleanup has finished and the line has been released. Then stop the agent in Terminal A. To edit, change its instructions or tools and restart both terminals so a previously prepared room does not use old code. For the next session, start the agent, then run the short command again: ```bash theme={null} chert dev --agent team-otter-agent ``` If cleanup or release remains pending, preserve `~/.chert` and ask your organizer for help. Do not delete state to dismiss the error or let another team take over before resolving it. ## Common problems | Symptom | Next step | | - | - | | `chert: command not found` | Select the Node version used for installation and reinstall the pinned package. | | Wrong project / `project_not_signed_in` | Approve login for the intended Chert project; clear or update any old saved setup. | | `project_role_insufficient` | Ask for owner/admin access to the workshop project. | | No line / multiple lines / routing error | Ask the organizer to provide exactly one eligible line; see [Projects and lines](/facetime-cli/connect). | | Busy line / offline worker | Wait for your slot or contact the organizer. Do not blindly use `--takeover`. | | LiveKit keys missing or wrong project | Run from the agent folder; check `.env.local`, shell overrides, and any saved setup. | | Agent did not join | Keep the agent running; match the dispatch name and LiveKit credentials in both processes. | | Voice works but video is missing | Use FaceTime Video and check the [video troubleshooting guide](/facetime-cli/video#check-both-video-directions). | | Connected but silent | Say hello after connection, unmute the caller, and check the agent's model credentials/credits and errors. | | Old or unexpected agent answers | Stop other instances using the same name; restart both terminals after code changes. | | Call ends immediately | Record the call time and errors from both terminals; ask the organizer to check the call record. | | Cleanup remains pending | Preserve state and stop testing until the organizer resolves it. | For help, share Node/CLI versions, agent name, call time, and relevant error lines. Never share tokens, API keys, `.env.local`, or unsanitized logs. # Audio, video, and caller vision Source: https://docs.trychert.com/facetime-cli/video Use FaceTime Video with chert dev or BYOR callbacks, without a webhook. **`chert dev` and `chert byor` support inbound audio and video without a public webhook.** The caller chooses FaceTime Audio or FaceTime Video. There is no `chert dev --video` switch: use the same command with either call type. | What you want | What your agent needs | | - | - | | Talk to the caller | Publish audio and consume the caller's microphone track. | | Show the caller your agent | Publish a camera track from the same participant that publishes the agent's audio. | | See what the caller points at | Subscribe to the caller's video track and send frames to a vision-capable model or tool. | Use an eligible Chert line with video enabled. Caller-camera delivery also needs capture enabled and configured on that line's worker. Confirm those capabilities with Chert for your assigned line; working audio does not establish either video direction. A webhook is not a prerequisite for video. ## Start a video agent For an existing named LiveKit agent, keep its normal startup command and run `chert dev --agent YOUR_AGENT_NAME` from the folder with its LiveKit credentials. No callback module or manual enrollment is needed for `dev`. For a new agent, the published beta.10 CLI includes a starter that speaks and publishes an animated face: ```bash theme={null} npm install -g @trychert/facetime@0.1.0-beta.10 chert new my-video-agent --video cd my-video-agent npm install ``` Use macOS or Linux with Node 22.23.2 or 24.x for this walkthrough. Fill in the generated `.env.local` with your `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `OPENAI_API_KEY`. Keep `AGENT_NAME=my-video-agent`, or use the same new dispatch name in both your agent and CLI. In the agent terminal: ```bash theme={null} npm run check npm start ``` In a second terminal in the same folder: ```bash theme={null} chert login chert doctor chert dev --agent my-video-agent ``` Approve login for your assigned Chert project, then wait for the CLI to report the connection and waiting agent. Call your line using **FaceTime Video** to hear the agent and see its animated face. A FaceTime Audio call carries its voice only. Follow [Connect and test](/facetime-cli/quickstart) for hangup, cleanup, and line release. `--video` belongs to `chert new`; it selects starter code. It does not enable video on a line. `--muse` is a separate, specific agent integration and is not required for ordinary video agents. ## Connect the caller's camera to your agent The video starter **publishes an animated face but does not consume the caller's camera**. It uses `AutoSubscribe.AUDIO_ONLY` and `videoEnabled: false` for input. For a visual-support demo such as asking the caller to point at a port: 1. Use FaceTime Video with the caller's camera on and confirm caller capture is enabled on the assigned line. 2. Register track handlers before joining the room and enable video subscriptions in your agent. For generic `chert dev`, the caller is the bridge participant named `chert-bridge-`; custom BYOR uses the bridge identity in your token. 3. Consume that participant's `facetime-remote-video` frames, alongside `facetime-remote-audio`. Connect video input to your vision-capable model or frame-processing tool; changing subscription settings alone does not add vision. 4. Handle camera mute, track removal, and replacement. Do not treat an old frame as the caller's current view. 5. Test recognition by changing what the camera shows. If your demo also draws a circle or annotation, render it into your agent's published video. The CLI does not supply object detection or a drawing tool. Warm-room job metadata has no call media or call ID. Register subscriptions so your agent can handle tracks arriving after the caller joins. For a cold dispatch, job metadata includes `media.video`; custom BYOR callbacks receive `call.media` directly. See [Audio and video](/facetime/audio-video) for the media contract. ## Publish the agent's video Keep exactly one eligible remote audio publisher in the room. The FaceTime bridge takes the agent video from that same participant. If an avatar service publishes both voice and video, avoid also publishing a second agent audio track. Video published by a different participant from the voice is not shown. Without agent video, the caller sees the line's default picture. This does not by itself tell you whether caller video is reaching your agent. ## Custom BYOR callbacks The [callback workflow](/facetime-cli/byor) receives `call.media: { audio: true, video: boolean }`. Prepare a session for the requested media before returning `ready: true`; decline if your application cannot support it within the supplied deadline. Your application owns readiness and cleanup. Manual enrollment is a setup requirement for this mode, not a restriction to audio. The separate [create-room adapter](/facetime-cli/create-room) remains audio-only. Use `dev` or `byor` for video. ## Check both video directions `chert dev` reports the observed agent audio/video publications after a call starts. These diagnostics help find missing video or a mismatched publisher; they do not prove that frames reached FaceTime or your vision model. | Symptom | Check | | - | - | | Voice works but no agent video | Use FaceTime Video; publish a camera track from the participant that speaks. | | Default picture despite a video publisher | Check that audio and video come from the same participant. | | Agent cannot see the caller | Check caller camera, worker capture, video subscriptions, and model/frame input separately. | | Call ends with several audio publishers | Keep one eligible remote audio publisher; avoid duplicate agent/avatar voice tracks. | On the intended line, verify two-way speech, changing agent video, changing caller video reaching your vision pipeline, caller hangup, room cleanup, and a fresh second call. `doctor` and room membership do not replace these checks. # Hackathon organizer guide Source: https://docs.trychert.com/facetime-cli/workshop Give each team an agent project, Chert access, and a scheduled FaceTime line. Students build **LiveKit voice agents** on their own laptops. Each team creates its own LiveKit Cloud project and model API account; you provide Chert project access and an already provisioned FaceTime line. Use **inbound FaceTime Audio** as the voice-starter baseline. Teams can also use **FaceTime Video** through the same CLI without a webhook; use the [video guide](/facetime-cli/video) and confirm the intended line supports each required video direction. ## Before the event | Organizer provides | Each team prepares | | - | - | | Chert account access with owner/admin role in a workshop project. | A LiveKit Cloud account and project, plus its private URL/key/secret. | | Exactly one eligible line in that Chert project and its FaceTime address. | A funded model API account; the starter uses OpenAI Realtime. | | Reserved test slot and support contact. | macOS/Linux with Node 22.23.2 or 24.x, npm, and an editor. | | Confirmed routing and a physically tested line. | A unique dispatch name, for example `team-otter-agent`. | | Links to [Build your agent](/facetime-cli/build-agent) and [Connect and test](/facetime-cli/quickstart). | A FaceTime-capable device/account to call the assigned line. | Use dedicated workshop Chert projects: owner/admin is a privileged role, and the current CLI has no limited student role for claiming a line. Complete [project and line setup](/facetime-cli/connect) with Chert before students arrive. Do not hand out production API keys or ask students to reuse a hosted demo agent's name. ## Plan capacity and handoffs **One line = one connected CLI = one active call at a time.** More agent names in LiveKit do not create more FaceTime capacity. If there are more teams than lines, schedule testing slots while other teams build their agents locally. Give each team a private assignment card: * Chert account, project name and UUID. * Exact FaceTime address and time slot. * Agent dispatch name. * Who pays for LiveKit and model usage, the budget, and support contact. Keep secret values out of the assignment card and public guides. Teams store their own credentials privately in `.env.local`. At the end of a slot, the caller hangs up, the student waits for room cleanup, stops Chert and confirms line release, then stops the agent. The next team runs `chert doctor` before starting. Hold the handoff if cleanup or release failed; only the organizer should coordinate a takeover. ## Rehearse the actual student experience Before opening a line to students, follow the public guide in a fresh folder: install CLI beta.10, build the starter, approve browser login, check credentials from the agent folder, and run both terminals. On **each intended line**, verify: 1. A relevant two-way conversation using the student's model account. 2. A follow-up and interruption. 3. Caller hangup and room cleanup. 4. A fresh second call and second cleanup. 5. Ctrl+C and confirmed line release. Record the date, Node/CLI versions, line and outcomes. Doctor, a recent heartbeat, and automated tests do not establish a working FaceTime conversation. ## Costs and scope Prewarming and `doctor --agent` dispatch real jobs. The supplied starter waits for a participant before opening its model session, but modified agents may incur charges earlier. Set budget ownership and limits before the event. The public JavaScript starter has been checked for installation, syntax and SDK compatibility; its live two-call rehearsal is still required. A previous Muse demo does not qualify this starter or every workshop line. For video demos, also verify changing agent video and caller-camera input on each intended line. The video starter publishes a face; caller vision and custom avatars need their own agent integration. Do not add `--muse` to ordinary student agents. The CLI runs on student/operator computers. Line provisioning, infrastructure maintenance and media troubleshooting remain with the organizer and Chert. # Managed Assistants Source: https://docs.trychert.com/facetime/assistants Run a Chert-managed persona on a provisioned line. For a Chert-managed conversation, configure an Assistant in the [FaceTime dashboard](https://facetime.trychert.com). For your own agent and LiveKit project, use [BYOR](/facetime/bring-your-own-room). > [!NOTE] > > “Chert-operated line” refers to the FaceTime infrastructure. “Chert-managed Assistant” refers to who runs the agent. If you bring your own coaching agent, choose BYOR even though Chert operates the line. ## Lifecycle 1. Create or edit a draft with a name, system prompt, greeting, and avatar configuration. 2. Publish an immutable version. 3. Assign the Assistant to a line. The line resolves its active published version for a new call. ## Inbound calls The worker checks readiness, answers an eligible call, initializes the managed agent, and bridges media. Review the call record for status and terminal reason. Publishing a new version affects subsequent calls. > [!NOTE] > > FaceTime Video carries audio and video in both directions. A vision-enabled managed agent can receive the caller's camera while presenting its own video. See [Audio and video](/facetime/audio-video) for media selection. ## Configuration Use the dashboard to create, publish, and assign Assistants. Keep a BYOR line on `customer_decides` with no default Assistant: assigning a published Assistant can select the managed route instead. # FaceTime Audio vs. Video Source: https://docs.trychert.com/facetime/audio-video Choose the right media experience for each call. > [!NOTE] > > FaceTime Video supports audio and video in both directions: your agent can hear and see the caller, and the caller can hear and see your agent. FaceTime Audio carries audio only. Outbound BYOR currently supports **audio only** on enabled lines. The video guidance below applies to enabled inbound video calls. See [Outbound](/facetime/outbound). ## Media comparison | | FaceTime Video | FaceTime Audio | | - | - | - | | Avatar | Animated persona or participant video | No video track; voice only | | Connecting experience | Branded holding screen for \~5–10 seconds | Silence during initialization; holding audio is planned | | Assistant config | Same assistant; avatar is rendered | Same assistant; avatar\_id is unused | | Sessions | Audio and video in both directions | Audio in both directions | | Call record | `media: { audio: true, video: true }` | `media: { audio: true, video: false }` | ## Practical guidance For coaching and movement analysis, connect the caller's video frames to your own vision pipeline. Media transport supplies the track; your agent supplies the perception and coaching behavior. Use [BYOR](/facetime/bring-your-own-room) with your own agent, even when Chert operates the FaceTime line. * Keep greetings concise. For outbound, wait for `bridge_ready` or the corresponding outbound `call.started` notification before speaking. * Inbound audio and video can use either the incoming-decision webhook or the [CLI (`chert dev` or `chert byor`)](/facetime-cli/video). Video does not require a webhook. Outbound audio starts with an API request. * During design-partner rollout, `call.incoming` includes `media`, allowing different accept or decline logic by call type. For BYOR, subscribe to the bridge participant's `facetime-remote-audio` and `facetime-remote-video` tracks. Handle camera mute and track changes throughout the call. # Authentication & API Keys Source: https://docs.trychert.com/facetime/authentication Authenticate project-scoped server requests safely. > [!NOTE] > > **Base URL** > > `https://facetime.trychert.com/api` ## Project API keys `ftk_…` keys are bearer credentials for customer API access. Create them in **Developer → Project API keys**. Secrets are shown once, hashed at rest, and revocable at any time. Use `facetime:read` for GET endpoints and `facetime:write` for mutations. Every request requires both headers: ```http theme={null} Authorization: Bearer ftk_... X-Chert-Project-Id: ``` ## Worker credentials `ftw_…` credentials identify internal Chert infrastructure for heartbeats and call reporting. They are never issued to customers; the prefix is documented only so you can recognize it. ## Key hygiene * Keep API keys server-side and out of browser bundles. * Keep BYOR bridge credentials on your backend; issue separate scoped credentials to any browser participant. * Rotate by creating a replacement, migrating traffic, then revoking the old key. * Webhook signing secrets are separate from API keys. # Bring Your Own Room Source: https://docs.trychert.com/facetime/bring-your-own-room You host the room; Chert joins it as the FaceTime gateway. Your server creates a LiveKit room, runs your agent, and mints a separate participant token for Chert's FaceTime bridge. Your agent joins your own LiveKit project; Chert does not supply a hosted room for BYOR. To initiate a call from your backend, use the [Outbound guide](/facetime/outbound). ## Connect your own agent to a Chert-operated line A **Chert-operated line** means Chert runs the FaceTime endpoint and media bridge. It does not require you to use a **Chert-managed Assistant**. If you already have a coaching agent, use BYOR: keep your agent and coaching logic in your own LiveKit project. | Resource | Who supplies it? | | - | - | | Provisioned FaceTime handle and bridge | Chert | | LiveKit project, room, and agent runtime | You | | Agent participant token | Your backend, for your agent | | Separate bridge participant token | Your backend, returned to Chert for this call | | Caller audio and video tracks | Chert's bridge publishes them into your room | | Coaching, vision, and responses | Your agent consumes the caller media and publishes its response | You do not need to install or patch the open-source connector to use the Chert-operated service. These pages describe that service's BYOR contract; the open-source connector may have a different setup and feature set. > [!NOTE] > > **No standing credential exchange** > > Keep your LiveKit API key and secret on your backend. Return only a short-lived, room-scoped bridge token. Chert seals these credentials for redemption by the authorized worker command and scrubs them on terminal cleanup. ## Set up inbound calls The steps below use an HTTPS webhook. For inbound audio and video without a webhook, use [`chert dev`](/facetime-cli/quickstart) for a named LiveKit agent or [`chert byor`](/facetime-cli/byor) for your own session callbacks. See the [CLI video guide](/facetime-cli/video) for agent video and caller-camera input. 1. Obtain a Chert project and provisioned FaceTime line. Confirm its worker is accepting calls with fresh readiness. 2. In **Workers & Lines → Integration**, configure your HTTPS webhook and assign the integration. Signing is optional and new integrations default to unsigned; enable signing and save the one-time secret on your backend for authenticated delivery. 3. Use `customer_decides` with no default Assistant. Assigning a published Assistant can select the managed route instead. 4. Prepare your LiveKit room and agent before the decision deadline. When signing is enabled, verify webhook signatures over the raw request body. ## Audio and video For enabled inbound video calls, Chert bridges audio and video in both directions. Outbound currently supports audio only. Your agent receives the caller's microphone and camera as LiveKit tracks, and the caller receives your agent's audio and video in FaceTime. | Direction | LiveKit media | | - | - | | Caller audio → your agent | Audio track: `facetime-remote-audio` | | Caller video → your agent | Video track: `facetime-remote-video` | | Your agent audio → caller | Your agent's published audio track | | Your agent video → caller | Your agent's published video track | For video calls, request `media: { audio: true, video: true }` and subscribe to both tracks from the bridge participant. Publish your agent's audio and video into the same room. Audio-only calls use `video: false` and do not publish caller video. A caller who turns off their camera will not provide live camera frames until they turn it back on. ## Token requirements * Use a secure `wss://` LiveKit URL with no embedded credentials or fragment, and the default port or port 443. * Mint a signed LiveKit JWT with a nonempty `sub` identifying the bridge, distinct from your agent's identity. * Include integer Unix-second `nbf` and `exp` claims. The token must already be valid, unexpired, and have `exp - nbf ≤ 7200` seconds. * Set `video.roomJoin`, `video.canPublish`, and `video.canSubscribe` to `true`, scoped to exactly one room through `video.room`. * Supply a nonempty `remote_participant_identity` distinct from the bridge identity. This is required input, but the current worker selects the sole eligible audio publisher rather than enforcing an exact identity match. Use a fresh bridge token for each call. Single-use redemption refers to Chert's credential handoff; a LiveKit JWT is not inherently a one-use token. Never log the token or include it in browser-facing call records. ## Inbound flow 1. FaceTime rings and Chert sends a `call.incoming` event, signed when your integration enables signing. 2. Verify the signature when enabled and respond with HTTP 2xx and an accept or decline body within the decision deadline (five seconds by default). 3. On acceptance, Chert authorizes the call and joins your room with the bridge token. Your agent must publish usable audio for the audio-ready gate. 4. Monitor call lifecycle events and release your own agent resources when the call ends or fails. Example incoming event; IDs below are illustrative. Call information is nested under `data`. ```json theme={null} { "event_id": "2e3f6bb2-567c-4ce9-94b6-2bdcc0538001", "type": "call.incoming", "created_at": "2026-09-19T19:00:00Z", "data": { "call_id": "c7161775-cd89-44e4-b1b1-93cc9c346b21", "line_id": "a74e0182-9e14-4f0d-a202-d97cd31f8412", "media": { "audio": true, "video": true }, "from_handle_hash": null, "occurred_at": "2026-09-19T19:00:00Z" } } ``` Accept with call-scoped credentials: ```json theme={null} { "action": "accept", "livekit_url": "wss://your-project.livekit.cloud", "participant_token": "", "remote_participant_identity": "your-agent" } ``` To decline, return `{ "action": "decline" }`. Missing, late, or invalid decisions fail closed. A saved webhook URL alone does not prove the receiver works. ## Participants and tracks Give the bridge a unique identity through the token's `sub`. Subscribe to that participant's published tracks using the LiveKit SDK. Do not assume an identity such as `facetime-caller` is assigned automatically. The bridge publishes caller audio as `facetime-remote-audio` and caller video as `facetime-remote-video`. Subscribe to both tracks to hear and see the caller. Handle track publication, subscription, mute, and unpublish events, and inspect track kind rather than relying only on names. For agent-to-caller media, the current worker requires exactly one eligible remote participant with one unmuted published audio track. Silent auxiliary participants may be present. Avoid duplicate audio publishers, including an agent voice track alongside a second avatar voice track. Video-only publication does not satisfy audio readiness. ### Example room and participant layout For one demo call, your backend can create a room named `coaching-demo-001` and use this layout. The names are examples that you choose, not fixed Chert identities. | Participant | Identity | Publishes | Subscribes to | | - | - | - | - | | Your coaching agent | `coach-001` | Agent audio and video | Caller audio and video | | Chert FaceTime bridge | `bridge-001` | `facetime-remote-audio` and `facetime-remote-video` | Agent audio and video | Mint two distinct tokens scoped to this room. Connect your agent using its own token. Mint Chert's bridge token with `sub: "bridge-001"` and the grants above, then return: ```json theme={null} { "action": "accept", "livekit_url": "wss://your-project.livekit.cloud", "participant_token": "", "remote_participant_identity": "coach-001" } ``` In your agent, register track handlers before joining and enable subscriptions to both audio and video. Identify the caller participant by the bridge identity you chose. Feed its audio into your speech pipeline and its video frames into your vision or pose-analysis pipeline. Receiving a video track does not automatically enable vision in an audio-only agent; configure your agent to consume those frames. See LiveKit's [track subscription guide](https://docs.livekit.io/transport/media/subscribe/) and [raw media processing guide](https://docs.livekit.io/transport/media/raw-tracks/) for SDK examples. Use a separate room and agent session for each simultaneous call so teams' media and coaching state remain isolated. A shared LiveKit project can contain those separate rooms. ## Concurrent demos Each provisioned FaceTime line handles one active call. Several teams can reuse one line sequentially; simultaneous demos require separately provisioned, ready lines and enough capacity in your agent runtime. Confirm the number of assigned lines and their handles with Chert before publishing the demo schedule. See [capacity planning](/facetime/limits#capacity). ## Ending and cleanup Either party may hang up. To request an end through the API, send `POST /api/v1/calls/{id}/end` with project authentication. Chert releases the bridge and scrubs its stored room credentials during terminal cleanup. Call records and operational events can remain; token expiry does not mean all records disappear. Your service owns cleanup of its agent, dispatch, and room. # Calls API Source: https://docs.trychert.com/facetime/calls-api Create, inspect, and end FaceTime calls. Use the base URL `https://facetime.trychert.com/api` with [project authentication](/facetime/authentication). ## Endpoints | Method | Path | Purpose | | - | - | - | | `POST` | `/v1/calls` | Request an outbound call | | `GET` | `/v1/calls` | List project calls | | `GET` | `/v1/calls/{callId}` | Read a call | | `GET` | `/v1/calls/{callId}/events` | Read recorded lifecycle events | | `POST` | `/v1/calls/{callId}/end` | Request that a call end | See [BYOR outbound calls](/facetime/outbound) for the complete request body, bridge credentials, and idempotency requirements. ## Call records A successful creation returns HTTP `201` and an envelope containing `data` and `request_id`. Read the new call ID from `data.id`. Creation does not prove that the destination answered or that media is ready. ```bash theme={null} curl "https://facetime.trychert.com/api/v1/calls/$CALL_ID" \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "X-Chert-Project-Id: $PROJECT_ID" ``` ## Lifecycle and cleanup For outbound audio, wait for `bridge_ready` (or the corresponding outbound `call.started` webhook) before starting the greeting. Deduplicate by call ID and ignore activation after termination. Monitor status, lifecycle events, and terminal reason. If your integration receives a call-ended or failure event, release your agent resources. Chert cleans up its bridge and stored room credentials; your service owns its room and agent cleanup. Choose a unique end key (`END_KEY`, 8–200 characters). HTTP `202` acknowledges the request; continue observing until the call is terminal and cleanup completes. ```bash theme={null} curl -X POST "https://facetime.trychert.com/api/v1/calls/$CALL_ID/end" \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "X-Chert-Project-Id: $PROJECT_ID" \ -H "Idempotency-Key: $END_KEY" \ -H "Content-Type: application/json" \ --data-binary '{}' ``` # Core Concepts Source: https://docs.trychert.com/facetime/core-concepts The durable resources behind every FaceTime experience. ## Resource model | Concept | What it is | | - | - | | **Line** | A real FaceTime handle Chert operates. It has a status, answer policy, optional assistant, and one concurrent call. | | **Worker** | The infrastructure unit that operates a line, bridges media, and reports heartbeat and readiness. | | **Assistant** | An AI persona definition: system prompt, greeting, and avatar. Drafts publish into immutable versions. | | **Assistant version** | An immutable published snapshot. Lines resolve the active version at call time. | | **Call** | One FaceTime conversation with direction, state, timings, terminal reason, and rollout metadata. | | **Session** | A live link between a FaceTime call and a LiveKit room. Use a separate room per concurrent BYOR call. | | **Answer policy** | `auto_answer` or webhook-driven `customer_decides`. | | **Readiness** | Named worker health stages. Required readiness checks gate call acceptance. | # Dashboard Guide Source: https://docs.trychert.com/facetime/dashboard Understand the operational home for your Chert project. ## Overview See assistant count, active calls, connected workers, available lines, readiness stages, and recent calls at a glance. ### Readiness by stage Each worker reports Control, FaceTime sign-in, Accessibility, Screen capture, Audio, Video, Avatar, and Production media. Check the stages required for your selected profile and the freshness of their evidence. Audio and video have separate readiness checks. **Production media** is the master gate. > [!WARNING] > > **Availability is not readiness** > > A line can be available because it is not busy while its worker is offline. Check connected workers and heartbeat freshness to confirm it is answerable. ## Assistants * Drafts are editable: name, system prompt, greeting, avatar, and framing. * Publishing creates an immutable version. Publish again to change behavior. * Archive permanently removes an assistant from selection. Duplicate it to revive its configuration. * Voice is currently fixed platform-wide; the displayed selector does not yet affect calls. ## Calls Review direction, mode, status, start time, terminal reason, and, during design-partner rollout, the lifecycle timeline, transcript, and summary. ## Workers & Lines Workers are the infrastructure units operating FaceTime. Lines are the FaceTime handles assigned to them. * `auto_answer` answers inbound calls automatically. * `customer_decides` asks your webhook to accept or decline. * A line resolves the assistant's active published version when each call begins. New publications affect the next call without redeployment. ## Developer Create and revoke project API keys, inspect scopes and last-used timestamps, and copy a project-aware authenticated request example. # Errors and failure semantics Source: https://docs.trychert.com/facetime/errors Diagnose invalid requests, credentials, and failed calls. ## Error response API errors include a machine-readable code, a message, and a request ID. Keep the request ID for troubleshooting, without logging API keys or room tokens. ```json theme={null} { "error": { "code": "invalid_request", "message": "Request validation failed" }, "request_id": "" } ``` ## Common API errors | Code | What to check | | - | - | | `invalid_json` | The request body must be valid JSON. | | `invalid_request` | Check required fields, field types, and UUIDs. | | `idempotency_key_required` | Supply an `Idempotency-Key` of 8–200 characters for call creation. | | `invalid_livekit_url` | Use a secure `wss://` URL on the default port or 443, without embedded credentials or a fragment. | | `invalid_token_grants` | Check the bridge token's room, join/publish/subscribe grants, identity, and validity window. | | `invalid_participant_identity` | Supply a nonempty remote participant identity. | | `session_expired` | Mint a fresh, valid bridge token. | | `not_found` | Check the resource ID and project. | | `invalid_state` | The resource cannot perform this operation in its current state. | For authentication failures, verify both your bearer key and `X-Chert-Project-Id`, including the required read or write scope. ## Call failures A successful API request does not guarantee a completed call. Inspect the call's status, terminal reason, and recorded events. Check worker readiness, webhook response timing, token validity, and the room's audio publishers. Avoid retrying an outbound creation with a new idempotency key until you have checked whether the original request created a call. # FAQ Source: https://docs.trychert.com/facetime/faq Common product and integration questions. > **Can our own agent use a Chert-operated FaceTime line?** > > Yes. Use [BYOR](/facetime/bring-your-own-room). Chert operates the line and bridge; your agent joins your own LiveKit room using a token from your backend. Chert joins with a separate token you provide. A Chert-operated line does not require a Chert-managed Assistant. > **Does our agent receive the caller's camera?** > > Yes, on FaceTime Video calls with caller capture enabled on the assigned line's worker. The bridge publishes `facetime-remote-video` alongside `facetime-remote-audio`. Subscribe to both and connect the video frames to your agent's vision pipeline. This works with the CLI or a webhook; the CLI's video starter publishes a face but does not consume caller video by default. See [Caller vision](/facetime-cli/video#caller-vision). Audio-only calls do not include caller video. > **Does video require a webhook, or can we use the CLI?** > > Both `chert dev` and `chert byor` support inbound FaceTime Audio and Video without a webhook. `chert dev --agent NAME` uses browser login and handles connector enrollment automatically for an eligible line. Custom `chert byor` callbacks use manual enrollment. Only the specialized `chert create-room` adapter is audio-only. See the [CLI video guide](/facetime-cli/video). > **How many teams can call at once?** > > One active call per provisioned line. N independently provisioned, ready lines support up to N simultaneous calls, subject to your agent and LiveKit capacity. Confirm your event's actual line allocation with Chert; teams can share fewer lines through sequential slots. See [Limits and readiness](/facetime/limits). > **Are LiveKit Cloud keys all we need for a demo?** > > You also need Chert project access, a provisioned FaceTime line, and a running agent. With [`chert dev`](/facetime-cli/quickstart), the CLI handles room preparation and call-scoped tokens without a webhook. For your own session lifecycle, use [BYOR callbacks](/facetime-cli/byor) or an HTTPS decision webhook. Configure the agent and line for the required audio/video directions, then rehearse a live call before the demo. > **Does the caller need to install anything?** > > No. Any iPhone, iPad, or Mac with FaceTime works. That is the point. > **Can one assistant serve multiple lines?** > > Yes. Each line resolves the assistant's active published version independently. > **Can I switch a line's assistant mid-day?** > > Yes. Assign another assistant or publish a new version; it takes effect on the next call. Active calls never change mid-call. > **What if my BYOR call.incoming endpoint is down?** > > The call is declined at the response deadline. The caller experiences a normal unanswered or busy call. > **Are audio-only agents supported?** > > Yes. FaceTime Audio uses the same assistant; the avatar is simply not rendered. > **Can I run my own voice or LLM but use Chert's avatar?** > > Not currently. BYOR gives you full control by bringing the entire agent; Managed Assistants uses Chert's managed stack end-to-end. > **How do I get design-partner access?** > > Contact the Chert team to provision a FaceTime line and enable your integration. # Hosted Sessions Source: https://docs.trychert.com/facetime/hosted-sessions Proposed Chert-hosted rooms for customer participants. > [!WARNING] > > Hosted Sessions is a proposed integration model, not a verified production API contract. Use [Bring Your Own Room](/facetime/bring-your-own-room) for the current customer-controlled integration. ## Proposed behavior Chert would create a room and issue a short-lived participant token for your human or software client. This differs from BYOR, where your backend creates the room and supplies a token for Chert's bridge. ## Integrate today Create a room in your own LiveKit project, run your agent, and follow the [BYOR quickstart](/facetime/quickstart). Do not build against earlier illustrative hosted-session endpoints. # FaceTime Source: https://docs.trychert.com/facetime/introduction Connect your agent to real FaceTime calls. Chert connects a provisioned FaceTime line to your agent. With **Bring Your Own Room (BYOR)**, your backend creates a LiveKit room and grants Chert's bridge call-scoped access. You own the agent, room, and conversation. > **[Bring Your Own Room](/facetime/bring-your-own-room)** > > Connect your LiveKit agent to a FaceTime line. > **[Quickstart](/facetime/quickstart)** > > Prepare your line and room for inbound or outbound calls. > **[Outbound](/facetime/outbound)** > > Start a FaceTime Audio call from your backend. > **[Authentication](/facetime/authentication)** > > Create a project key and authenticate server requests. > **[Audio and video](/facetime/audio-video)** > > Understand current media support and acceptance requirements. > **[FaceTime CLI](/facetime-cli/overview)** > > Connect an audio or video agent with chert dev, without a webhook. ## Receive a call For a named LiveKit agent, [use the CLI](/facetime-cli/quickstart) to handle inbound audio and video without building a webhook receiver. If you prefer your own receiver, the webhook flow is: 1. A caller rings your provisioned FaceTime handle. 2. Chert asks your backend to accept or decline. 3. You return a short-lived token for your LiveKit room. 4. Chert joins the room and bridges the call to your agent. 5. When the call ends, both services clean up their resources. > [!NOTE] > > BYOR supports two-way audio and video. Your agent receives the caller's microphone and camera as LiveKit tracks, while the caller sees and hears your agent in FaceTime. ## Designed to fail closed Readiness checks gate call acceptance. A missing, late, or invalid acceptance response declines the call. Monitor call state, media tracks, and terminal reasons throughout the call. ## Place a call Prepare your LiveKit room and silent agent, then send a project-authenticated request with the line ID and recipient to `POST /api/v1/calls`. Chert dials from that line and bridges audio after answer. Start the greeting only on `bridge_ready` or the corresponding outbound `call.started` notification. See [Outbound](/facetime/outbound). Outbound currently supports audio only on enabled lines. ## Other integration models [Managed Assistants](/facetime/assistants) run Chert-managed agent behavior. [Hosted Sessions](/facetime/hosted-sessions) describes a proposed model and is not the current customer-controlled integration. > [!NOTE] > > The FaceTime API uses `https://facetime.trychert.com/api` and FaceTime project credentials. The Messaging API elsewhere on this documentation site has a separate API contract. # Limits and readiness Source: https://docs.trychert.com/facetime/limits Plan call capacity and verify the media you need. ## Capacity A provisioned line handles **one active call at a time**. Multiple teams can share that line sequentially. For simultaneous demos, each call needs its own provisioned, ready line and an isolated agent session. | Provisioned, ready lines | Maximum simultaneous calls, if your agent has capacity | | - | - | | 1 | 1 | | 3 | Up to 3 | | N | Up to N | These are capacity examples, not a promise of how many lines are assigned to your project. Confirm your reserved line count, FaceTime handles, and demo window with Chert. Available concurrency can be lower when a line is busy, offline, or not ready, or when your LiveKit or model-provider limits are reached. ## Plan a multi-team event 1. Decide how many teams must demo simultaneously. 2. Arrange that many independently provisioned lines with Chert, or schedule sequential slots on fewer lines. 3. Assign each team a handle and time slot. Do not assume that one FaceTime handle provides an automatic multi-call pool or queue. 4. Use a separate LiveKit room and agent session per active call. Sharing a project does not require sharing a room. 5. Rehearse all planned simultaneous calls and confirm that each line becomes ready again after hangup. BYOR credentials and the `remote_participant_identity` can be chosen per accepted call, so the same line can serve different team agents at different times. Your webhook owns that selection; use your demo schedule or application context rather than relying on a caller hash as authenticated team identity. ## Operational limits * **Inbound decisions:** respond within the configured decision deadline, five seconds by default. Missing or invalid decisions fail closed. * **Bridge tokens:** include valid integer `nbf` and `exp` claims with `exp - nbf` no greater than 7,200 seconds. * **Audio participants:** provide exactly one eligible remote participant with one unmuted published audio track. Silent auxiliary participants may be present. * **Outbound idempotency:** use an `Idempotency-Key` of 8–200 characters. Reuse it when retrying the same creation request. * **Readiness:** an available line can still have an offline or unready worker. Inspect current worker readiness before testing. * **Video:** video calls carry media in both directions. Subscribe to the caller's video track and handle camera mute or track removal; audio-only calls do not include video. See [BYOR](/facetime/bring-your-own-room) for credentials and media requirements. # Outbound Source: https://docs.trychert.com/facetime/outbound Start a FaceTime Audio call from your backend using your own LiveKit agent. Your backend prepares a LiveKit room and agent, then sends an authenticated request to `POST https://facetime.trychert.com/api/v1/calls`. Chert uses the line you select to dial the recipient and connect the call to your room. Outbound BYOR currently supports **audio only** on enabled, ready lines. Use `media: { audio: true, video: false }`. Inbound readiness alone does not mean a line can place outbound calls. ## Choose the project and line | Request value | What it does | | - | - | | Project API key | Authorizes your backend. Use `facetime:write` for create/end and `facetime:read` for status/events. | | `X-Chert-Project-Id` | Selects the project that owns the line. | | `line_id` | Selects the provisioned FaceTime line and its associated worker (Mac). Chert does not fall back to another line if it is unavailable. | | `destination` and `destination_type` | Identify the recipient's email address or phone number. | | LiveKit URL, bridge token, and agent identity | Tell Chert which room to join and provide call-scoped access. | Get your project and line IDs from the dashboard or [list your lines](/facetime/quickstart#connectivity). Keep the project key and LiveKit credentials on your backend. Inbound calls ask your webhook to accept or decline. **Outbound calls start with your API request; no incoming-decision webhook is needed.** You can observe events through the API or use an assigned integration for lifecycle notifications. ## 1. Prepare the room and agent * Join your agent to the room with exactly one eligible, unmuted audio publication, but keep it silent until the activation event below. * Mint a separate bridge token with a distinct identity and permission to join, publish, and subscribe to this one room. * Follow the [token requirements](/facetime/bring-your-own-room#token). The bridge token must have **more than 90 seconds remaining** when the call is allocated, with an issued lifetime of at most two hours. Allow enough time for ringing and conversation. * Disable any greeting triggered by room join. The bridge joins **before** the recipient answers. ## 2. Create the call Use your own project and line IDs. The values below are placeholders. ```http theme={null} POST https://facetime.trychert.com/api/v1/calls Authorization: Bearer X-Chert-Project-Id: Idempotency-Key: Content-Type: application/json { "direction": "outbound", "mode": "customer_managed", "line_id": "", "destination": "recipient@example.com", "destination_type": "email", "media": { "audio": true, "video": false }, "livekit_url": "wss://your-project.livekit.cloud", "participant_token": "", "remote_participant_identity": "your-agent" } ``` For a phone recipient, use `destination_type: "phone"` and an international number such as `+15551234567`. HTTP **201 means allocated**, not dialed or answered. Save the returned `data.id` as your call ID. Retry the same request with the same idempotency key to retrieve the original call. Materially different call or room fields with that key return **409**. If a response is lost, do not switch to a new key and risk another call. ## 3. Wait for answer, then greet With the same project authentication, observe: ```http theme={null} GET /api/v1/calls/ GET /api/v1/calls//events ``` **Start the greeting on `bridge_ready`**, or the corresponding outbound `call.started` webhook when an integration is configured. Room join, HTTP 201, and an `active` status alone are not the signal to speak. Greet only once per call ID and ignore activation after the call ends or fails. Subscribe to the bridge's caller audio and publish your agent's replies into the room. ## 4. End and clean up Either party can hang up. To end through the API, use a separate idempotency key: ```http theme={null} POST https://facetime.trychert.com/api/v1/calls//end Authorization: Bearer X-Chert-Project-Id: Idempotency-Key: Content-Type: application/json {} ``` HTTP **202 acknowledges the end request**. Continue observing until the call is terminal and the line is available again. Chert cleans up its bridge and stored credentials; your backend must release its own agent, dispatch, and room resources, including after failures. # Quickstart Source: https://docs.trychert.com/facetime/quickstart Receive or place a FaceTime call with your own LiveKit agent. ## What to have ready * A Chert project and provisioned FaceTime line, including its handle and line ID. * Your LiveKit project, WebSocket URL, and server-side API key and secret. * A backend that prepares a room and mints separate tokens for your agent and Chert's bridge. * An agent that publishes audio and consumes the caller's audio. For inbound video, also publish and subscribe to video. * A person ready to place or answer the test call. Each line handles one active call at a time. Chert operates the FaceTime endpoint; you do not need to install the open-source connector. For inbound calls, use an acceptance webhook or the [CLI](/facetime-cli/quickstart). `chert dev` and `chert byor` support audio and video without a webhook; the inbound steps below describe the webhook route. Outbound calls use an authenticated API request and require an outbound-enabled line. ## 1. Get your project key Open **Developer → Project API keys**. Create a key with `facetime:write` for create/end and `facetime:read` for status/events. Store it on your backend and copy the Project ID. ```bash theme={null} export CHERT_API_KEY="" export PROJECT_ID="" export BASE=https://facetime.trychert.com/api ``` ## 2. Choose your line ```bash theme={null} curl "$BASE/v1/lines" \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "X-Chert-Project-Id: $PROJECT_ID" ``` Confirm the line's worker is accepting calls with fresh readiness in **Workers & Lines**. For outbound, confirm that this line is enabled for outbound audio, then use its exact ID as `line_id`. Chert does not fall back to another line when it is unavailable. ## 3. Prepare your LiveKit agent Run your agent in your room with exactly one eligible, unmuted audio publication. Mint a separate, room-scoped bridge token with join, publish, and subscribe grants and an identity distinct from your agent. See the [token requirements](/facetime/bring-your-own-room#token). For outbound, keep the agent silent until `bridge_ready`. The bridge token needs more than 90 seconds remaining at allocation and an issued lifetime of at most two hours. ## 4. Choose a calling direction **Inbound** Set the line to `customer_decides` with no default Assistant. Configure its HTTPS Integration URL. If you enable webhook signing, store the signing secret on your backend and verify incoming signatures. On `call.incoming`, return an accept response within the decision deadline (five seconds by default): ```json theme={null} { "action": "accept", "livekit_url": "wss://your-project.livekit.cloud", "participant_token": "", "remote_participant_identity": "your-agent" } ``` Call your provisioned handle using FaceTime Audio, or FaceTime Video on a video-enabled line. The [BYOR guide](/facetime/bring-your-own-room#inbound) covers the incoming event and decline response. **Outbound** Your backend sends the request; no incoming-decision webhook is needed. Use audio only and save this body as a protected local file named `outbound-call.json`, replacing the placeholders: ```json theme={null} { "direction": "outbound", "mode": "customer_managed", "line_id": "", "destination": "recipient@example.com", "destination_type": "email", "media": { "audio": true, "video": false }, "livekit_url": "wss://your-project.livekit.cloud", "participant_token": "", "remote_participant_identity": "your-agent" } ``` Send it with a unique create key (8–200 characters): ```bash theme={null} curl --fail-with-body "$BASE/v1/calls" \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "X-Chert-Project-Id: $PROJECT_ID" \ -H "Idempotency-Key: $CREATE_KEY" \ -H "Content-Type: application/json" \ --data-binary @outbound-call.json ``` Set `CREATE_KEY` before running the command. HTTP `201` means allocated, not answered. Save `data.id` as `CALL_ID`. Retry an uncertain request with the same body and key; do not create a new attempt automatically. Poll `GET /api/v1/calls//events` with the same project headers. Start the greeting once on `bridge_ready`, or the corresponding outbound `call.started` webhook. Room join or an `active` status alone is insufficient. Ignore activation after termination. See [Outbound](/facetime/outbound) for phone destinations, line selection, and the full flow. ## 5. Verify the call and cleanup Confirm clear two-way audio. For inbound video, also confirm that each side sees the other's current video. Subscribe to the bridge's `facetime-remote-audio` track and, when enabled, `facetime-remote-video`. Hang up from either end, or end through the API using the call ID and a unique end key (8–200 characters): ```bash theme={null} curl --fail-with-body -X POST "$BASE/v1/calls/$CALL_ID/end" \ -H "Authorization: Bearer $CHERT_API_KEY" \ -H "X-Chert-Project-Id: $PROJECT_ID" \ -H "Idempotency-Key: $END_KEY" \ -H "Content-Type: application/json" \ --data-binary '{}' ``` Set `END_KEY` before running the command. HTTP `202` acknowledges the request. Check that FaceTime ends, the call becomes terminal, and the line becomes available again. Your backend must also release its agent and room resources. See [Calls API](/facetime/calls-api). # Security Source: https://docs.trychert.com/facetime/security Keep API keys and call-scoped room credentials on your backend. ## API keys Use project-scoped FaceTime API keys from your backend. Keys are displayed once, stored as hashes, and can be revoked. Use read scopes for read-only access and write scopes for mutations. ## Webhooks Signing is optional and new integrations default to unsigned. Enable signing and verify the HMAC-SHA256 signature over the raw request body. Store the signing secret separately from your API key. See [Webhooks](/facetime/webhooks). ## Bring Your Own Room Keep your LiveKit API key and secret on your backend. Give Chert only a fresh room-scoped participant token for each call. Chert seals those credentials, restricts redemption to the authorized worker command, and scrubs them during terminal cleanup. Single-use credential redemption does not make the underlying LiveKit JWT inherently single-use. Restrict its permissions and validity window, and do not log it. ## Caller identity The incoming webhook exposes `from_handle_hash`, which may be null, rather than promising the raw caller handle. Do not treat this value as proof of a caller's identity. ## Retained records Call records and operational events may remain after room credentials are removed. Token expiry is not a guarantee that all call data has been deleted. Confirm retention and any transcript requirements with Chert before collecting sensitive conversation data. ## Hosted Sessions Hosted Sessions is proposed. Use the documented BYOR credential flow for the current integration. # Webhooks Source: https://docs.trychert.com/facetime/webhooks Receive call lifecycle events with optional signature verification. In **Workers & Lines → Integration**, enter the webhook URL and select **Update**. New integrations default to unsigned delivery. Enable signing to authenticate deliveries, configure your receiver with the one-time secret, and complete the signing setup before assigning the integration. Other lines and existing calls keep their original webhook. A saved URL does not verify delivery. ## Event types | Event | Fires when | Notes | | - | - | - | | `call.incoming` | An inbound call is detected | Your response accepts or declines customer\_decides calls within five seconds | | `call.started` | The call is answered and media is bridged | | | `call.ended` | A call ends | Includes duration and terminal\_reason | | `call.failed` | A call cannot be completed | Includes a named failure reason | ## Signature verification When signing is enabled, each delivery includes `X-Chert-Signature: sha256=`, the HMAC-SHA256 of the raw request body. ```javascript theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; const expected = "sha256=" + createHmac("sha256", WEBHOOK_SECRET) .update(rawBody) .digest("hex"); const actual = Buffer.from(signatureHeader ?? "", "utf8"); const wanted = Buffer.from(expected, "utf8"); if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) { throw new Error("Invalid webhook signature"); } ``` ## Delivery semantics * Delivery is at least once; deduplicate on `event_id`. * Timestamps are ISO-8601 UTC. * Non-2xx responses retry with backoff. * `call.incoming` does not retry past its decision deadline and fails closed. # Architecture Source: https://docs.trychert.com/hubspot/architecture Component map, send and reply sequences, and OAuth token refresh handling. Component map, call graph, and synchronous-versus-asynchronous behavior of the integration. ## System Landscape ![System landscape](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/hubspot/diagrams/system-landscape.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=266877505376198cab3ec6a3c948d163) A connected HubSpot account issues outbound HTTPS to the Chert messaging service from three places — the sidebar UI extension on a contact record, the workflow custom action, and the `contact.creation` webhook. Chert dispatches the send through its delivery infrastructure and posts replies back into HubSpot as Communication engagements on the contact timeline. There is no shared infrastructure between HubSpot accounts. Every request is verified against the per-account signing secret before any work runs. ## Components ### Server routes | Route | Method | Surface | Auth | | - | - | - | - | | `/api/hubspot/oauth/install` | GET | Browser entry | Signed state cookie | | `/api/hubspot/oauth/callback` | GET | OAuth redirect target | Signed state cookie + code exchange | | `/api/hubspot/uninstall` | POST | `app.uninstalled` webhook | HubSpot Signature v3 | | `/api/hubspot/webhook` | POST | `contact.creation` webhook | HubSpot Signature v3 | | `/api/hubspot/send` | POST | Workflow custom action callback | HubSpot Signature v3 | | `/api/hubspot/widget/context` | GET | Sidebar card load | HubSpot Signature v3 | | `/api/hubspot/widget/send` | POST | Sidebar card send | HubSpot Signature v3 | | `/api/hubspot/widget/campaign-status` | GET | Sidebar card campaign tab | HubSpot Signature v3 | ### Server modules | Module | Responsibility | | - | - | | OAuth | Build the install URL, exchange the authorization code, refresh the access token with a 60-second leeway. | | Tenants | Lookup of per-account install state by HubSpot account ID. | | Verify | HubSpot Signature v3 validation with a 5-minute timestamp skew bound. | | Contact resolve | Match a HubSpot contact to a Chert lead by normalized phone. Auto-create on workflow paths when the account opts in. | | Push inbound | Create a Communication engagement on the contact when a reply arrives. Per-account circuit breaker after five consecutive failures. | ### Data model | Table | Purpose | | - | - | | `hubspot_tenants` | One row per connected HubSpot account. Holds OAuth tokens, the bound Chert project, the phone line override, auto-create and auto-send flags, and the inbound circuit-breaker counter. | | `hubspot_send_events` | Append-only delivery log keyed by tenant and idempotency key. Powers dedup, retry-safe responses, and the admin dashboard. | | `leads`, `convo`, `campaigns`, `phone_lines` | Existing Chert tables. The integration is a thin adapter on top. | ## Outbound Send Sequence ![Send sequence](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/hubspot/diagrams/send-sequence.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=e9ce2da459f5dcc5919b432c6b38abd2) 1. A workflow, the sidebar card, or the contact-creation webhook hits a Chert ingress route. 2. The route reconstructs the full request URL and verifies the HubSpot Signature v3 header against the timestamp and the raw body. 3. The route loads the tenant by HubSpot account ID. Inactive tenants short-circuit with `unknown or inactive tenant`. 4. The route checks the idempotency key in `hubspot_send_events`. A prior row returns its stored response verbatim. 5. The route resolves a Chert lead. Sidebar sends always auto-create; workflow sends auto-create only when the per-account flag is on. 6. The route renders the message. The sidebar passes the operator's text through unchanged. The workflow action passes a token-rendered string from HubSpot's workflow editor. The contact-creation webhook renders the project's CRM template against the new contact's name and company. 7. The route hands off to Chert's internal send pipeline, which selects a phone line, enforces the 10-minute minimum gap, deduplicates within a 6-hour rolling window, and dispatches through Chert's delivery infrastructure. 8. The route writes the outcome to `hubspot_send_events` and returns the response shape required by the caller. End-to-end latency in the synchronous path is dominated by Chert's internal pipeline; typical observed latency is 200 to 600 ms. ## Inbound Reply Sequence ![Reply sequence](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/hubspot/diagrams/reply-sequence.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=f6fc3dedc77001764885476b92e11957) 1. A recipient replies. Chert's internal reply pipeline resolves the lead and stamps the inbound message on its conversation log. 2. The reply-notification cron picks up the new inbound and fans out to email, Slack, the customer webhook (if configured), and the HubSpot push. 3. The HubSpot push loads the tenant and confirms the lead carries a `hubspot_tenant_id` and `hubspot_contact_id`. Without both, the push is a no-op. 4. The push obtains a valid access token, refreshing if the cached token is within 60 seconds of expiry. 5. The push POSTs a Communication engagement to `/crm/v3/objects/communications` with `hs_communication_channel_type = SMS`. 6. The push associates the new Communication to the contact via `/crm/v4/objects/communications/{id}/associations/default/contacts/{contactId}`. The default association resolves the correct association type ID server-side. 7. On success, the per-tenant `inbound_consecutive_failures` counter resets and `inbound_last_success_at` is stamped. On failure, the counter increments. Five consecutive failures trip the breaker and silence the push until the next successful send through the tenant. The reply path is fire-and-forget from the cron's perspective — a dead HubSpot account never blocks operator email or Slack notifications. ## OAuth Token Refresh HubSpot access tokens expire after roughly 30 minutes. Chert never refreshes ahead of time on a schedule; instead, every code path that calls HubSpot acquires a token through the same helper. | Step | Detail | | - | - | | 1 | Read the cached access token and expiry from the tenant row. | | 2 | If expiry is more than 60 seconds in the future, return the cached token. | | 3 | Otherwise, exchange the refresh token at HubSpot's OAuth token endpoint. | | 4 | Persist the new access token, refresh token, and expiry in `hubspot_tenants`. HubSpot rotates the refresh token on every refresh; storing the new value is required for subsequent calls to succeed. | | 5 | Return the new access token. | A 401 response from HubSpot triggers a single one-shot refresh and retry, in case the cached token expired between read and call. ## Synchronous versus Asynchronous Boundaries | Action | Where it runs | | - | - | | Sidebar card load (`/widget/context`) | Synchronous. Returns within one HTTP roundtrip. | | Sidebar card send (`/widget/send`) | Synchronous up to the dispatch handoff. | | Workflow custom action (`/send`) | Synchronous. HubSpot waits for the response and reads `outputFields`. | | Contact creation webhook (`/webhook`) | Synchronous up to dispatch. Always returns 200 to HubSpot to avoid retries on per-event errors. | | Reply push to HubSpot | Asynchronous. Triggered by the reply-notify cron, never blocks operator notifications. | | OAuth token refresh | Lazy. Triggered on the first call that finds the cached token within 60 seconds of expiry. | ## Idempotency | Path | Idempotency key | Window | | - | - | - | | Workflow custom action | HubSpot's `callbackId` | Forever — same `callbackId` always returns the prior response. | | Sidebar card send | `widget:{tenantId}:{contactId}:{secondBucket}` | One second. Collapses double-clicks; allows operator retries the next second. | | Contact creation webhook | `webhook:contact_creation:{objectId}` | Forever — same contact creation event never re-fires. | Beneath these per-route keys, Chert's internal send pipeline applies its own 6-hour rolling dedup on `(phone_line, recipient, message)`. ## See Also * [Configuration](/hubspot/configuration) — settings exposed in the Chert console. * [Security](/hubspot/security) — signature verification, token storage, scope rationale. * [Limits](/hubspot/limits) — rate limits, daily caps, retry semantics. # Configuration Source: https://docs.trychert.com/hubspot/configuration Bind a Chert project, choose a phone line, configure auto-send, and map contact properties. Configure a connected HubSpot account from the Chert console. Every setting below is per HubSpot account; multiple Chert projects each bound to a different HubSpot account run independently. ## Before You Begin | Requirement | How to verify | | - | - | | The HubSpot account is connected | **Settings → Integrations** in the Chert console shows the account ID and a green **Connected** badge. | | At least one phone line is healthy in the bound project | The project's Phone Lines page shows one or more lines with status `active`. | | The project's CRM campaign has a message template | **Settings → Template** is non-empty. | ## Settings Open **Settings → Integrations** in the Chert console and click the connected HubSpot account. Each setting persists immediately on save. | Setting | Default | Effect | | - | - | - | | **Bound Chert project** | The project the install was launched from | All sends from this HubSpot account land in this project's lead pool. Re-pointing requires a disconnect and reconnect. | | **Phone line** | Pool default | The phone line that sends from. Leave on **Pool default** to let Chert pick from the project's healthy lines on each send. | | **Auto-create leads on workflow send** | On | When a workflow action fires for a contact whose phone is not in Chert yet, create a lead before sending. Off makes the workflow action no-op for unknown contacts. | | **Auto-send on contact creation** | Off | When a contact is created in HubSpot, fire an iMessage immediately using the project's CRM template. | | **Auto-send template override** | Empty | Optional template used by auto-send on contact creation only. When empty, falls back to the project's CRM template. | | **Label** | HubSpot account ID | Display label shown on the admin dashboard. Cosmetic only. | > [!NOTE] > > The bound project is locked after install on purpose — silently > re-pointing an active integration would move conversation history > between projects mid-flight. Disconnect and reinstall from the > destination project to move. ## Contact Phone Mapping Chert reads two HubSpot contact properties when resolving a phone number, in priority order. | Order | Property | Notes | | - | - | - | | 1 | `phone` | The default HubSpot phone property. Preferred. | | 2 | `mobilephone` | Used when `phone` is empty. | Numbers are normalized to E.164 before matching. A contact whose `phone` is `(415) 555-2671` matches a Chert lead whose number is `+14155552671`. There is no UI to remap these properties. If your account uses a custom property for the primary phone, copy it into `phone` through a HubSpot workflow before triggering a Chert send. ## Webhook URL The OAuth public app declares its webhook URL once at the app level. Customers do not configure a webhook URL — the install flow wires the account up to the right URL automatically. | Subscription | Endpoint | Purpose | | - | - | - | | `app.uninstalled` | `https://console.trychert.com/api/hubspot/uninstall` | Marks the tenant inactive when the customer uninstalls from HubSpot. | | `contact.creation` | `https://console.trychert.com/api/hubspot/webhook` | Optional auto-send on new contact. | Both endpoints verify the HubSpot Signature v3 header on every request. See [Security](/hubspot/security) for the algorithm. ## Workflow Custom Action The **Send iMessage via Chert** action accepts these inputs in HubSpot's workflow editor. | Input | Required | Notes | | - | - | - | | **Phone** | Yes | Map from the contact's `phone` or `mobilephone` property. | | **Message** | Yes | Free text. HubSpot's token picker resolves contact tokens before sending. | | **Name** | No | Persisted on the lead row when auto-creating. | | **Company** | No | Persisted on the lead row when auto-creating. | The action returns these output fields, available downstream in the same workflow. | Output | Type | Meaning | | - | - | - | | `ok` | boolean | `true` if the send was accepted by Chert. | | `lead_id` | string | The Chert lead ID, set even on dedup. | | `message_id` | string | Stable Chert message ID for the send. | | `duplicate` | boolean | `true` when this callback was deduped against an earlier execution. | | `error` | string | Present only when `ok` is false. Human-readable; do not branch on it. | | `code` | integer | Present only when `ok` is false. Numeric error code from the shared [Messaging API error scheme](/api/errors) — branch on this. | | `message` | string | Present only when `ok` is false. Generic human-readable detail; provider names and raw errors are not leaked here. | | `retryable` | boolean | Present only when `ok` is false. `true` when a backed-off retry is worthwhile. | | `trace_id` | string | Present only when `ok` is false. Correlation id for support; the full failure detail is logged server-side under this id. | > [!NOTE] > > The custom action **always** returns HTTP `200` — HubSpot retries non-200 > responses, so logical failures are signalled in `outputFields` with > `ok: false` rather than via the HTTP status. Branch your workflow on > `outputFields.ok` (and `outputFields.code` for the failure class). ## Auto-Send on Contact Creation When **Auto-send on contact creation** is on, every contact created in the bound HubSpot account is checked against three conditions before Chert fires a send. | Condition | If false | | - | - | | The contact has a phone number on `phone` or `mobilephone` | Skip. Logged as `no_phone`. | | **Auto-create leads** is on | Skip. Logged as `auto_create_disabled`. | | The same `objectId` has not already triggered a send | Skip. Logged as `dedup`. | The send uses the **Auto-send template override** if set, otherwise the project's CRM template. The phone line resolves the same way as manual sends — the per-account override first, then the project's default phone line. ## See Also * [Architecture](/hubspot/architecture) — how each setting flows through the runtime. * [Limits](/hubspot/limits) — daily caps, the 10-minute minimum gap, dedup window. * [Security](/hubspot/security) — what is logged, where credentials live. # Install Source: https://docs.trychert.com/hubspot/install Connect your HubSpot account to Chert through the OAuth install flow. Connect Chert to your HubSpot account in under a minute. The install is fully OAuth-driven — there is no package to upload and no admin SQL to run on the HubSpot side. ## Before You Begin | Requirement | How to verify | | - | - | | You are a HubSpot Super Admin or have the **App Marketplace Access** permission | In HubSpot, open **Settings → Users & Teams**, find yourself, and confirm the permission. | | You have an active Chert project | Open `https://console.trychert.com` and confirm the project you want to send from. | | At least one healthy phone line is provisioned for the project | Chert support provisions phone lines during onboarding. | | Outbound HTTPS to `console.trychert.com` is reachable from HubSpot | This is the default for HubSpot-hosted webhooks and UI extensions. No allowlisting is required. | ## Scopes Requested When you click **Install**, HubSpot prompts you to approve the scopes below. Each scope is the minimum required to make a feature work. | Scope | Why Chert needs it | | - | - | | `oauth` | Issue and refresh access tokens for your account. | | `crm.objects.contacts.read` | Read a contact's `phone`, `mobilephone`, `firstname`, `lastname`, and `company` when you open the sidebar card or trigger a workflow. | | `crm.objects.contacts.write` | Create Communication engagements on the contact timeline when a reply arrives. | | `crm.schemas.contacts.read` | Resolve the contact object schema so reads work across portals with custom property layouts. | | `automation` | Register the **Send iMessage via Chert** workflow custom action and accept callbacks from it. | Chert does not request access to deals, companies, tickets, or any custom object. ## Install the App **1. Open the Chert console** Sign in at `https://console.trychert.com` and open the project you want HubSpot to send from. **2. Open Settings → Integrations** Click **Settings** in the top nav, then scroll to the **Integrations** section. **3. Click Connect HubSpot** Chert redirects you to HubSpot's OAuth approval screen. **4. Pick the HubSpot account and approve** HubSpot lists every account you have access to. Pick the account you want to bind to this Chert project, review the scopes, and click **Connect app**. **5. Return to Settings** HubSpot redirects back to the Chert console with the new install confirmed. The Integrations section now shows the bound HubSpot account ID, the granted scopes, and a **Disconnect** button. ## Verify the Install **1. Open a contact in HubSpot** Open any contact that has a phone number in the `phone` or `mobilephone` property. **2. Locate the Chert iMessage card** The card appears in the right sidebar of the contact record. The first load takes a moment while the card fetches conversation history. **3. Send a test message** Type a short message in the conversation tab and click **Send**. The bubble appears immediately in the card, and the same message appears in the Chert console for the bound project within a few seconds. **4. Confirm the workflow action is available** In HubSpot, open **Workflows**, create a contact-based workflow, and click the **+** to add an action. **Send iMessage via Chert** appears in the action picker under the **Chert** group. ## Reinstall and Re-authorize Re-running the install flow against an account that already has Chert installed rotates the access and refresh tokens. The bound Chert project is preserved — a re-install never silently moves an account between projects. To move an account between projects, disconnect first, then run the install flow from the destination project. ## Uninstall You can disconnect from either side; both end states are equivalent. | From | Result | | - | - | | Chert console (**Settings → Integrations → Disconnect**) | The HubSpot tenant is marked inactive in Chert. The sidebar card stops loading; workflow actions return an error. | | HubSpot connected apps (**Settings → Integrations → Connected Apps → Uninstall**) | HubSpot fires an `app.uninstalled` webhook to Chert. Chert marks the tenant inactive. | Disconnecting does not delete conversation history. Reconnecting the same account flips the tenant back to active and resumes sending. ## Troubleshoot Installation | Symptom | Resolution | | - | - | | OAuth approval screen shows "App not found" | Confirm the install link is current. Old links from before a Chert client ID rotation no longer resolve. | | Callback returns an OAuth state error (`code 2014`, `reason: state_mismatch` / `missing_state_cookie` / `invalid_state_cookie` / `missing_code_or_state`) | The install state cookie expired (10-minute TTL), is missing, or was tampered with. Click **Connect HubSpot** again to restart the flow. The callback body carries a stable short `reason` slug plus a `trace_id`. | | Sidebar card shows "unknown or inactive tenant" | The HubSpot account was disconnected. Reconnect from the Chert console. | | Workflow action does not appear | The workflow custom action requires the `automation` scope. Re-install if the scope was not granted. | ## See Also * [Configuration](/hubspot/configuration) — bind a Chert project, pick a phone line, set up auto-send. * [Security](/hubspot/security) — scope rationale, signature verification, token storage. * [Limits](/hubspot/limits) — rate limits, daily caps, retry semantics. # Limits Source: https://docs.trychert.com/hubspot/limits HubSpot API rate limits, daily send capacity, retry semantics, and dedup windows. The integration is bound by three independent rate limits — HubSpot's API limits on outbound calls Chert makes into your account, Chert's own per-project send capacity, and HubSpot's webhook delivery guarantees. ## Before You Begin | Concept | Definition | | - | - | | HubSpot daily limit | The 24-hour rolling cap on API calls to your HubSpot account, set by your HubSpot edition. | | HubSpot burst limit | The 10-second-window cap that protects HubSpot from spikes; usually 100 to 190 calls per 10 seconds depending on edition. | | Chert daily cap | The per-project per-day cap on first-touch sends, configured in **Settings → Auto-dial**. | | Idempotency key | A stable token that lets a retry of the same logical operation return the prior result instead of re-sending. | ## HubSpot API Calls Made by Chert Each integration surface consumes a known number of HubSpot API calls. | Surface | Per event | Endpoints | | - | - | - | | Sidebar card load | 1 | `GET /crm/v3/objects/contacts/{id}` | | Sidebar card send | 1 | `GET /crm/v3/objects/contacts/{id}` | | Workflow custom action | 0 | The action callback delivers the data; Chert does not read back. | | Contact creation auto-send | 1 | `GET /crm/v3/objects/contacts/{id}` | | Reply push | 2 | `POST /crm/v3/objects/communications`, `PUT /crm/v4/objects/communications/{id}/associations/default/contacts/{contactId}` | | OAuth token refresh | 1 | `POST /oauth/v1/token` (does not count against CRM daily limit) | A typical operator who opens 50 contact records, sends from 10 of them, and receives 10 replies consumes roughly 80 HubSpot API calls per day. ## Chert Send Capacity | Limit | Default | Configurable | | - | - | - | | Project daily cap | Set per project during onboarding | Yes, in the Chert console | | Per-phone-line minimum gap | 10 minutes between first-touch sends | No | | Per-phone-line first-touch cap | 30 sends per phone line per day | No | | Sending window | Per project, in the project's timezone | Yes | | Auto-dial enabled | Off until configured | Yes | When the daily cap is reached, the workflow action returns `ok = false` with `error = "daily cap reached"`. The sidebar card returns the same shape and renders an inline notice. The contact creation webhook logs the event with status `failed` and skips the send. Every HubSpot-facing failure body now also carries a numeric `code`, a generic human `message`, a `retryable` boolean, and a `trace_id` alongside `ok: false` / `error`. The numeric `code` follows the shared [Messaging API error scheme](/api/errors). All of these routes return HTTP `200` for logical failures — see Retry Semantics below. ## Retry Semantics ### From HubSpot to Chert All four HubSpot-facing routes return HTTP `200` for **every** logical failure — including signature verification failures — so HubSpot's retry-on-non-2xx behavior never fires on a per-event error. The failure is signalled in the body (`ok: false` with `code` / `message` / `retryable` / `trace_id`, or `outputFields.ok: false` for the workflow action). Branch on the body, not the HTTP status. | Surface | HubSpot retry behavior | Chert response | | - | - | - | | `app.uninstalled` webhook | Retries on 5xx with exponential backoff, up to roughly 24 hours. | Always 200 OK. Success and logical failures (including signature rejection) both return `200` with `ok` reflecting the outcome. | | `contact.creation` webhook | Retries on 5xx; one delivery per retryable failure. | Always 200 OK to discourage retries on per-event failures. Per-event errors are logged with status `failed` and returned in the body with `ok: false` + `code`. | | Workflow custom action (`/send`) | Retries on 5xx; the workflow execution itself stalls until the action returns. | Always 200 OK with `outputFields.ok` reflecting the actual outcome. The workflow author branches on `ok` / `code`, not on HTTP status. (Previously this route returned non-200 on some failures, which broke HubSpot's contract — now fixed.) | | Sidebar UI extension | No retry — the operator clicks again. | Always 200 OK with the `ok` flag plus `code` / `message` / `retryable` / `trace_id` on failure. | ### From Chert to HubSpot | Path | Retry behavior | | - | - | | Reply push | One inline retry on 401 after a forced token refresh. No further retries from this code path. The reply-notify cron re-runs every two minutes; transient HubSpot failures retry on the next tick. | | Reply push circuit breaker | Five consecutive failures trip the breaker for the tenant. Subsequent pushes are skipped until a successful push resets the counter. | | OAuth token refresh | One attempt per call site. A persistent refresh failure surfaces to the caller. | ### Inside the Chert pipeline | Path | Retry behavior | | - | - | | Outbound send dispatch | Handled by the Chert delivery pipeline, transparent to the integration. | | Lead resolve | No retry. Failure is returned to the caller as a structured error. | ## Idempotency | Source | Key | Effect on retry | | - | - | - | | Workflow custom action | HubSpot's `callbackId` | Same callback always returns the prior response. | | Sidebar card send | `widget:{tenantId}:{contactId}:{secondBucket}` | Double-clicks within the same second collapse to one send; the next second is a fresh attempt. | | Contact creation webhook | `webhook:contact_creation:{objectId}` | The same contact creation event never re-fires. | | Chert internal pipeline | `(phone_line, recipient, message)` for six hours | Identical messages within six hours dedup at the dispatch layer. | A response carrying `duplicate: true` is a successful outcome and should be treated identically to `duplicate: false` by downstream workflow branches. ## Sandbox versus Production HubSpot's developer sandbox accounts are first-class for this integration. The same install flow, scopes, and signature scheme apply. Differences to plan for: | Concern | Sandbox | Production | | - | - | - | | Rate limit | Lower than production | Edition-dependent; see HubSpot docs | | Webhook delivery | Delayed under load | Real-time | | Phone line | Use a Chert test project with test phone lines | Use the production project | | Workflow execution | Manual triggering only | Triggered by real contact events | Chert provisions a separate tenant per HubSpot account regardless of sandbox or production status. A sandbox install does not consume a production install slot. ## See Also * [Architecture](/hubspot/architecture) — where each limit applies in the call graph. * [Configuration](/hubspot/configuration) — daily cap and sending window settings. * [Security](/hubspot/security) — what is logged for retried events. # Overview Source: https://docs.trychert.com/hubspot/overview Chert Messaging for HubSpot — OAuth public app that sends iMessage from HubSpot contacts and posts replies onto the contact timeline. Chert for HubSpot connects one HubSpot account to one Chert project. Teams can send iMessage from the contact sidebar, from a contact-based workflow, or from an optional contact-created automation. Replies are pushed back to the contact timeline as Communication engagements. ![HubSpot integration landscape](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/hubspot/diagrams/system-landscape.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=266877505376198cab3ec6a3c948d163) ## When to use it Use the HubSpot app when HubSpot is the source of contact workflows and reps need message history visible on the contact record. | | | | - | - | | **Best fit** | Sales or recruiting teams already routing work through HubSpot contacts and workflows. | | **Automation fit** | Contact-based workflows should send templated iMessages. | | **Not for** | Deals, companies, tickets, or custom objects. Chert only requests contact-related access. | ## What gets connected | Item | Where it appears | What it does | | - | - | - | | OAuth public app | HubSpot Connected Apps | Stores access and refresh tokens for the bound Chert project. | | Contact sidebar card | Contact record right rail | Sends iMessage, shows conversation status, and loads campaign context. | | Workflow custom action | Contact-based workflow builder | Sends a templated iMessage when a contact reaches the action. | | Contact-created webhook | App webhook subscription | Optionally sends a project template when a new contact is created. | | App-uninstalled webhook | App webhook subscription | Marks the tenant inactive when the customer disconnects. | Nothing is added to the contact schema. No custom properties or associations are created in the customer's account. ## Edition support | HubSpot edition | Sidebar sends | Workflow action | Auto-send on contact create | | - | - | - | - | | Free CRM | Yes | No | No | | Starter | Yes | No | Yes | | Professional | Yes | Yes | Yes | | Enterprise | Yes | Yes | Yes | Workflow actions require an edition with contact-based workflows. ## Minimal workflow example Create a contact-based workflow and add **Chert -> Send iMessage via Chert** after your enrollment trigger. | Field | Example | | - | - | | Enrollment | Contact property `Lifecycle stage` becomes `Lead`. | | Message | `Hi {{ contact.firstname }}, saw your interest in Chert. Want me to send details?` | | Expected result | Chert sends from the bound project phone line and writes replies back to the contact timeline. | ## Next steps > **[Install](/hubspot/install)** > > OAuth flow, scopes, and verifying the first send. > **[Configuration](/hubspot/configuration)** > > Bind a Chert project, pick a phone line, configure auto-send. > **[Architecture](/hubspot/architecture)** > > Component map, send and reply sequences, OAuth refresh. > **[Security](/hubspot/security)** > > Trust boundary, signature verification, scope rationale. > **[Limits](/hubspot/limits)** > > Rate limits, daily caps, retry semantics, idempotency. # Security Source: https://docs.trychert.com/hubspot/security Trust boundary, OAuth scope rationale, signature verification, and data handling. Every request crossing the trust boundary is signed, every secret is stored encrypted at rest, and every scope is the minimum needed to make a feature work. ![HubSpot trust boundary](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/hubspot/diagrams/trust-boundary.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=e1dc1ad4316dcad4cd569baa462ff00a) ## Trust Boundary | Side | What it controls | What it cannot do | | - | - | - | | HubSpot account | Approve or revoke the OAuth grant; configure the workflow action; uninstall the app. | Read or modify another connected account's data; impersonate Chert; bypass the signature requirement on inbound webhooks. | | Chert | Use the granted scopes; refresh tokens; create Communication engagements on contacts the customer's app has access to. | Read deals, companies, tickets, custom objects, or any contact property outside the documented mapping. | The boundary runs along the OAuth grant. Revoking the grant in HubSpot — or disconnecting from the Chert console — is sufficient to end Chert's access to the account. ## OAuth Scopes Each scope is the least-privilege scope that makes its feature work. | Scope | Used by | Why this scope and not a broader one | | - | - | - | | `oauth` | Token exchange and refresh | HubSpot requires this scope on every public app that uses OAuth. | | `crm.objects.contacts.read` | Sidebar card, workflow action, contact-creation webhook | Reads `phone`, `mobilephone`, `firstname`, `lastname`, and `company` on the targeted contact only. No bulk read of the contacts collection. | | `crm.objects.contacts.write` | Reply push | Creates Communication engagements and associates them to the contact. Chert never updates contact properties. | | `crm.schemas.contacts.read` | Sidebar card | Resolves the contact schema in accounts with custom property layouts so reads work without per-account configuration. | | `automation` | Workflow custom action | Registers the custom action and accepts its callbacks. Chert does not read, modify, or trigger workflows it did not register. | Chert does not request scopes for deals, companies, tickets, custom objects, files, marketing email, ads, or social. ## HubSpot Signature v3 Every webhook and every fetch from the sidebar UI extension carries HubSpot's v3 signature. Chert verifies the signature before any database work and before parsing the request body. | Component | Detail | | - | - | | Header | `X-HubSpot-Signature-v3`, base64-encoded HMAC-SHA256. | | Header | `X-HubSpot-Request-Timestamp`, Unix milliseconds. | | Skew bound | Reject when the timestamp differs from server time by more than five minutes. | | HMAC input (POST) | `${method}${decodedRequestUri}${rawBody}${timestamp}` | | HMAC input (GET) | `${method}${decodedRequestUri}${timestamp}` — the body is omitted entirely, not included as an empty string. | | Key | The app's client secret. | | Comparison | Constant-time. | The request URI is URL-decoded before hashing and includes the full scheme, host, path, and query string. > [!WARNING] > > The GET signature shape is a frequent source of integration bugs. > HubSpot signs GETs without any body segment — a GET signature > computed over `${method}${uri}${""}${timestamp}` will always > disagree. ## OAuth State Cookie The install flow uses a short-lived signed cookie to bind the OAuth `state` parameter to the originating Chert project. | Property | Value | | - | - | | Cookie name | `hubspot_oauth_state` | | TTL | 10 minutes | | HttpOnly | Yes | | Secure | Yes in production | | SameSite | `lax` | | Signature | HMAC-SHA256 of the JSON payload, keyed by the app's client secret | The callback rejects the request if the cookie is missing, the signature does not match, the embedded `state` does not match the URL `state`, or the timestamp is older than the TTL. ## Token Storage | Secret | Where it lives | At rest | | - | - | - | | HubSpot client ID | Chert environment variables | Standard environment-variable storage | | HubSpot client secret | Chert environment variables | Standard environment-variable storage | | Per-account access token | `hubspot_tenants.access_token` | Encrypted at rest by the database provider | | Per-account refresh token | `hubspot_tenants.refresh_token` | Encrypted at rest by the database provider | Refresh tokens rotate on every refresh — the helper persists the new value before returning. Stale refresh tokens are not retained. The Chert console never exposes access or refresh tokens in any UI or API response. The admin dashboard surfaces only the granted scopes, the token expiry, and the per-tenant counters. ## Data Handling | Data | Read | Stored | Retention | | - | - | - | - | | Contact `phone` and `mobilephone` | Yes | On the resolved Chert lead row | Same retention as other Chert lead data | | Contact `firstname`, `lastname`, `company` | Yes, when auto-creating | On the lead row | Same retention as other Chert lead data | | Outbound message text | Sent through Chert's pipeline | On the conversation row, plus a 500-character preview on `hubspot_send_events.request_payload` for support | Conversation rows follow the project's retention policy; event log rows roll off after 90 days | | Inbound reply text | Pushed to HubSpot as a Communication body | On the conversation row | Same as outbound | | HubSpot Communication ID | Returned by the create call | Implicit in the engagement on the contact timeline | Lives in HubSpot | Chert does not log full request or response bodies for HubSpot calls beyond the 500-character preview noted above. PII fields are redacted in error logs by server-side sanitization. ## Sub-processors | Sub-processor | Purpose | | - | - | | HubSpot, Inc. | Authoritative source for contact data and the destination for reply pushes. | | Vercel, Inc. | Hosts the Chert console and API. | | Supabase, Inc. | Hosts the encrypted database that stores tenant rows, leads, conversations, and the event log. | | OpenAI, L.L.C. | Optional AI-drafting of messages when the account has the feature enabled. The HubSpot integration does not require AI drafting. | The current sub-processor list is published at `https://trychert.com/legal/sub-processors`. ## Incident Response | Event | Action | | - | - | | Suspected token leak | Disconnect the affected HubSpot account from the Chert console, then re-install. The disconnect flips the tenant inactive and invalidates the cached tokens; the re-install rotates them. | | Lost client secret | Rotate the secret in HubSpot's developer dashboard. Chert's environment is updated, then every connected account refreshes its tokens on the next call. Existing access tokens remain valid until they expire naturally. | | Stuck circuit breaker | The per-tenant `inbound_consecutive_failures` counter resets on the next successful reply push. To force a reset without waiting for a reply, contact Chert support. | ## See Also * [Install](/hubspot/install) — scopes and consent flow. * [Architecture](/hubspot/architecture) — how the verified payload flows through the runtime. * [Limits](/hubspot/limits) — rate limits and retry semantics. # Chert Source: https://docs.trychert.com/introduction iMessage infrastructure for trusted communication at scale Chert provides the infrastructure for teams to send, receive, and automate conversations over iMessage using APIs. ## Pick an integration path > **[Use the Messaging API](/api/quickstart)** > > Send from your backend with bearer auth or HMAC. Best when you already own the product UI. > **[Install Salesforce](/salesforce/overview)** > > Add iMessage sending and reply logging to Lead, Contact, and Account records. > **[Install HubSpot](/hubspot/overview)** > > Send from contact records, workflow actions, and contact-created automations. > **[Connect Slack](/slack/overview)** > > Route replies into Slack channels for review, triage, and agent operations. ## What Chert handles | Layer | What you get | | - | - | | Delivery | Managed iMessage lines, routing, retries, and delivery events. | | Conversation state | Tenant-scoped chats, messages, phone-line pinning, and reply history. | | Integrations | Packaged CRM and Slack surfaces on top of the same messaging layer. | ## Start here * [Quickstart](/quickstart) — choose API, Salesforce, HubSpot, or Slack. * [Usage examples](/api/usage-examples) — copy common API flows. * [Authentication](/api/authentication) — bearer auth for scripts, HMAC for production. # Quickstart Source: https://docs.trychert.com/quickstart Choose the shortest path to a working Chert integration. Most teams start in one of four places. Pick the path that matches where your users already work. \| Goal | Start here | Time | \|---|---| \| Send iMessage from your own backend | [Messaging API quickstart](/api/quickstart) | 5 minutes | \| Add Chert to Salesforce records | [Salesforce overview](/salesforce/overview) | 30 minutes | \| Send from HubSpot contacts and workflows | [HubSpot overview](/hubspot/overview) | 15 minutes | \| Triage replies in Slack | [Slack overview](/slack/overview) | 10 minutes | ## API path in one command If you already have a signing secret, this is the shortest possible API check: ```bash theme={null} curl https://console.trychert.com/api/v1/send \ -H "Authorization: Bearer $CHERT_SIGNING_SECRET" \ -H "Content-Type: application/json" \ -d '{"phone":"+14155551234","body":"Hi Sam - testing Chert."}' ``` Use the full [Messaging API quickstart](/api/quickstart) when you need credentials, reply streaming, webhooks, or HMAC signing. ## What to read next * [Usage examples](/api/usage-examples) — send, follow up, attach media, stream replies. * [Authentication](/api/authentication) — bearer auth for scripts, HMAC for production. * [Receiving replies](/api/receiving-replies) — webhooks, retries, and event signatures. ## See also * [Introduction](/introduction) * [Messaging API overview](/api/overview) # Architecture Source: https://docs.trychert.com/salesforce/architecture Component map, sequence diagrams, and entity relationships. Component map, call graph, and synchronous-versus-asynchronous behavior. ## System Landscape ![System landscape](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/salesforce/diagrams/system-landscape.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=414753871ad55d5f0bf779a3efc123b6) A Salesforce Org issues outbound HTTPS to the Chert messaging service through the `Chert_Endpoint` Named Credential. Chert dispatches sends to its delivery infrastructure and posts replies back into the Org via the `ChertInboundRest` REST resource. There are no hardcoded endpoints in Apex. Every outbound HTTP call flows through `Chert_Endpoint`. ## Components ### Apex | Class | Responsibility | Sharing | | - | - | - | | `ChertSendIMessage` | Invocable action. Signs the request, calls Chert, returns `messageId` and `duplicate` to the Flow. | `with sharing` | | `ChertBridge` | `@AuraEnabled` data layer for the Lightning Web Components. Resolves Contact / Lead / Account context, exposes campaign status, drives bulk enrichment. | `with sharing` | | `ChertEnrichService` | Background phone-number enrichment. Signs and POSTs to Chert; persists results when the callback returns. | `with sharing` | | `ChertEnrichScheduler` | `Schedulable` + `Batchable`. Nightly run that enqueues missing-mobile Contacts for enrichment. | `with sharing` | | `ChertEnrichmentRunNowRest` | REST resource for on-demand enrichment triggered from the LWC. | `with sharing` | | `ChertEnrichResultRest` | REST resource for asynchronous enrichment callbacks from Chert. Bearer-authenticated. | `with sharing` | | `ChertInboundRest` | REST resource for reply ingestion. Bearer-authenticated. Resolves the parent record by `(salesforce_record_id, phone)` tie-break. | `with sharing` | | `ChertContactsRest` | Site-exposed REST resource for the operator dashboard's live-Contacts feed. Bearer-authenticated. Read-only plus a single mutable field. | `without sharing` | | `ChertTenantConfig` | Static accessor for `Chert_Tenant.Default` Custom Metadata. | `with sharing` | ### Lightning Web Components | Component | Surface | Purpose | | - | - | - | | `chertConversation` | Lead, Contact, Account record pages | Three-tab pane: **Conversation** (full iMessage thread + inline send box, auto-refreshing), **Status** (CRM status, phone line in use, next scheduled send, sequence enrollment), **Campaign** (today's send count, send window, recent activity). When the record has no phone number, the component shows a **Find phone number** action instead of the conversation view. | | `chertSequenceLauncher` | Contact Quick Action and List View action | Enrolls one or more Contacts into a Chert outbound sequence. | | `chertBulkEnrich` | Contact List View action | Bulk-enriches missing mobile numbers. Concurrency-bounded. | ### Custom Objects and Metadata | Object | Type | Purpose | | - | - | - | | `Chert_Message__c` | Custom object | Append-only conversation log. One record per message, in or out. | | `Chert_Tenant__mdt` | Custom Metadata Type | Per-Org credentials: tenant slug, signing secret, ingest token, base URL override. | ### Platform Wiring | Artifact | Purpose | | - | - | | `Chert_Endpoint` Named Credential | Single outbound endpoint registration. | | `Chert_Messaging_User` Permission Set | End-user access grants. | | `Lead_Record_Page_Chert`, `Contact_Record_Page_Chert`, `Account_Record_Page_Chert` Flexipages | Lightning Record Pages with `chertConversation` placed. | | `Start_Chert_Sequence`, `Enrich_Phone_Numbers` Quick Actions | LWC entry points on Contact. | ## Outbound Send Sequence ![Send sequence](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/salesforce/diagrams/send-sequence.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=edbb06e01ba6d8cfded9b4b8a13fbca5) 1. A Flow, trigger, button, or LWC invokes `ChertSendIMessage`. 2. The action loads `Chert_Tenant.Default` through `ChertTenantConfig`. 3. The action serializes the JSON body, computes `HMAC-SHA256(., secret)`, and attaches the `x-chert-signature` header. 4. The action issues a `POST` through `Chert_Endpoint` to `/send`. 5. Chert verifies signature and timestamp, resolves or creates the lead, and dispatches the send through its delivery infrastructure. 6. Chert returns `{ ok, lead_id, message_id, duplicate }`. 7. The action inserts a `Chert_Message__c` row with `direction = 'outbound'` and the returned `message_id`. 8. The action returns `{ messageId, duplicate }` to the Flow. End-to-end latency in the Apex transaction is dominated by the HTTPS round trip; typical observed latency is 400–900 ms. ## Inbound Reply Sequence ![Reply sequence](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/salesforce/diagrams/reply-sequence.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=33ac78055ec1c5888f5ce6f3f029d165) 1. A recipient replies. Chert receives the inbound message from its delivery infrastructure. 2. Chert POSTs to `.my.salesforce.com/services/apexrest/chert/v1/inbound` with the bearer ingest token in the `Authorization` header. 3. `ChertInboundRest` validates the bearer against `Chert_Tenant__mdt.Ingest_Token__c`. 4. The resource resolves the parent record by the `salesforce_record_id` from the original send, falling back to a phone-number match if the original record is unavailable. 5. The resource inserts a `Chert_Message__c` with `direction = 'inbound'` and inserts a standard `Task` against the same parent record so the reply surfaces on the Salesforce Activity Timeline alongside emails and calls. 6. Open `chertConversation` components subscribed to the Contact's record updates pick up the new row on the next refresh. Every outbound send writes the same pair: a `Chert_Message__c` row with `direction = 'outbound'` and a corresponding `Task`. Standard Salesforce reports built on Tasks pick up iMessage activity without custom report types. ## Phone Enrichment Enrichment fills `Contact.MobilePhone` (and the Chert-prefixed metadata fields) for records that arrive without a number. There are three trigger points and one resolver pipeline. ### Trigger points ![The Chert Messaging widget showing a Find phone number action and the Conversation, Status, and Campaign tabs](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/salesforce/widget-no-phone.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=0ad77e378c383c6d32c207c3b253ef7e) The chertConversation component shows a Find phone number action when the record has no number on file. | Trigger | Surface | Path | | - | - | - | | Single contact, in Salesforce | The `chertConversation` component's **Find phone number** action when no phone is on file | `ChertBridge.enrichContact` → `ChertEnrichService` | | Bulk, list view | The `Enrich Phone Numbers` Quick Action on Contact list views (`chertBulkEnrich`) | `ChertBridge.bulkEnrich` → `ChertEnrichmentRunNowRest` | | Bulk, console | The Chert console's enrichment dashboard | Calls `ChertEnrichmentRunNowRest` directly | ![The Chert console enrichment dashboard with a list of contacts ready for bulk phone-number enrichment](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/console/enrichment-dashboard.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=34b5c31f04858c93fc4e8b8002723712) The Chert console's Enrichment dashboard. Operators select rows and run enrichment in bulk; results write back to Salesforce. ### Resolver waterfall The Chert side runs a three-step waterfall: **1. Cache check** Recent enrichment results for the same identity (LinkedIn URL, email, or `(name, company)` tuple) are returned from cache without re-querying providers. **2. Multi-source resolution** Multiple phone-data providers are queried in parallel. Results are crosschecked against each other; a number that appears across two or more sources gets a high confidence score, a single-source result gets a lower one. **3. Write back to Salesforce** Chert POSTs the result to `ChertEnrichResultRest` (bearer-authenticated). The resource updates `Contact.MobilePhone` and stamps `Chert_Phone_Source__c`, `Chert_Phone_Confidence__c`, and `Chert_Last_Enriched_At__c`. If no provider returns a match, the row is stamped with `Chert_Enrichment_Status__c = 'no_match'` so the dashboard knows not to retry. The first trigger (single contact) is **synchronous** from the operator's perspective — they click and wait. Bulk paths kick off `Queueable` jobs on the Chert side and return immediately; the `chertBulkEnrich` component polls for completion via `ChertEnrichResultRest` callbacks. ### Stamped metadata | Field | Type | Meaning | | - | - | - | | `Chert_Phone_Source__c` | Picklist | Which provider returned the number, or `manual` if hand-entered | | `Chert_Phone_Confidence__c` | Number | 0–100 score, higher when multiple sources agree | | `Chert_Last_Enriched_At__c` | DateTime | When enrichment last ran for this Contact | | `Chert_Enrichment_Status__c` | Picklist | `pending`, `enriched`, `no_match`, or `failed` | The Permission Set grants Read on all four fields and Edit on `MobilePhone` so the bulk-enrich component can write back through standard DML. ## Sequencing The `chertSequenceLauncher` Quick Action enrolls Contacts into a Chert sequence. Sequences are configured in the Chert console; the package exposes only the enrollment surface. A sequence contains: * An ordered set of steps (initial message, follow-ups) * A delay between each step * Message copy per step, with template variables for `{name}`, `{company}`, `{calendar}`, and any `{custom_field}` you bind from a CSV column or Salesforce field * A send window and timezone * A per-line spacing rule between consecutive sends * A phone-line pool that scopes which lines this sequence may use When a lead replies at any step, the sequence stops automatically — no further automated messages go out for that lead. The reply is logged to `Chert_Message__c` and the Activity Timeline; from there a rep handles the conversation manually. ## Entity Relationship ![ERD](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/salesforce/diagrams/erd.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=6037d735b34e6b63a38ff425a5e25317) `Chert_Message__c` carries a `Lookup` to **Contact**, a `Lookup` to **Lead**, and a `Chert_Message_Id__c` external ID for upserts during ingestion. Both Contact and Lead lookups are nullable; exactly one is populated per record. `Chert_Tenant__mdt` is a single-record metadata type. The `Default` record is the only supported instance. ## Synchronous versus Asynchronous Boundaries | Action | Where it runs | | - | - | | `ChertSendIMessage.send` | Synchronous. Returns to the caller within one Apex transaction. | | `ChertBridge.getContext`, `getCampaignStatus`, `getAccountContacts` | Synchronous read paths invoked from the LWC. | | `ChertBridge.startSequenceForRecords` | Synchronous orchestration; one Chert callout per record. Bounded to 80 records per call by the LWC; larger sets chunk client-side. | | `ChertBridge.enrichContact` | Synchronous trigger of an asynchronous Chert-side enrichment. | | `ChertEnrichScheduler.execute` | Asynchronous. Scheduled Apex daily; enqueues a batch. | | Chert's enrichment callback to `ChertEnrichResultRest` | Asynchronous. Out-of-band write into the Org. | | Chert's reply callback to `ChertInboundRest` | Asynchronous. Out-of-band write into the Org. | ## Idempotency and Dedup Outbound sends carry a deterministic `idempotency_key`. Within Chert's 6-hour rolling window, a duplicate key returns `{ duplicate: true, ok: true }` without re-delivering. The Flow should treat `duplicate: true` as success. Inbound replies are deduped on Chert's `message_id`, persisted as the `Chert_Message_Id__c` external ID on `Chert_Message__c`. Re-delivery of the same reply upserts in place. ## Error Handling | Surface | On failure | | - | - | | `ChertSendIMessage` | Returns `{ ok: false, errorCode, errorMessage }` to the Flow. The Flow author chooses the retry strategy. | | `ChertBridge` `@AuraEnabled` methods | Throw `AuraHandledException` with a sanitized message. The LWC surfaces the error through `lightning/platformShowToastEvent`. | | `ChertInboundRest`, `ChertEnrichResultRest` | Return HTTP `4xx` for malformed input or auth failure, `5xx` for unexpected server errors. Chert retries `5xx` with exponential backoff up to three attempts. | ### Chert-side API responses The `/api/salesforce/*` endpoints on Chert's side (which the Apex classes above call) keep the `{ ok: false, ... }` shape — the Apex client branches on `ok` — but every error path now also carries the canonical numeric `code`, a generic human `message`, a `retryable` boolean, and a `trace_id` (also in the `X-Trace-ID` header). The numeric `code` follows the shared [Messaging API error scheme](/api/errors); provider names, env var names, and raw database errors are no longer leaked in `message` — the full detail is logged server-side under `trace_id`. Auth and signature failures return distinct codes (`2012` missing credentials, `2004` invalid signature/token, `2013` timestamp skew) rather than one opaque "unauthorized". The widget context endpoint (`/api/salesforce/v1/widget/context`) was made consistent: a record with no lead yet **always** returns `200 { ok: true, lead: null }` — it no longer sometimes returns `404`. ## Extension Points | Hook | Use | | - | - | | `ChertSendIMessage` invocable | Compose into any Flow, Process Builder, or trigger. | | `ChertBridge` `@AuraEnabled` methods | Build additional LWC surfaces against the same data layer. | | `Chert_Message__c` triggers | Add Org-side automation on inbound reply ingestion (assign owner, create Task, raise Platform Event). | ## See Also * [SECURITY](SECURITY.md) for the trust boundary and HMAC format. * [LIMITS AND CONSIDERATIONS](LIMITS_AND_CONSIDERATIONS.md) for the governor-limit budget per send. * [CONFIGURATION](CONFIGURATION.md) for the per-component setup detail. # Configuration Source: https://docs.trychert.com/salesforce/configuration Wire up the Named Credential, Custom Metadata, Permission Sets, and Lightning Record Pages. Every artifact the package depends on, what it does, and how to set it. ## Configuration Checklist Complete in this order. Each step is detailed below. 1. Edit the **Chert\_Tenant.Default** Custom Metadata record. 2. Verify the **Chert\_Endpoint** Named Credential. 3. Assign the **Chert Messaging User** Permission Set. 4. Activate the three Lightning Record Pages. 5. Wire `ChertSendIMessage` into a Flow or button. ## Custom Metadata — `Chert_Tenant__mdt` The package ships one default record, `Chert_Tenant.Default`. It holds the per-Org credentials for talking to the Chert API. | Field | API name | Purpose | Source | | - | - | - | - | | Tenant Slug | `Tenant_Slug__c` | Identifies your Org to Chert. Sent on every request. | Provided by Chert at provisioning. | | Signing Secret | `Signing_Secret__c` | HMAC-SHA256 key for signing outbound payloads. Never leaves the Org except as a derived signature. | Provided by Chert at provisioning. | | Ingest Token | `Ingest_Token__c` | Bearer token Chert uses on inbound reply callbacks. | Generated by you, supplied to Chert. | | Chert Base URL | `Chert_Base_URL__c` | Override only when Chert directs you to a non-default region. | Default: `https://console.trychert.com/api/salesforce/v1` | **To configure:** From Setup, enter **Custom Metadata Types**, click **Manage Records** beside **Chert Tenant**, edit **Default**, and paste the values. Click **Save**. The record's **Protected Component** flag should be **true** before any managed package promotion. Protected metadata cannot be read by Apex in subscriber Orgs that did not install the package, and cannot be exported through the Metadata API by non-System Administrators. ## Named Credential — `Chert_Endpoint` | Property | Value | | - | - | | Label | Chert Endpoint | | Name | `Chert_Endpoint` | | URL | `https://console.trychert.com/api/salesforce/v1` | | Identity Type | Anonymous | | Authentication Protocol | No Authentication | | Generate Authorization Header | Disabled | ![The Chert Endpoint Named Credential detail page in Salesforce Setup](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/salesforce/named-credential-detail.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=17233358366ac081723e4013ae722152) Setup → Named Credentials → Chert Endpoint after install. Authentication is handled in Apex by signing the request body with the `Signing_Secret__c` from Custom Metadata. The Named Credential exists to keep the endpoint URL out of code and to satisfy the platform requirement that Apex callouts use a registered endpoint. If your Org connects to a regional Chert deployment, update the Named Credential URL — do not edit Apex. ## Permission Set — `Chert Messaging User` Assigned to every user who will send iMessages or view conversations. Salesforce best practice is Permission Set assignment, not Profile modification; the package contains no Profiles. | Grant | Scope | | - | - | | **Object — `Chert_Message__c`** | Read, Create — no Edit, no Delete, no View All, no Modify All. Records are append-only by design. | | **Object — Lead, Contact, Account** | Read, Edit — required for the Lightning component to update phone fields after enrichment. | | **Field-level security** | Read on every standard field referenced by the conversation pane; Edit on `MobilePhone` and the Chert-prefixed enrichment fields. | | **Apex class access** | `ChertSendIMessage`, `ChertBridge`, `ChertContactsRest`, `ChertEnrichService`, `ChertEnrichResultRest`, `ChertEnrichmentRunNowRest`, `ChertInboundRest`, `ChertTenantConfig`. | | **Named Credential access** | `Chert_Endpoint`. | | **User permissions** | None beyond the platform default. The package does not grant **View All Data**, **Modify All Data**, or any custom user permission. | **To assign:** From Setup, enter **Permission Sets**, click **Chert Messaging User**, click **Manage Assignments**, click **Add Assignments**, select users, click **Assign**. For larger rollouts, group **Chert Messaging User** into a Permission Set Group alongside any role-specific Permission Sets. ## Lightning Record Pages The package ships three Flexipages, each with the `chertConversation` component pre-placed. | Page | Object | Activation | | - | - | - | | `Lead_Record_Page_Chert` | Lead | Org default, App default, or per record-type/profile | | `Contact_Record_Page_Chert` | Contact | Org default, App default, or per record-type/profile | | `Account_Record_Page_Chert` | Account | Org default, App default, or per record-type/profile | **To activate:** From Setup, enter **Lightning App Builder**. For each page, click **Activation**, choose the activation scope, and click **Save**. Activation is per-Org — a sandbox-active page does not auto-activate in production. ## Quick Actions | Quick Action | Object | LWC | Purpose | | - | - | - | - | | `Start_Chert_Sequence` | Contact | `chertSequenceLauncher` | Enroll one or more Contacts into a Chert outbound sequence. | | `Enrich_Phone_Numbers` | Contact | `chertBulkEnrich` | Bulk-enrich missing mobile numbers via Chert. | Add either Quick Action to a Lightning Record Page or List View by editing the relevant Page Layout or List View Button Layout. ## Wiring `ChertSendIMessage` into a Flow The invocable action accepts these inputs: | Input | Type | Required | Purpose | | - | - | - | - | | `phone` | Text | Yes | E.164 phone number (`+14155551234`). | | `message` | Text | Yes | Body of the iMessage. | | `recordId` | Text | Recommended | The Salesforce ID the send relates to. Surfaces in Chert reporting and on the related `Chert_Message__c`. | | `idempotencyKey` | Text | Recommended | Deterministic key. Re-invoking with the same key within the dedup window is a no-op. | Outputs: | Output | Type | Purpose | | - | - | - | | `messageId` | Text | The Chert-side message ID. Persist on the parent record for traceability. | | `duplicate` | Boolean | `true` if the send was deduplicated. Treat as success. | To use from Flow Builder, drop an **Action** element, search for **Chert Send iMessage**, and bind the Lead's `MobilePhone` and a text template to `phone` and `message`. Use the Lead Id as `recordId`. ## See Also * [INSTALL](INSTALL.md) for the prerequisite step. * [SECURITY](SECURITY.md) for the trust boundary and HMAC format. * [ARCHITECTURE](ARCHITECTURE.md) for what runs synchronously versus asynchronously. # Limits and Considerations Source: https://docs.trychert.com/salesforce/limits Per-send governor consumption, edition fit, and platform-feature compatibility. Capacity planning, edition compatibility, and platform-feature behavior. Read before piloting in production. ## Per-Send Governor Limit Consumption Numbers below are per outbound send through `ChertSendIMessage`, measured against the synchronous Apex transaction limits. | Limit | Consumed | Platform max (synchronous) | Headroom | | - | - | - | - | | HTTP callouts | 1 | 100 | Sends from a single transaction may bulk up to \~100 | | Callout total time | \< 5 s typical | 120 s | 24× headroom | | SOQL queries | 2 | 100 | Tenant config + duplicate check | | SOQL rows | ≤ 2 | 50,000 | Bounded by query shape | | DML statements | 1 | 150 | Inserts the `Chert_Message__c` record | | DML rows | 1 | 10,000 | One row per send | | Heap | \< 32 KB | 6 MB | Payload + signature | | CPU time | \< 200 ms | 10,000 ms | Excludes network wait | The package does not consume **future invocations**, **queueable invocations**, or **scheduled Apex slots** during a synchronous send. ## Bulk Sends from Triggers Calling `ChertSendIMessage` from a trigger on more than one record at a time chunks the work. The first 80 sends run in the trigger transaction; overflow is enqueued through `System.enqueueJob` to a `Queueable` that honours the same dedup and signature contract. `Queueable` chaining respects the platform's 50-job synchronous depth limit. For initial-load scenarios above 1,000 records, prefer scheduling sends through the `ChertSendIMessage` invocable inside a Flow with a **Wait** element rather than firing all-at-once from a trigger. ## Daily Send Capacity The platform imposes no daily Apex callout cap. Daily send volume is bounded by Chert-side per-line capacity. Each Chert phone line has a fixed daily first-touch budget. When all lines are saturated, `ChertSendIMessage` returns a `duplicate=false, ok=false` response with an explanatory error code; the Flow can route the failure for re-attempt the following day. Capacity provisioning is sized to your expected volume during tenant onboarding. ## Apex Test Coverage Shipped coverage is ≥ 85% line coverage per class. Reproduce locally: ```bash theme={null} sf apex run test \ --target-org \ --tests ChertBridge_TEST \ --tests ChertContactsRest_TEST \ --tests ChertEnrichResultRest_TEST \ --tests ChertEnrichScheduler_TEST \ --tests ChertEnrichService_TEST \ --tests ChertEnrichmentRunNowRest_TEST \ --tests ChertInboundRest_TEST \ --code-coverage \ --result-format human ``` Every HTTP callout path is covered by `HttpCalloutMock`. No test makes a real callout; runs are deterministic and offline. A passing run looks like this: ```text sf apex run test (excerpt) theme={null} === Test Summary NAME VALUE ─────────────────── ───────────────────────────────── Outcome Passed Tests Ran 57 Pass Rate 100% Fail Rate 0% Skipped 0 Test Total Time 16240 ms === Test Results TEST NAME OUTCOME RUNTIME (MS) ───────────────────────────────────────────────────────────────────── ─────── ──────────── ChertBridge_TEST.getContext_happy Pass 287 ChertBridge_TEST.getContext_404_returns_empty Pass 142 ChertBridge_TEST.getContext_no_phone_throws Pass 94 ChertBridge_TEST.sendMessage_happy_insertsChertMessage Pass 412 ChertBridge_TEST.sendMessage_error_throws Pass 188 ChertBridge_TEST.sendMessage_blank_throws Pass 71 ChertBridge_TEST.getContext_contact_happy Pass 263 ChertBridge_TEST.getContext_contact_phone_fallback Pass 201 ChertBridge_TEST.sendMessage_contact_happy_insertsChertMessage Pass 394 ChertBridge_TEST.resolvePhoneFromRecord_unsupported_returns_null Pass 55 ChertBridge_TEST.resolvePhoneFromRecord_account_picks_first_contact_with_phone Pass 331 ChertBridge_TEST.getContext_account_happy Pass 298 ChertBridge_TEST.sendMessage_account_happy_logs_against_contact Pass 421 ChertBridge_TEST.getAccountContacts_returns_contacts_with_phones Pass 244 ... (20 ChertBridge tests in total) ChertContactsRest_TEST.rejects_missing_bearer Pass 112 ChertContactsRest_TEST.rejects_invalid_bearer Pass 108 ChertContactsRest_TEST.empty_org_returns_empty_list Pass 96 ChertContactsRest_TEST.returns_contacts_with_account_join Pass 198 ChertContactsRest_TEST.honors_limit_param Pass 142 ChertContactsRest_TEST.clamps_oversized_limit Pass 151 ChertContactsRest_TEST.patch_rejects_missing_token Pass 105 ChertContactsRest_TEST.patch_rejects_missing_id Pass 109 ChertContactsRest_TEST.patch_updates_mobile_phone Pass 176 ChertContactsRest_TEST.patch_clears_mobile_when_null_passed Pass 162 ChertContactsRest_TEST.patch_rejects_no_updatable_fields Pass 117 ChertEnrichResultRest_TEST.post_writes_phone_to_contact_on_happy_path Pass 221 ChertEnrichResultRest_TEST.post_rejects_missing_bearer Pass 104 ChertEnrichResultRest_TEST.post_rejects_wrong_token Pass 108 ChertEnrichResultRest_TEST.post_rejects_record_type_mismatch Pass 132 ChertEnrichResultRest_TEST.post_rejects_missing_fields Pass 117 ChertEnrichScheduler_TEST.batch_writes_phone_on_success Pass 489 ChertEnrichScheduler_TEST.batch_stamps_no_match_when_endpoint_says_so Pass 352 ChertEnrichScheduler_TEST.batch_skips_contacts_with_existing_phone Pass 298 ChertEnrichScheduler_TEST.schedulable_execute_enqueues_batch Pass 254 ChertEnrichService_TEST.enrich_happy_returns_phone Pass 367 ChertEnrichService_TEST.enrich_no_match_returns_ok_false Pass 202 ChertEnrichService_TEST.enrich_server_error_returns_ok_false Pass 219 ChertEnrichmentRunNowRest_TEST.post_enqueues_batch_on_happy_path Pass 205 ChertEnrichmentRunNowRest_TEST.post_rejects_missing_bearer Pass 108 ChertEnrichmentRunNowRest_TEST.post_rejects_wrong_token Pass 112 ChertInboundRest_TEST.post_happy_matches_lead_and_inserts_message_and_task Pass 274 ChertInboundRest_TEST.post_invalid_token_returns_401 Pass 104 ChertInboundRest_TEST.post_missing_auth_returns_401 Pass 98 ChertInboundRest_TEST.post_no_matching_lead_returns_404 Pass 163 ChertInboundRest_TEST.post_malformed_body_returns_400 Pass 112 ChertInboundRest_TEST.post_missing_fields_returns_400 Pass 107 ChertInboundRest_TEST.post_matches_contact_only_sets_contact_field Pass 189 ChertInboundRest_TEST.post_matches_lead_only_sets_lead_field Pass 184 ChertInboundRest_TEST.post_contact_wins_on_tie_when_both_share_phone Pass 232 ChertInboundRest_TEST.post_no_contact_or_lead_match_returns_404_with_legacy_code Pass 148 ChertInboundRest_TEST.post_idempotent_on_redelivery Pass 211 === Apex Code Coverage by Class CLASSES PERCENT UNCOVERED LINES ─────────────────────────────── ─────── ───────────────── ChertBridge 94% 72, 118, 244 ChertContactsRest 91% 57, 89 ChertEnrichResultRest 93% 48, 102 ChertEnrichScheduler 88% 31, 64, 77 ChertEnrichService 90% 55, 121, 188 ChertEnrichmentRunNowRest 92% 33, 61 ChertInboundRest 95% 82, 147 ChertSendIMessage 89% 44, 91, 133, 162 ChertTenantConfig 100% ─────── Org Wide Coverage 91% ``` The deploy gate (`sf project deploy start --test-level RunLocalTests`) fails if Org Wide Coverage drops below the platform's 75% bar. Shipped coverage holds at 91% so a regression has room to surface before blocking deploys. ## Edition Support | Edition | Status | Notes | | - | - | - | | Enterprise | Supported | | | Unlimited | Supported | | | Performance | Supported | | | Developer | Supported | Recommended for evaluation. | | Professional | Supported with API add-on | Without the add-on, Apex callouts are unavailable and the package will not function. | | Essentials | Not supported | API access is unavailable on Essentials. | ## User Interface | Surface | Status | | - | - | | Lightning Experience | Supported | | Mobile (Salesforce mobile app) | Supported — the `chertConversation` LWC adapts to the phone form factor. | | Salesforce Classic | Not supported. The package contains no Visualforce; the LWC requires Lightning Experience. | | Experience Cloud (community) sites | Not validated. Out of scope for v1. | ## Platform Feature Compatibility ### Person Account Supported. `chertConversation` and `ChertSendIMessage` detect `Account.IsPersonAccount` and route the contact resolution through the Person Account's contact half. No additional configuration required. ### Shield Platform Encryption The package is compatible with Shield Platform Encryption with the following considerations: * **Probabilistic encryption** on `Contact.MobilePhone` and `Lead.Phone` is fully supported. The package reads and writes through standard formula-free field references. * **Deterministic encryption** is required if your administrators want to use phone-number-based duplicate matching across the standard CRM duplicate rules. * The custom field `Chert_Message__c.Text__c` is a **Long Text Area** and supports Shield encryption. Encrypt it at the field level through Setup → **Encryption Policy** if your data-classification policy requires it. ### Multi-Currency Compatible. The package defines no currency fields and performs no currency arithmetic. ### Translation Workbench Field labels, picklist values, and the Permission Set label can be translated through Translation Workbench. The `chertConversation` LWC's hard-coded user-facing strings are not yet externalized; flag during v2 planning if multi-language support is needed. ### Government Cloud and Hyperforce The package's Apex and metadata are Hyperforce-compatible. Validation against Government Cloud Plus has not been performed; do not deploy to GovCloud Plus Orgs without consulting Chert. ## Sandbox versus Production The package metadata is identical across sandboxes and production. The **Custom Metadata record** values differ: each environment must be configured with its own `Tenant_Slug__c`, `Signing_Secret__c`, and `Ingest_Token__c`. Do not copy production secrets into sandboxes; ask Chert for a sandbox-specific tenant. ## Idempotency Every send accepts an `idempotencyKey`. Within a 6-hour rolling window, re-invoking with the same key is a no-op and returns `duplicate: true`. Treat `duplicate: true` as success in Flow logic. If your Flow does not supply `idempotencyKey`, the package generates one as `:`, which is stable across retries of the same logical action. ## Retry Semantics Outbound HTTP failures (network timeout, 5xx) are not auto-retried by the package. The invocable returns `ok: false` with an error code; the Flow should branch on the result and either retry or surface the error to the user. This is intentional — silent retries are difficult to reason about in declarative tooling, so the contract surfaces the choice to the Flow author. ## Setup Audit Trail The package writes the following entries to **Setup Audit Trail** on install and configuration changes: * `Installed package: Chert Messaging` * `Modified custom metadata record: Chert_Tenant.Default` * `Activated Lightning page: Lead_Record_Page_Chert` * `Activated Lightning page: Contact_Record_Page_Chert` * `Activated Lightning page: Account_Record_Page_Chert` * `Assigned permission set: Chert Messaging User` Compliance teams can grep the trail for `Chert_` to audit every package-related change. ## See Also * [SECURITY](SECURITY.md) for the trust boundary and signature format. * [ARCHITECTURE](ARCHITECTURE.md) for synchronous-versus-asynchronous behavior. * [Apex Developer Guide — Execution Governors and Limits](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm) * [General Shield Platform Encryption Considerations](https://developer.salesforce.com/docs/atlas.en-us.securityImplGuide.meta/securityImplGuide/security_pe_considerations_general.htm) # Onboarding Source: https://docs.trychert.com/salesforce/onboard Seven steps from package install to first verified send. ~30 minutes. Most setup happens on Chert's side before you install. The seven steps below are the only customer-side work; total time is \~30 minutes plus one short pairing call with Chert during step 5. > [!NOTE] > > **Before the pairing call:** Steps 1–4 should already be complete in > your sandbox. The call covers step 5 (credential paste + verify) and > step 6 (Slack channel install). Have on hand: a sandbox admin login, a > test Lead with a real mobile number you control, and a workspace admin > who can authorize the Slack OAuth install. Plan \~30 minutes. ## What you do, what Chert does | Step | Owner | Time | | - | - | - | | 1. Install the package into your Org | You | 5 min | | 2. Assign the **Chert Messaging User** Permission Set | You | 2 min | | 3. Add the `chertConversation` component to record pages | You | 5 min | | 4. Add Quick Actions to Contact list views | You | 5 min | | 5. Configure the `Chert_Tenant.Default` Custom Metadata record | You + Chert (paired) | 5 min | | 6. Connect the Slack channel for reply notifications | Chert | — | | 7. (Optional) Wire a Flow handoff from your existing email sequencer | You | 10 min | Project-side setup, sequence templates, phone-line provisioning, and console configuration are handled by Chert before you install. ## Before You Begin | Requirement | How to verify | | - | - | | Lightning Experience enabled | From Setup, search **Lightning Experience Transition Assistant**. Status must be **turned on for all users**. | | My Domain deployed to users | From Setup, search **My Domain**. Step 4 (**Deploy to Users**) must be complete. | | API enabled on the Org's edition | Enterprise, Unlimited, Performance, and Developer Editions include API. Professional requires the API add-on. Essentials is not supported. | | Installer is a System Administrator | The user must hold **Download AppExchange Packages**, **Customize Application**, and **Modify Metadata Through Metadata API Functions**. | | Outbound HTTPS to `console.trychert.com` | If your Org enforces an outbound IP allowlist, allow the Chert API host. | | Salesforce CLI installed locally | Run `sf --version`. Must be **2.0.0 or later**. Install via `npm install -g @salesforce/cli`. | | Chert tenant credentials | Confirm with Chert that you have a `tenant_slug`, `signing_secret`, and `ingest_token` in hand before step 5. | ## The seven steps **1. Install the package into your Org** Chert is a Managed 2GP package. Install into a sandbox first, smoke-test, then install the same link into production. **Install links** (current version `0.1.0-2`): | Environment | URL | | - | - | | Sandbox | [`https://test.salesforce.com/packaging/installPackage.apexp?p0=04tg50000006i4nAAA`](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tg50000006i4nAAA) | | Production | [`https://login.salesforce.com/packaging/installPackage.apexp?p0=04tg50000006i4nAAA`](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tg50000006i4nAAA) | Both links install the same package version. Sandbox uses `test.salesforce.com`; production uses `login.salesforce.com`. **Install into the sandbox first:** 1. Click the **sandbox** link above. Salesforce prompts you to log in. 2. Log in with a sandbox System Administrator account. 3. On the **Install Chert Messaging** screen, choose **Install for Admins Only**. (You'll grant end-user access via the Permission Set in step 2 — installing for All Users right away skips the Permission Set discipline.) 4. Click **Install**. The install takes 2–5 minutes for a fresh org, longer if there are many existing automations. 5. When the screen shows **Installation Complete**, you're done with the sandbox install. **Install into production** (after smoke-testing the sandbox install through steps 2–5): Repeat the same flow with the **production** link. The package version ID is identical — what changes is which Org you authenticate into. > [!TIP] > > **Why "Install for Admins Only"?** It defers user access until the > Permission Set is assigned in step 2, so end users can never see the > component before you've explicitly granted them access. This is the > Salesforce-recommended pattern for ISV packages. **Verify the install:** 1. From Setup, search **Installed Packages** → confirm **Chert Messaging** appears with the expected version number. 2. From Setup, search **Setup Audit Trail** → search for `Chert_` to see the per-component install entries. 3. From Setup → **Object Manager** → **Contact** → **Fields & Relationships** → confirm these custom fields exist: * `Chert_Phone_Confidence__c` * `Chert_Phone_Source__c` * `LinkedIn_URL__c` * `Chert_Last_Enriched_At__c` * `Chert_Enrichment_Status__c` If any of those are missing, the install completed partially. Re-run the install link; Salesforce will reconcile. **Common install failures:** | Symptom | Cause | Fix | | - | - | - | | `Install failed: FIELD_INTEGRITY_EXCEPTION` | Your Org has a custom field with the same API name as one in the package | Tell Chert which field conflicts; we can rename the package field and reissue the install link. | | `Install failed: API access required` | Org edition lacks API access (e.g., Essentials, Professional without add-on) | Confirm prerequisites in **Before You Begin**. | | `User Doesn't Have Access` after install | The installer's profile lacks **Download AppExchange Packages** | Re-run as a System Administrator. | > [!NOTE] > > **Alternate path for source-tree deployments.** If your team prefers > deploying from source rather than a packaged install — for example, > to integrate Chert into your existing CI/CD pipeline — Chert can > provide the metadata source. Contact `contact@trychert.com`. The > install-link path documented above is recommended for everyone else. **2. Assign the Chert Messaging User Permission Set** Salesforce best practice is Permission Set assignment, not Profile modification. The package contains no Profiles. 1. From Setup, enter **Permission Sets**. 2. Click **Chert Messaging User**. 3. Click **Manage Assignments** → **Add Assignments**. 4. Select the users who will send or review iMessages, then click **Assign**. For larger rollouts, group **Chert Messaging User** into a Permission Set Group alongside any role-specific Permission Sets. **What this grants:** Read + Create on `Chert_Message__c`, Read + Edit on the Chert-prefixed Contact and Lead enrichment fields, Apex class access for the eight package classes, and Named Credential access to `Chert_Endpoint`. No `View All Data`, no `Modify All Data`. Full breakdown in [Configuration](/salesforce/configuration). **Verify:** * From Setup → **Permission Sets** → **Chert Messaging User**, confirm the user count under **Manage Assignments** matches what you assigned. * Sign in as one of the assigned users (use the **Login As** option from Setup → **Users**) and confirm Step 3's component appears. > [!TIP] > > For Orgs running Permission Set Groups: create a group named e.g. > **Chert Outbound** that contains **Chert Messaging User** plus your > existing **Sales Rep** Permission Set. Assign the group rather than > the individual Permission Sets — easier to manage at scale and the > audit trail is cleaner. **3. Add the chertConversation component to record pages** The package ships three pre-configured Lightning Record Pages. You activate them per Org, App, or record-type/profile combination. 1. From Setup, enter **Lightning App Builder**. 2. For each of the three shipped pages, click the page name then click **Activation**: * `Lead Record Page Chert` * `Contact Record Page Chert` * `Account Record Page Chert` (optional) 3. Choose the activation scope: * **Org Default** — applies to every Lead/Contact/Account record across the Org. * **App Default** — scoped to one Lightning app (e.g., Sales Console). * **Profile / Record Type** — narrowest; assign only to specific profiles or record types. 4. Click **Save**. **Verify:** Open any Lead, Contact, or Account record. The **Chert Messaging** component appears in the right-rail with three tabs: **Conversation**, **Status**, and **Campaign**. ![Salesforce Contact record page with the Chert Messaging widget visible in the right rail](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/salesforce/lead-page-with-component.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=a3f6d6681bfce0c504ef03d2169a51c7) The chertConversation component on a Contact record after activation. The three tabs are Conversation, Status, and Campaign. If the Contact has no phone number on file, the same component renders its empty state with a **Find phone number** action that triggers the enrichment pipeline: ![The Chert Messaging widget showing the Find phone number action](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/salesforce/widget-no-phone.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=0ad77e378c383c6d32c207c3b253ef7e) Empty-state Chert Messaging widget on a Contact with no phone number. > [!NOTE] > > Activation is **per-environment**. A sandbox-active page does not > auto-activate in production. Repeat this step on each Org you deploy to. > [!TIP] > > If your Org already has a customized Lead/Contact record page, you can > skip the shipped Flexipages and instead drop the `chertConversation` > LWC component directly onto your existing page from Lightning App > Builder. The component is exposed under the **Custom — Managed** > section of the components palette. **4. Add Quick Actions to Contact list views** Two Quick Actions ship with the package and need to be exposed on Contact list views so reps can run them in bulk. | Quick Action | Component | Purpose | | - | - | - | | `Enrich_Phone_Numbers` | `chertBulkEnrich` | Bulk-enrich missing mobile numbers across selected Contacts | | `Start_Chert_Sequence` | `chertSequenceLauncher` | Enroll selected Contacts into a Chert sequence | To expose them on the Contact list view: 1. From Setup → **Object Manager** → **Contact** → **Search Layouts for Salesforce Classic** (or **List View Button Layout** in Lightning). 2. Edit the **List View** layout. 3. Add **Enrich Phone Numbers** and **Start Chert Sequence** to the **Selected Buttons** list. 4. Save. **Verify:** Open the Contacts tab in Lightning Experience, switch to any list view with selectable rows, select a few Contacts, and confirm both Quick Action buttons appear in the action bar. > [!NOTE] > > **Checkpoint — you should now be able to:** > > * Open any Lead, Contact, or Account record and see the three-tab Chert Messaging component > * Open the Contacts list view, select rows, and see the **Enrich Phone Numbers** + **Start Chert Sequence** buttons > * See `Chert_Message__c` and `Chert_Tenant__mdt` in **Object Manager** > * Find **Chert Messaging User** under Permission Sets > > If any of those are missing, fix before moving to step 5. Step 5 > sends real network traffic to Chert and assumes the package is fully > installed and assigned. **5. Configure the Chert\_Tenant.Default Custom Metadata record** This step pastes the credentials Chert provisioned for your Org into the package's tenant configuration. Most customers do this on a 30-minute pairing call with Chert; the call confirms credentials are correct before any traffic flows. 1. From Setup, enter **Custom Metadata Types**. 2. Click **Manage Records** next to **Chert Tenant**. 3. Edit the **Default** record. 4. Paste these values from your Chert onboarding handoff: * `Tenant_Slug__c` — your Org's identifier with Chert * `Signing_Secret__c` — HMAC-SHA256 key for outbound requests * `Ingest_Token__c` — bearer token Chert presents on inbound webhooks * `Chert_Base_URL__c` — leave default unless directed to a regional deployment 5. Click **Save**. **Verify** — three options, pick whichever you have access to: > **Option A — Developer Console (easiest)** > > 1. From the App Launcher (waffle icon, top-left), search for **Developer Console**. It opens in a popup window. > 2. In the popup, click **Debug** → **Open Execute Anonymous Window**. > 3. Paste this Apex snippet, replacing the placeholder Lead ID with a real Lead from your Org: > > ```apex theme={null} > ChertSendIMessage.SendInput in = new ChertSendIMessage.SendInput(); > in.phone = '+15555550123'; > in.message = 'Test from anonymous Apex'; > in.recordId = '00Q...your-test-Lead-id'; > List out = > ChertSendIMessage.send(new List{ in }); > System.debug(JSON.serializePretty(out[0])); > ``` > > 4. Check the **Open Log** box, then click **Execute**. > 5. In the resulting log, search for `USER_DEBUG`. You should see: > > ```text theme={null} > USER_DEBUG|[7]|DEBUG|{ > "ok" : true, > "messageId" : "msg_...", > "duplicate" : false, > "errorCode" : null, > "errorMessage" : null > } > ``` > **Option B — VS Code with the Salesforce Extensions** > > 1. Save the snippet above as `scripts/apex/ping-chert.apex`. > 2. From the command palette: **SFDX: Execute Anonymous Apex with Editor Contents**. > 3. Output appears in the VS Code Output panel under the **Apex** channel. > **Option C — sf CLI** > > ```bash theme={null} > echo 'ChertSendIMessage.SendInput in = new ChertSendIMessage.SendInput(); > in.phone = "+15555550123"; > in.message = "Test from anonymous Apex"; > in.recordId = "00Q...your-test-Lead-id"; > System.debug(JSON.serializePretty( > ChertSendIMessage.send(new List{ in }) > ));' | sf apex run --target-org > ``` **What success looks like:** `ok: true`, a non-null `messageId`, and an actual iMessage delivered to the recipient device within \~2 seconds. **Common failures:** | Output | Cause | Fix | | - | - | - | | `errorCode: signature_invalid` | `Signing_Secret__c` does not match what Chert provisioned | Re-paste the secret. Check for trailing whitespace/newline. | | `errorCode: tenant_not_found` | `Tenant_Slug__c` does not match | Re-paste the slug. | | `errorCode: phone_invalid` | `phone` not in E.164 format | Use `+1` prefix and digits only, no spaces or dashes. | | `errorCode: all_servers_at_capacity` | Chert side has no free phone-line slot for this recipient right now | Not a config problem; retry in a few minutes or contact Chert. | > [!WARNING] > > Treat `Signing_Secret__c` and `Ingest_Token__c` as production secrets. > Do not export them via the Metadata API to non-System Administrators. > The Custom Metadata record's **Protected Component** flag should be > set to **true** before any 2GP managed-package promotion. **6. Connect the Slack channel for reply notifications** **Chert handles this step.** During the pairing call in step 5, Chert will: 1. Install the **Chert Notifications** Slack app into your workspace. 2. Bind the app to the channel you nominate (typically `#chert-replies` or a channel scoped to the team that owns outbound). 3. Configure the per-channel feature flags (daily summary canvas, low-leads alert, settings mirror). Customer-side action: have a workspace admin available to authorize the Slack OAuth install when Chert sends the install link. \~2 minutes of admin time. **Verify:** Once bound, ask Chert to fire a test reply notification. The card should appear in the bound channel with a conversation preview, an AI-drafted suggested reply, and a **Review & reply →** deep-link to the Chert console. See [Slack notifications](/slack/notifications) for the full card anatomy and the thread-reply triage flow. **7. (Optional) Wire a Flow handoff from your existing email sequencer** If your team already runs email outreach in Outreach, Salesloft, or a Salesforce-native sequencer and wants iMessage to fire as a follow-up step, this is one Flow plus one invocable action. 1. From Setup, enter **Flow** → **New Flow** → **Record-Triggered Flow**. 2. Object: **Lead** (or Contact, or your sequencer's enrollment object). 3. Trigger: **A record is updated**. 4. Entry condition: whatever your sequencer sets to mark a step as **complete** and ready to hand off (e.g., `Outreach_Step__c = 'iMessage'`). 5. Add an **Action** element. Search for **Chert Send iMessage**. 6. Bind: * `phone` → `{!$Record.MobilePhone}` (or `Phone`) * `message` → a text template, e.g., `Hey {!$Record.FirstName}, following up on the email I sent.` * `recordId` → `{!$Record.Id}` * `idempotencyKey` → `{!$Record.Id}:imessage:1` (deterministic; replays are no-ops) 7. Add a **Decision** branch on the action's `duplicate` output: * `duplicate = true` → log success but don't re-send * `ok = false` → write the error to a custom field for retry 8. Save and activate the Flow. **Verify:** Trigger the entry condition manually on one test record. Within \~1 second the Flow runs; within \~2 seconds the recipient sees the iMessage; within a few seconds the `Chert_Message__c` row plus a standard `Task` appear on the record's Activity Timeline. ## Verify the Installation End-to-End Once all seven steps are complete: 1. Open any Lead with a valid mobile number. 2. Open the **Chert Messaging** component → **Conversation** tab. 3. Type a test message and click **Send**. The bubble should appear with a status indicator. 4. From Setup → **Apex Jobs**, confirm no failed `ChertEnrich` jobs. 5. From Setup → **Setup Audit Trail**, search for `Chert_` to confirm the install + configuration entries. 6. Reply from the recipient device. Within a few seconds, the inbound bubble appears in the Conversation tab and a `Chert_Message__c` row + a standard `Task` are inserted on the Lead. ## Troubleshoot > **RunLocalTests fails with MIXED\_DML\_OPERATION** > > The package does not perform mixed DML on its own. Investigate your > Org's test data factory or any triggers that fire during package > test execution. > **Chert Messaging component does not appear on a record** > > The Lightning Record Page is not active for the user's profile, app, > or record type. From **Lightning App Builder**, open the page, click > **Activation**, and confirm the scope covers the user trying to view > the record. > **INSUFFICIENT\_ACCESS when invoking ChertSendIMessage** > > The running user does not have the **Chert Messaging User** Permission > Set, or the Permission Set is missing Apex class access to > `ChertSendIMessage`. Re-assign in step 2. > **Outbound calls fail with UNAUTHORIZED\_ENDPOINT** > > The `Chert_Endpoint` Named Credential URL was overwritten. Reset to > `https://console.trychert.com/api/salesforce/v1` from Setup → **Named > Credentials** → **Chert Endpoint**. > **Outbound calls return signature\_invalid** > > The `Signing_Secret__c` in your `Chert_Tenant.Default` record does not > match what Chert provisioned. Re-paste from the secure handoff and > confirm there is no trailing whitespace or newline. > **Inbound replies do not appear in Salesforce** > > Either the `Ingest_Token__c` is wrong (Chert can't authenticate to the > ingestion REST endpoint) or your Org's network rules block inbound > HTTPS from Chert's webhook range. Ask Chert to replay the most recent > event from `console.trychert.com → Events → Replay`. If the replay > 401s, fix the token; if it 500s with a network error, contact your > network admin. ## Upgrade from a Previous Version Re-run `sf project deploy start` against the same target Org with the new source tree. Custom Metadata records, Permission Set assignments, and Lightning Record Page activations are preserved. Active Flows referencing `ChertSendIMessage` continue to work as long as the action's input contract is unchanged. Coordinate breaking changes per version with Chert. ## What's next Once the seven steps are complete and the end-to-end test in **Verify the Installation End-to-End** passes, you're live. Next: > **[Run the tutorial](/salesforce/tutorial)** > > Hands-on walkthrough: open a Lead, send a real iMessage, observe the round-trip. > **[Review the configuration](/salesforce/configuration)** > > Per-component reference for Permission Set scope, Custom Metadata fields, and Quick Action behavior. > **[Plan a pilot](mailto:contact@trychert.com)** > > Coordinate with Chert on the first sequence template, send window, and phone-line allocation. > **[Read the security model](/salesforce/security)** > > Trust boundary, HMAC format, and sharing posture per Apex class. Share with your security review team. ## See Also * [Configuration](/salesforce/configuration) for per-component setup detail. * [Architecture](/salesforce/architecture) for the component map, sequence diagrams, and ERD. * [Limits and Considerations](/salesforce/limits) for governor-limit math. # Overview Source: https://docs.trychert.com/salesforce/overview Managed 2GP package that sends iMessage from Lead, Contact, and Account records. Chert for Salesforce is a Managed 2GP package that lets reps send iMessage from Lead, Contact, and Account records. Replies are written back to Salesforce as `Chert_Message__c` rows and mirrored onto the Activity Timeline as standard Tasks. ![Salesforce Lead record page with the Chert Conversation component visible in the right rail](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/salesforce/lead-page-with-component.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=a3f6d6681bfce0c504ef03d2169a51c7) The chertConversation Lightning Web Component on a Lead record page. ## When to use it Use the Salesforce package when your sales team works primarily from Salesforce and needs iMessage activity recorded on the CRM record. | | | | - | - | | **Best fit** | Reps send from Salesforce records and managers want messages on the Activity Timeline. | | **Automation fit** | Existing Flows, triggers, or buttons should hand off outbound iMessage sends. | | **Not for** | Salesforce Classic, Essentials Edition, or orgs without API access. | ## What gets installed \| Item | Where it shows up | What it does | \|---|---| \| Managed 2GP package | Installed Packages, namespace `Chert` | Current version `0.1.0-2`; coordinate version changes with Chert. | \| `chertConversation` LWC | Lead, Contact, Account record pages | Conversation pane for sends, replies, status, and campaign context. | \| `ChertSendIMessage` | Flow action / Apex invocable | Sends an outbound iMessage and returns the Chert message ID. | \| `ChertInboundRest` | Apex REST endpoint | Receives inbound replies from Chert. | \| `Chert_Message__c` | Custom object | Append-only inbound and outbound message log. | \| `Chert_Endpoint` + `Chert_Tenant__mdt` | Setup | Stores the Chert API endpoint, tenant slug, signing secret, and inbound token. | \| `Chert_Messaging_User` | Permission Set | Grants end users package object, field, Apex, and Named Credential access. | ## Requirements | Requirement | Detail | | - | - | | Salesforce edition | Enterprise, Unlimited, Performance, Developer, or Professional with API add-on. | | Lightning Experience | Required. Salesforce Classic is not supported. | | My Domain | Must be deployed to users. | | Optional features | Person Accounts, Multi-Currency, and Shield Platform Encryption are compatible. See [Limits and Considerations](/salesforce/limits). | ## Minimal Flow example Add an **Action** element in Flow Builder, search for **Chert Send iMessage**, and bind: | Input | Example value | | - | - | | `phone` | `{!Lead.MobilePhone}` in E.164 format, for example `+14155551234`. | | `message` | A text template such as `Hi {!Lead.FirstName}, this is Alex from Chert.` | | `recordId` | `{!Lead.Id}` | | `idempotencyKey` | A deterministic key for the send, for example `welcome-{!Lead.Id}`. | Treat `duplicate = true` as success. The send was already accepted within the deduplication window. ## Next steps > **[Onboarding](/salesforce/onboard)** > > Seven-step setup from package install to first verified send. > **[Configuration](/salesforce/configuration)** > > Wire up the Named Credential, Custom Metadata, and Permission Sets. > **[Architecture](/salesforce/architecture)** > > Component map, sequence diagrams, and entity relationships. > **[Security](/salesforce/security)** > > Trust boundary, HMAC signature format, and data handling. # Security Source: https://docs.trychert.com/salesforce/security Trust boundary, HMAC signature format, sharing posture, and data handling. How the package authenticates, authorizes, and contains data. ![Trust boundary](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/salesforce/diagrams/trust-boundary.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=444c951f50008c46335ab324b6f8c11b) ## Trust Boundary | Side of the boundary | What lives there | | - | - | | Inside the Org | All Apex classes, Custom Metadata, Permission Sets, Lightning Web Components, custom objects, and the signing secret. | | On the wire | One HTTPS request per send, signed with HMAC-SHA256. Payload is JSON; transport is TLS 1.2+. | | Outside the Org | The Chert messaging service at `console.trychert.com`. Receives signed requests, dispatches to delivery infrastructure, surfaces replies through the inbound REST resource. | The signing secret never leaves the Org. Apex computes the HMAC over the request body and sends the signature in a header; the secret bytes themselves are never transmitted. ## Authentication ### Outbound (Org → Chert) Every outbound request carries: ``` x-chert-signature: v1,, ``` Where: * `` is the seconds-since-epoch at which the request was signed. * `` is `HMAC-SHA256(., signing_secret)` rendered as lowercase hex. The Chert API rejects requests where: * `` is more than 5 minutes in the past (replay window). * `` is more than 5 minutes in the future (clock-skew tolerance). * The HMAC does not match. ### Inbound (Chert → Org) Reply notifications POST to the `ChertInboundRest` REST resource on the Org. The resource requires: ``` Authorization: Bearer ``` The `ingest_token` is generated by you, stored in `Chert_Tenant__mdt.Ingest_Token__c`, and shared with Chert. It is not the same value as the outbound signing secret; the two are independently rotatable. ## Authorization ### Apex Sharing Every callable Apex class declares its sharing posture explicitly: | Class | Sharing | Rationale | | - | - | - | | `ChertSendIMessage` | `with sharing` | Runs in the calling user's context. Honours record sharing. | | `ChertBridge` | `with sharing` | LWC data layer. Honours record sharing. | | `ChertEnrichService` | `with sharing` | Phone enrichment under the calling user's grants. | | `ChertEnrichScheduler` | `with sharing` | Scheduled job; honours sharing of the scheduled-job owner. | | `ChertEnrichmentRunNowRest` | `with sharing` | REST resource invoked by authenticated users. | | `ChertEnrichResultRest` | `with sharing` | REST resource for enrichment callbacks; bearer-authenticated. | | `ChertInboundRest` | `with sharing` | REST resource for reply ingestion; bearer-authenticated. | | `ChertContactsRest` | `without sharing` | Site-exposed REST resource gated by bearer token. Documented and bounded — see below. | `ChertContactsRest` is the only `without sharing` class in the package. Its scope is read-only on a fixed projection of Contact fields and an edit on a single mutable field. The bearer-token check runs before any SOQL or DML, and the field allowlist is enforced server-side. If your deployment does not need the live-Contacts surface, remove the Permission Set's class access and disable the Site that exposes it. ### Field-Level Security The package never bypasses CRUD or FLS. Apex DML on standard objects runs under the calling user's profile and Permission Set grants. The Lightning Web Components route every read and write through Apex classes that honour `WITH USER_MODE` semantics. ### Permission Set Scope `Chert Messaging User` grants only what end users need: * Read on the conversation pane's Contact and Lead fields. * Edit on `Contact.MobilePhone` and the Chert-prefixed enrichment fields. * Read and Create on `Chert_Message__c`. No Edit, no Delete, no View All, no Modify All. * Apex class access for the eight package classes. * Named Credential access to `Chert_Endpoint`. The Permission Set does **not** grant `View All Data`, `Modify All Data`, or any custom user permission. ## Data Handling ### What Crosses the Wire on a Send | Field | Direction | Purpose | | - | - | - | | `tenant_slug` | Out | Identifies your Org. | | `phone` | Out | The recipient's E.164 number. | | `message` | Out | The iMessage body. | | `idempotency_key` | Out | Deterministic dedup key. | | `salesforce_record_id` | Out | The Lead, Contact, or Account ID. Used for traceability and inbound reply correlation. | | `lead.name`, `lead.company`, `lead.custom_fields` | Out, optional | Used to personalize the message body server-side. Pass only if you opt in. | ### What Crosses on an Inbound Reply | Field | Direction | Purpose | | - | - | - | | `phone` | In | The sender's number. | | `message` | In | The reply body. | | `received_at` | In | Carrier-side timestamp. | | `salesforce_record_id` | In | Correlated to the Salesforce parent record from the original send. | ### Retention Chert retains conversation data for the duration of the customer contract plus 30 days for operational continuity, after which it is purged. Data residency and retention overrides are negotiated through the Chert master service agreement. ### Sub-Processors The Chert messaging service depends on a small set of named sub-processors for delivery, hosting, and observability. The current list is published at `console.trychert.com/legal/sub-processors` and is the authoritative source. ## Cryptography | Property | Value | | - | - | | Signature algorithm | HMAC-SHA256 | | Signature input | `.` | | Signature encoding | Lowercase hex | | Replay window | 5 minutes | | Transport | TLS 1.2+ enforced by Chert | | Secret rotation | Rotate the Custom Metadata field; the next signed request uses the new secret. Coordinate with Chert support to overlap windows during rotation. | ## Vulnerability Disclosure Report security issues to `contact@trychert.com`. Chert's disclosure policy is 90 days from acknowledgement to public publication. ## Deployment Topology — Hardening Roadmap The current reference topology exposes the live-Contacts read surface (`ChertContactsRest`) through a public Salesforce Site, gated by the bearer ingest token. This is appropriate for Org-internal pilots where the bearer token is the authentication boundary and the field projection is intentionally narrow (no `Account` lookup, no Email, write access scoped to `MobilePhone` only). The recommended path for production-scale customer deployments is: 1. Replace the public Site with a Connected App configured for the **OAuth 2.0 Client Credentials Flow**. Chert obtains an access token per request rather than presenting a static bearer. 2. Revoke the Site Guest User's access to `ChertContactsRest`. 3. Rotate the bearer ingest token to invalidate any cached value. This roadmap is tracked separately and does not block package installation or pilot use. ## See Also * [ARCHITECTURE](ARCHITECTURE.md) for the call-graph and async behavior. * [LIMITS AND CONSIDERATIONS](LIMITS_AND_CONSIDERATIONS.md) for governor-limit math and Shield Platform Encryption support. * [Salesforce Well-Architected — Trusted / Secure](https://architect.salesforce.com/well-architected/trusted/secure) # Send your first iMessage from Salesforce Source: https://docs.trychert.com/salesforce/tutorial End-to-end walkthrough — open a Lead, send a message, watch the reply land back on the record. A hands-on walkthrough that takes you from an installed package to a verified round-trip iMessage in under ten minutes. Run this in a sandbox or Developer Edition Org first. ## What You'll Do * Send an outbound iMessage from a Lead record using the `chertConversation` Lightning component. * Confirm the send wrote a `Chert_Message__c` row with the correct `Direction__c` and `Chert_Message_Id__c`. * Receive a reply on the recipient device and watch it appear on the same record. ## Before You Begin | Requirement | How to verify | | - | - | | Sandbox or Developer Edition Org | A scratch org or partner Developer Edition works. Don't run this in production on day one. | | Package installed | From Setup, enter **Installed Packages** (for 2GP) or check that Apex classes prefixed `Chert` exist. See [Install](/salesforce/onboard). | | Tenant credentials configured | `Chert_Tenant.Default` Custom Metadata holds non-empty `Tenant_Slug__c`, `Signing_Secret__c`, and `Ingest_Token__c`. See [Configuration](/salesforce/configuration). | | Permission Set assigned | The user running the tutorial holds **Chert Messaging User**. | | Lead Record Page activated | `Lead_Record_Page_Chert` is the active record page for your profile. | | Recipient device | A phone or Mac you can read iMessage on. iMessage must be enabled on that device. | > [!NOTE] > > Outbound calls leave Salesforce and hit the live Chert messaging > service. The send is real. Use a phone number you control as the > recipient. ## Step 1: Open a Test Lead **1. Open any Lead record** From the App Launcher, open **Sales**, click **Leads**, and pick a Lead. If your sandbox is empty, click **New** and create one named `Tutorial Lead` with a recipient mobile number you control. **2. Confirm MobilePhone is set in E.164 format** The component reads `Lead.MobilePhone`. Set it to the recipient's number in E.164 format — for example, `+14155551234`. If `MobilePhone` is empty, paste the number into the field and save. **3. Locate the Chert Conversation pane** Scroll the right rail. The **Chert Conversation** Lightning component appears with an empty thread and a message composer at the bottom. If it's missing, the Lead Record Page is not active for your profile — see [Configuration](/salesforce/configuration#lightning-record-pages). ## Step 2: Send Your First Message **1. Type a short test message** Use something obvious so you can spot it on the recipient device, e.g. `Tutorial test from Salesforce`. **2. Click Send** The component disables the composer for the duration of the callout. On success, the message renders in the conversation thread with a timestamp and an outbound chevron. **3. Inspect the response** `chertConversation` calls `ChertSendIMessage` under the hood. The invocable returns `messageId` and `duplicate`. The message ID is persisted to `Chert_Message__c.Chert_Message_Id__c` on the row inserted in the next step. > [!TIP] > > If Send returns an error, open Setup → **Apex Jobs** and the > **Debug Logs** for your user. Most first-send failures trace to a > missing or stale `Signing_Secret__c` value. ## Step 3: Verify in Salesforce **1. Open the Chert Messages related list** From the Lead record, scroll to the **Chert Messages** related list. A new `Chert_Message__c` row appears. **2. Confirm the field values** Click into the row. You should see: | Field | Expected value | | - | - | | `Direction__c` | `outbound` | | `Text__c` | The message body you typed | | `Sent_At__c` | A timestamp within the last few seconds | | `Chert_Message_Id__c` | A non-empty Chert-side message ID | | `Delivered__c` | `false` initially; flips to `true` once the recipient device acknowledges | **3. Cross-reference Setup Audit Trail** Optional but recommended in a regulated Org. From Setup, enter **Setup Audit Trail**. The send is not itself an audited Setup event, but Permission Set assignments and metadata changes from install are visible here. ## Step 4: Receive a Reply **1. Reply from the recipient device** On the iMessage thread on your phone or Mac, send a short reply back to the Chert phone line — for example, `got it`. **2. Wait for the inbound POST** Chert posts the reply to your Org's `ChertInboundRest` resource. Delivery is typically under five seconds. **3. Refresh the Lead record** The conversation pane updates on next load. The `Chert Messages` related list shows a new row with `Direction__c = inbound` and `Text__c` matching what the recipient sent. **4. Confirm the dedup behavior** If the same inbound message replays (Chert retries on 5xx), the second POST is detected by `Chert_Message_Id__c` uniqueness and is treated as a no-op. No duplicate row. ## Verify Yourself Answer these to confirm you've internalized the round-trip. The answers are linked into the reference docs. 1. **Which Apex action does `chertConversation` invoke to send a message?** ([Configuration](/salesforce/configuration#wiring-chertsendimessage-into-a-flow)) 2. **Which custom object stores the per-message audit row, and what field captures the Chert-side message ID?** ([Architecture](/salesforce/architecture)) 3. **Where is the HMAC signing secret stored, and which Apex class reads it at send time?** ([Security](/salesforce/security)) 4. **What's the dedup window for an `idempotencyKey`, and what `duplicate` value should Flow logic treat as success?** ([Limits and Considerations](/salesforce/limits#idempotency)) 5. **Which Permission Set must a user hold to send through `ChertSendIMessage`, and what object grants does it carry?** ([Configuration](/salesforce/configuration#permission-set--chert-messaging-user)) ## Next > **[Architecture](/salesforce/architecture)** > > Component map and the synchronous-versus-asynchronous call graph. > **[Security](/salesforce/security)** > > Trust boundary, HMAC signature format, and inbound verification. > **[Limits and Considerations](/salesforce/limits)** > > Governor math, edition fit, and platform-feature compatibility. # Configuration Source: https://docs.trychert.com/slack/configuration Per-project Slack settings: notification routing, feature flags, and the Nth-reply filter. Every Slack surface in Chert is per-project and opt-in. Defaults are conservative — enabling the Slack app on a new channel does not retroactively turn on canvases, daily summaries, or low-leads alerts. ## Before You Begin * Chert Notifications is installed (see [Install](/slack/install)). * A channel is bound to your project from Settings. ## Notification routing Reply notifications can fan out across email, Slack, and a generic HTTPS webhook. Configure on the Settings page under **Reply notifications**. | Setting | Type | Default | Behavior | | - | - | - | - | | `notificationMethod` | `slack` \| `webhook` \| `both` | `slack` | Where reply cards are sent. `webhook` mode skips Slack entirely. | | `webhookUrl` | URL | — | Required when `notificationMethod` is `webhook` or `both`. | | `webhookSecret` | string | — | When set, every webhook POST carries an `x-chert-signature` HMAC header. | | `minInboundRepliesToNotify` | number ≥ 1 | `1` | Skip notification until the lead has sent at least this many inbound messages. | | `confirmationEmail` | email | — | Address that receives an email copy alongside Slack. | | `notificationEmails` | email\[] | — | Additional CC observers on email notifications. | ### `slack` vs `webhook` vs `both` | Capability | `slack` | `webhook` | `both` | | - | - | - | - | | Reply notification cards in Slack | Yes | No | Yes | | Custom HTTPS webhook | No | Yes | Yes | | Thread-reply triage with Chert Notifications | Yes | No | Yes | | `@Chert Agent` campaign operations | Yes (separate app) | Yes (separate app) | Yes | | Natural-language approvals on proposals | Yes | No | Yes | > [!NOTE] > > All interactive features — thread-reply triage, approval reactions, > agent operations — require the Slack app. A pure webhook integration > receives notifications only. ### The Nth-reply filter `minInboundRepliesToNotify` gates notifications on how many inbound replies the lead has sent. With `minInboundRepliesToNotify = 2`, the first inbound is logged silently; the second triggers a notification. Useful for filtering one-word brush-offs. The filter is evaluated before any channel fires. When a reply is suppressed by the filter, Chert does not stamp the `reply_email_sent_at` marker, so a subsequent inbound from the same lead re-evaluates the threshold. ## Per-project feature flags Each canvas surface and the proactive alert are flagged off by default. Toggle from **Settings → Slack canvases + daily summary**. | Flag | What it does | | - | - | | `settingsMirrorEnabled` | Maintains the **Overview** canvas in the bound channel — agent docs plus a live mirror of campaign settings. Rewritten on every Settings save. | | `dailyStatsEnabled` | Maintains the **Daily Summary** canvas — one section per day with sent/replied/failed counts, refreshed every 30 min, finalized at the EOD time. Posts an EOD notification to the channel. | | `eodSummaryTime` | When the daily summary flips to **Final** and posts the EOD notification. Defaults to `17:00`. | | `eodSummaryTimezone` | IANA timezone for `eodSummaryTime`. Defaults to `America/Los_Angeles`. | | `lowLeadsAlertEnabled` | Once-per-day proactive alert in the channel when the untouched-lead pool drops below the configured daily auto-dial pull. | | `deepSourcingEnabled` | Enables the `open_deep_sourcing_ticket` agent tool and the **Lead Lists** canvas approval log. | > [!WARNING] > > Turning on `dailyStatsEnabled` or `lowLeadsAlertEnabled` causes Chert > to post unprompted in the bound channel. Confirm with your team > before enabling. ## EOD summary timing The Daily Summary cron ticks every 30 minutes. Behavior at each tick: | Condition | Action | | - | - | | First tick of a calendar day | Insert a new H2 at the top of the canvas with rolling counts. | | Subsequent ticks before EOD | Replace today's H2 with refreshed counts. | | Tick at or after `eodSummaryTime` | Replace today's H2 with **Final**, append the stats table, today's replies, and a one-line "Tomorrow's angle". Post a notification in the channel with a permalink to the canvas. | The EOD body is inserted exactly once per day; subsequent ticks no-op on the body block. ## Switching the bound channel Re-pick a channel from Settings. The bot token stays the same; the binding changes. Existing canvases tied to the old channel remain in that channel — they are not migrated. Future writes go to the new channel and create fresh canvases there. ## See Also > **[Notifications](/slack/notifications)** > > Card anatomy and thread-reply triage. # Install Source: https://docs.trychert.com/slack/install Install Chert Notifications and Chert Agent into your Slack workspace and bind a channel to a project. Each Chert project binds to one Slack channel. Install through the Settings page in the Chert console — the OAuth flow handles workspace authorization and writes a per-project bot token. ## Before You Begin | Requirement | How to verify | | - | - | | You are a Slack workspace admin, or your admin has pre-approved Chert apps | Slack → Settings & administration → Manage apps → App approval | | You have an active Chert project | Your console URL contains `?project=` | | The target channel exists | Public or private — both are supported | | Your workspace can install Slack apps | App installation is enabled at the workspace level | > [!NOTE] > > Each project gets its own bot token. Installing into multiple > projects from the same workspace means the workspace sees the same > Chert app installed once, with the bot present in each bound > channel. ## Install Chert Notifications **1. Open Settings in the Chert console** Open your project at `https://console.trychert.com/?project=`, then click **Settings**. **2. Click Connect Slack** The button kicks off the OAuth v2 flow. You will be redirected to `slack.com/oauth/v2/authorize` with the scopes listed below. **3. Authorize the app in Slack** Pick the workspace, review the requested scopes, and click **Allow**. Slack redirects back to Chert with an OAuth code. **4. Confirm the install** Settings shows **Connected to \**. The bot token is stored server-side and never exposed to the browser. **5. Pick a channel** The channel picker lists every public channel and every private channel the bot is a member of. Select the target channel and save. **6. Invite the bot to private channels** For private channels, run `/invite @Chert Notifications` in Slack so the bot can post. ### Requested scopes | Scope | Reason | | - | - | | `chat:write` | Post reply notification cards | | `chat:write.public` | Post without explicit channel invite (public channels) | | `channels:read` | Populate the channel picker | | `groups:read` | Include private channels in the picker | | `canvases:write` | Create and update the Overview, Daily Summary, and Lead Lists canvases | ## Install Chert Agent Chert Agent is a separate Slack app with its own bot user. Install it through the same Settings page. **1. Open the Chert Agent connector** On Settings, scroll to **Chert Agent**. Click **Connect**. **2. Authorize and pick a channel** OAuth flow mirrors Chert Notifications. Pick the channel where you want `@Chert Agent` mentions to be answered. **3. Try it** In the channel: `@Chert Agent how are we doing today?` > [!TIP] > > Both apps can live in the same channel. Chert Notifications posts > reply cards; Chert Agent responds to `@`-mentions and posts proactive > alerts. ## Channel binding | Behavior | Detail | | - | - | | One channel per project | Switching channels does not require re-installing — pick a different channel from the Settings picker. | | Cross-project isolation | Two Chert projects can target the same Slack channel. Each project's bot token, settings, and canvases are independent. | | Disconnect | Settings → **Disconnect Slack** removes the bot token from Chert. Notifications stop immediately. The Slack workspace's app install record is preserved on Slack's side until you uninstall from Slack. | ## See Also > **[Configuration](/slack/configuration)** > > Notification routing, feature flags, and the Nth-reply filter. > **[Security](/slack/security)** > > What scopes are used for what, and how tokens are stored. # Notifications Source: https://docs.trychert.com/slack/notifications Reply notification card anatomy, the Review and reply deep link, and thread-reply triage with Chert Notifications. When a lead replies, Chert Notifications posts a Block Kit card in the bound channel. Operators triage from the card — either by clicking through to the CRM or by replying directly in the Slack thread. ## Before You Begin * Chert Notifications is installed and a channel is bound. * `notificationMethod` is set to `slack` or `both` (see [Configuration](/slack/configuration)). ## Card anatomy | Section | Purpose | | - | - | | **Headline** | `:speech_balloon: Lead Name at Company replied` | | **Context strip** | Project name, campaign name, phone line label, lead phone number | | **Conversation tail** | Up to the last 8 non-system messages, formatted as quoted blocks with timestamps | | **Suggested reply** | Either a campaign-templated reply or an AI-drafted draft, in a code block | | **Review & reply button** | Opens the lead's conversation pane in the Chert console with the draft pre-filled | | **In-thread hint** | Reminder that you can reply in-thread to direct the bot | The card ts is recorded in `convo.reply_notify_slack_ts`. Thread replies use this ts as the join key back to the lead. ## Review & reply deep link The button URL has the shape: ``` https://console.trychert.com/?project=&lead=&draft=ready ``` Clicking opens the slide-over conversation panel for that lead. If a draft was attached to the notification, it is pre-filled in the reply composer. ![The Chert console slide-over conversation panel that the Slack notification deep link opens](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/images/screenshots/console/leads-with-slideover.png?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=eb8501ca33636ba80e201053bde4e461) The slide-over conversation panel a Slack reply card deep-links to. ## Thread-reply triage Replying in the card's thread routes the message through Chert Notifications' LLM, which classifies it as either a **question** or a **draft**. ![Thread-reply flow](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/slack/diagrams/thread-reply-flow.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=4e36a59b2def65ead838cb4921cdf402) Thread-reply triage flow ### Direct mention is required The bot only reacts to thread replies that include `@Chert Notifications` (or whatever name the workspace gave the bot). Bare in-thread chatter between operators is ignored, so two people deliberating in a thread does not trigger anything. ### Question vs draft classification | Heuristic | Routing | | - | - | | Reply contains a `?`, or starts with `what / who / when / why / where / how / did / does / is / are / any / can you / could you / tell me` | Treated as a **question**. The bot answers in-thread using the lead's recent conversation, campaign, and phone-line context. | | Otherwise | Treated as a **draft**. The bot polishes the body and posts a confirmation card with ✅/❌ reactions. | | Ambiguous | Defaults to **draft** with a hedge — re-ask with a `?` to flip to question mode. | ### Approving a drafted reply When the bot posts a draft confirmation card, react with: | Reaction | Effect | | - | - | | ✅ `:white_check_mark:` or `:heavy_check_mark:` or `:+1:` | Send the drafted reply to the lead. | | ❌ `:x:` or `:no_entry_sign:` or `:-1:` | Cancel. Nothing is sent. | Approval is processed as soon as Slack delivers the reaction event. A fallback cron also resolves pending proposals, so a missed event still gets reconciled. ### Card refinement If you reply again in the thread while a draft confirmation is pending, the previously pending card is auto-rejected and a fresh card is posted with the refined draft. You always see exactly one live card per thread. Examples: | Thread reply | Effect | | - | - | | `make it shorter` | Old card rejected. New card posted with a shorter draft. | | `add a calendar link` | Old card rejected. New card posted with the link inserted. | | `same idea but mention pricing` | Old card rejected. New card mentions pricing. | > [!NOTE] > > An auto-rejected proposal records `superseded by <new id>` in > the audit trail so the history of refinements is preserved. ## Webhook payload When `notificationMethod` is `webhook` or `both`, every qualifying reply is also POSTed to your `webhookUrl`. ```json theme={null} { "event": "lead_reply", "event_id": ":", "timestamp": "2026-04-22T...", "project": { "id": "...", "name": "..." }, "lead": { "id": "...", "name": "...", "phone": "...", "company": "...", "linkedin_url": "...", "custom_fields": { ... } }, "inbound_message": { "text": "...", "timestamp": "..." }, "conversation": [ { "role": "outbound" | "inbound" | "system", "text": "...", "timestamp": "..." } ], "suggested_reply": "AI-drafted text or null", "crm_deep_link": "https://console.trychert.com/?project=...&lead=...&draft=ready", "meta": { "inbound_count": 2, "outbound_count": 1, "campaign_name": "...", "phone_line_label": "..." } } ``` Headers: | Header | Purpose | | - | - | | `x-chert-event` | Always `lead_reply` | | `x-chert-event-id` | Dedup key for replays | | `x-chert-timestamp` | Unix seconds | | `x-chert-signature` | `v1,,` HMAC-SHA256 of `.`, only when `webhookSecret` is configured | Retry on 5xx or network failure up to 3× with exponential backoff (1s → 2s → 4s). 4xx is terminal. ## See Also > **[Configuration](/slack/configuration)** > > Notification routing and the Nth-reply filter. > **[Security](/slack/security)** > > Signature verification and what the bot reads vs. writes. # Overview Source: https://docs.trychert.com/slack/overview Chert for Slack — reply-triage notifications, channel-bound agent operations, and per-channel canvases for outbound campaigns. Chert for Slack connects a Chert project to a workspace channel. Reply notifications land in Slack for fast triage, and `@Chert Agent` can answer campaign questions or run channel-level operations. ## When to use it Use Slack when operators live in a channel and need to review replies, approve actions, or monitor campaign health without opening the CRM for every message. | | | | - | - | | **Best fit** | Shared reply triage, campaign monitoring, and lightweight operator actions. | | **Automation fit** | Slack cards should notify the team when a lead replies. | | **Not for** | Full CRM data entry. Slack complements Salesforce, HubSpot, or the Chert console. | ## What gets connected | Item | Where it appears | What it does | | - | - | - | | Chert Notifications | Slack app install | Posts lead reply cards and supports thread-based triage. | | Chert Agent | Separate Slack app install | Responds to `@Chert Agent` mentions for campaign operations. | | Channel binding | Chert Settings | Routes one Chert project to one Slack channel; switchable without reinstalling. | | Bot tokens | Chert server-side storage | Stored per project and never exposed to the browser. | | Canvases | Bound Slack channel | Optional Overview, Daily Summary, and Lead Lists canvases. | | Signing-secret verification | Chert inbound event handler | Verifies every inbound Slack event. | The two apps are independent installs. You can install either or both into the same channel. ## OAuth scopes Both apps request the same scopes because reply cards, app mentions, and canvases share the same workspace surface. | Scope | Why Chert needs it | | - | - | | `chat:write` | Post reply cards, agent responses, and alerts. | | `chat:write.public` | Post in public channels before explicit bot invite. | | `channels:read` | Populate the Settings channel picker. | | `groups:read` | Include private channels in the picker. | | `canvases:write` | Create and update Overview, Daily Summary, and Lead Lists canvases. | Canvas writes will silently no-op for installs that predate the `canvases:write` scope. Reinstall to grant it. ## Minimal usage example After installing Chert Notifications and binding a channel: 1. A lead replies to an outbound iMessage. 2. Chert posts a reply card in the bound Slack channel. 3. An operator reviews the conversation tail and either opens the CRM deep link or replies in the Slack thread for triage. For campaign operations, mention the agent in the same channel: ```text theme={null} @Chert Agent how are we doing today? ``` ![Slack notification flow](https://mintcdn.com/cherttechnologiesinc/7NuyCzLl8sIsJEKo/slack/diagrams/notification-flow.svg?fit=max\&auto=format\&n=7NuyCzLl8sIsJEKo\&q=85\&s=49eb08fa5f888cfe6c57a5b7d1a3c8bf) Slack notification flow ## Next steps > **[Install](/slack/install)** > > OAuth install for both apps and channel binding. > **[Configuration](/slack/configuration)** > > Per-project flags, notification routing, and the Nth-reply filter. > **[Notifications](/slack/notifications)** > > Card anatomy and thread-reply triage. > **[Security](/slack/security)** > > Token storage, signing-secret verification, and data handling. # Security Source: https://docs.trychert.com/slack/security How Chert handles Slack OAuth tokens, signing-secret verification, and channel-binding scope. Chert treats every Slack credential as server-side and every inbound event as untrusted until verified. ## Before You Begin * Chert Notifications and Chert Agent are installed via the OAuth flow described in [Install](/slack/install). ## OAuth token storage | Property | Value | | - | - | | Where stored | Per-project row in the `slack_installations` table | | Token type | Slack bot token (`xoxb-…`) | | Exposure to browsers | Never returned by the API | | Status endpoint | Returns workspace metadata only — team id, team name, bot user id, install timestamp | | Disconnect | `POST /api/slack/disconnect` deletes the row; the bot can no longer post for that project | Chert does not call Slack's `auth.revoke` on disconnect. To fully revoke the install on Slack's side, remove the app from Slack's **Manage apps** page. ## Signing-secret verification Every inbound webhook from Slack is verified before any logic runs. | App | Signing secret env var | | - | - | | Chert Agent events webhook | `SLACK_SIGNING_SECRET` | | Chert Notifications events webhook | `SLACK_HELPER_SIGNING_SECRET` | The verification follows Slack's documented v0 scheme: | Field | Value | | - | - | | Basestring | `v0::` | | Header | `x-slack-signature: v0=` | | Freshness window | 5 minutes (timestamp must be within ±300 s of server time) | | Comparison | Timing-safe | A failed verification returns `401` with a structured error body — `{ ok: false, code: 2004, message, retryable: false, trace_id }` — and the event is dropped. If the Slack signing secret is missing server-side the route returns `500` with `code 3003` (`PROVIDER_NOT_CONFIGURED`, `retryable: false`) instead. Slack's one-time `url_verification` handshake is the only event accepted without signing — it still returns a bare `200` with the raw `challenge` string, unchanged. Genuine error paths across `/api/slack/*` now carry the structured body (numeric `code`, generic `message`, `retryable`, `trace_id`); the OAuth callback redirects to `/settings` with a stable short `reason` slug (`no_code`, `slack_denied`, `oauth_exchange_failed`, `not_configured`, …) rather than raw error text in the URL. See the [Messaging API error scheme](/api/errors) for the `code` registry. ## Event deduplication Slack retries webhooks on slow responses or network errors. Chert deduplicates by Slack's `event_id` for 10 minutes, so retried events do not double-fire actions like proposals or sends. ## Channel-binding scope | Scope | What it means | | - | - | | One channel per project | Notifications and agent posts target the bound channel only. | | Cross-project isolation | Each project's bot token, settings, and canvases are independent of every other project, even on the same workspace. | | Private channels | The bot must be invited (`/invite @Chert Notifications`) before it can post or write canvases. | ## What the bot reads vs writes | Operation | Type | Surface | | - | - | - | | List channels for the picker | Read | `conversations.list` | | Post reply notification cards | Write | `chat.postMessage` | | Post agent responses | Write | `chat.postMessage` | | Update agent progress messages | Write | `chat.update` | | Read thread history for context | Read | `conversations.replies` (only on threads under bot-posted messages) | | Read reactions on proposal cards | Read | `reactions.get` (only on bot-posted messages) | | Create and edit canvases | Write | `conversations.canvases.create`, `canvases.edit`, `canvases.delete` | | Upload xlsx previews | Write | `files.getUploadURLExternal`, `files.completeUploadExternal` | The bot does not browse channel history beyond threads attached to its own messages, does not read direct messages, and does not read user profiles. ## Webhook signing for outbound notifications When `notificationMethod` is `webhook` or `both` and a `webhookSecret` is configured, every outbound POST from Chert carries an `x-chert-signature: v1,,` header. The signature is HMAC-SHA256 over `.` using your `webhookSecret`. Verify on receipt to authenticate the payload. ## Sub-processors | Vendor | Purpose | Data | | - | - | - | | Slack | Notification surface, agent surface | Lead names, message bodies, conversation excerpts, project metadata | | Anthropic | LLM drafting and classification | Same — used to draft replies and answer thread questions | | Resend | Email notifications | Operator email addresses, reply previews | All three are bound by their respective DPAs. Customers can disable the LLM surface by turning off AI-drafted replies in Settings. ## See Also > **[Configuration](/slack/configuration)** > > Routing, flags, and the Nth-reply filter. > **[Notifications](/slack/notifications)** > > Card anatomy and thread-reply triage.