# Delete an attachment Source: https://docs.blujay.io/api-reference/attachments/delete-an-attachment /api-reference/openapi.yaml delete /v1/attachments/{attachment_id} Stops the attachment from being sent or downloaded again, effective immediately. Messages that already include it keep their reference to it. # Download an attachment Source: https://docs.blujay.io/api-reference/attachments/download-an-attachment /api-reference/openapi.yaml get /v1/attachments/{attachment_id}/content Returns the attachment's file. Authenticate the same way as any other API call. Message parts link here through `download_url`. # Get an attachment Source: https://docs.blujay.io/api-reference/attachments/get-an-attachment /api-reference/openapi.yaml get /v1/attachments/{attachment_id} Returns an attachment's details. The first read after an upload checks the uploaded bytes. Until the upload finishes you get `409 ATTACHMENT_NOT_READY`; if the bytes don't match what you declared you get `422 ATTACHMENT_UPLOAD_INVALID`. # Upload an attachment Source: https://docs.blujay.io/api-reference/attachments/upload-an-attachment /api-reference/openapi.yaml post /v1/attachments Starts an upload for a file you can reuse in messages. The response gives you an `upload_url` and `upload_headers`. 1. `PUT` the file's raw bytes to `upload_url` with exactly the `upload_headers` returned. Don't use multipart form data. The URL expires after 15 minutes. 2. Send the `attachment_id` in a `media` message part, or call `GET /v1/attachments/{attachment_id}` to check it. There's no separate "complete" call. Blujay checks the bytes the first time you read the attachment or send it, so the size and type must match what you declared. Files can be up to 100 MiB. Attachment endpoints share a limit of 60 requests per minute per API key. # Get the current credential Source: https://docs.blujay.io/api-reference/auth/get-the-current-credential /api-reference/openapi.yaml get /v1/me Returns the workspace and the API key that authenticated this request. Call it to confirm your key works. # Check iMessage, RCS or SMS support Source: https://docs.blujay.io/api-reference/capability/check-imessage-rcs-or-sms-support /api-reference/openapi.yaml post /v1/capability/evaluate_service Checks which service Blujay would use to reach a US phone number from one of your numbers: `iMessage`, `RCS` or `SMS`. If Blujay can't confirm iMessage or RCS, the result is `SMS`. If Blujay already checked the same recipient from the same number in the last five minutes (through this endpoint, the Blujay app, or a send), you get that answer with `source: cached`. Cached answers are free and never limited. A fresh check runs from your number and shares its capacity with your messages, which always go first. Each of your numbers can run 30 fresh checks per clock hour and 100 per UTC day. Over a limit, or while the number is busy, the call returns `429 RATE_LIMIT_EXCEEDED` with `Retry-After`. This endpoint counts toward your read request limit, not your write limit. # Add a group participant Source: https://docs.blujay.io/api-reference/chats/add-a-group-participant /api-reference/openapi.yaml post /v1/chats/{chat_id}/participants Adds a phone number or email address to an active iMessage group chat. One of your Blujay numbers in the group makes the change. If more than one of your numbers is in the group, set `from_phone_number_id` to choose which one. Adding someone who is already in the group returns `200` with `status: already_active`. Send an `Idempotency-Key` so you can safely retry. # Create a chat and send the first message Source: https://docs.blujay.io/api-reference/chats/create-a-chat-and-send-the-first-message /api-reference/openapi.yaml post /v1/chats Starts a chat from one of your Blujay numbers and sends its first message. One recipient in `to` creates a direct chat; two or more create a group chat. For a direct chat you can leave out `from`, and Blujay picks the number. It uses the number this person already knows you on. If they've never texted any of your numbers, it uses your only number, or else the default line of the teammate the contact is assigned to. If none of those apply, the request returns `422 FROM_NUMBER_REQUIRED` and Blujay never guesses. A group chat always needs `from`. Blujay returns `202` as soon as the message is accepted and sends it in the background. Follow its progress with message webhooks or `GET /v1/messages/{message_id}`. The first message has some limits (see the sending rules at https://docs.blujay.io/messaging/sending-rules): - It can't contain a link. A link part, or a URL in the text, returns `400 FIRST_MESSAGE_LINK_NOT_ALLOWED`. Send links in follow-up messages after the contact replies. - Until the contact replies, `media` and `vcard` parts return `400 PRE_REPLY_CONTENT_BLOCKED` and text is limited to 300 characters (`400 PRE_REPLY_LENGTH_EXCEEDED`). A first message is text only. - `reply_to` isn't accepted (`400 VALIDATION_ERROR`), and message effects and text decorations return `400 FEATURE_NOT_ENABLED`. Send an `Idempotency-Key` header (or `message.idempotency_key`) so you can safely retry. Repeating a request with the same key returns `200` with the original response instead of sending again. Errors worth handling: - `403 RECIPIENT_SUPPRESSED`: the recipient opted out. - `403 CONTACT_NOT_VERIFIED`: on the Free Plan, the contact hasn't texted your Free Plan number to verify yet. - `403 CONTACT_MUST_TEXT_FIRST`: the number is on the AI Agent plan: it only replies to people who text it first, and can't send to group chats. - `422 FROM_NUMBER_REQUIRED`: you left out `from` and Blujay couldn't tell which number to use. `error.message` names the fix, such as setting a default line for the assigned teammate in Team Members. - `429 RATE_LIMIT_EXCEEDED` or `429 QUEUE_FULL`: the sending number hit a limit. Wait `Retry-After` seconds. Each number's current limits are in `GET /v1/phone-numbers` under `limits`. - `503 QUEUE_UNAVAILABLE`: Blujay couldn't accept the message right now. Retry with the same `Idempotency-Key`. # Get a chat Source: https://docs.blujay.io/api-reference/chats/get-a-chat /api-reference/openapi.yaml get /v1/chats/{chat_id} Returns one chat in your workspace. # Leave a group chat Source: https://docs.blujay.io/api-reference/chats/leave-a-group-chat /api-reference/openapi.yaml post /v1/chats/{chat_id}/leave Removes one of your Blujay numbers from an active iMessage group chat. If more than one of your numbers is in the group, set `from_phone_number_id` to choose which one leaves; otherwise you can send an empty body. Errors worth handling (all `409`): - `LAST_PHONE_NUMBER_IN_GROUP`: this is your only number in the group, so it can't leave. - `GROUP_MINIMUM_PARTICIPANTS`: leaving would leave the group with fewer than three members. - `PHONE_NUMBER_HAS_SCHEDULED_MESSAGES`: the number has scheduled messages for this group. - `PHONE_NUMBER_HAS_MESSAGES_SENDING`: the number still has messages sending in this group. Retry once they finish. # List chats Source: https://docs.blujay.io/api-reference/chats/list-chats /api-reference/openapi.yaml get /v1/chats Returns the chats in your workspace, one page at a time. Pass `next_cursor` from the previous page as `cursor` to get the next page. # List group participants Source: https://docs.blujay.io/api-reference/chats/list-group-participants /api-reference/openapi.yaml get /v1/chats/{chat_id}/participants Returns the people in a group chat. Your own Blujay numbers in the group aren't listed here; they appear in the chat's `phone_numbers` field. # Mark a chat as read Source: https://docs.blujay.io/api-reference/chats/mark-a-chat-as-read /api-reference/openapi.yaml post /v1/chats/{chat_id}/read Marks the chat's incoming messages as read in Blujay. In direct iMessage and RCS chats, Blujay also sends a read receipt when it can. SMS and group chats are only marked read in Blujay. A `204` means the request was accepted; it doesn't confirm that the other person sees a read receipt. # Remove a group participant Source: https://docs.blujay.io/api-reference/chats/remove-a-group-participant /api-reference/openapi.yaml delete /v1/chats/{chat_id}/participants Removes a phone number or email address from an active iMessage group chat. If more than one of your Blujay numbers is in the group, set `from_phone_number_id` to choose which one makes the change. Removing someone who already left returns `200` with `status: already_removed`; removing someone who was never in the group returns `404 NOT_FOUND`. Neither contacts the phone. To take one of your own numbers out of a group, use `POST /v1/chats/{chat_id}/leave` instead. Send an `Idempotency-Key` so you can safely retry. # Rename a group chat Source: https://docs.blujay.io/api-reference/chats/rename-a-group-chat /api-reference/openapi.yaml patch /v1/chats/{chat_id} Sets the name of an active iMessage group chat. Everyone in the group sees the new name. Blujay returns `202` once the change is accepted. You can't clear a group name, and the group icon can't be changed through the API yet. If the chat isn't an active iMessage group, you get `400 UNSUPPORTED_CHAT_OPERATION`. # Start a typing indicator Source: https://docs.blujay.io/api-reference/chats/start-a-typing-indicator /api-reference/openapi.yaml post /v1/chats/{chat_id}/typing Shows a typing indicator in an active direct or group chat. Typing is best effort: - Direct chats confirmed as iMessage get a typing indicator. - iMessage group chats get one when the chat's number is still in the group. - SMS and RCS chats, direct chats whose service isn't confirmed yet, and groups that can't receive iMessage typing accept the request and show nothing. A `204` means Blujay accepted the request, not that the recipient saw a typing bubble. Check the chat's `supports_typing_indicators` to know whether one can appear. Returns `400 FEATURE_NOT_ENABLED` if typing indicators are switched off for your workspace (they're on by default; contact support@blujay.io if yours were switched off), and `400 UNSUPPORTED_CHAT_OPERATION` if the chat isn't active. # Stop a typing indicator Source: https://docs.blujay.io/api-reference/chats/stop-a-typing-indicator /api-reference/openapi.yaml delete /v1/chats/{chat_id}/typing Clears a typing indicator in an active direct or group chat. The same rules apply as for starting one. A `204` means Blujay accepted the request, not that an indicator was showing or was cleared. # Add a tag to a contact Source: https://docs.blujay.io/api-reference/contacts/add-a-tag-to-a-contact /api-reference/openapi.yaml post /v1/contacts/{contact_id}/tags Adds a tag to the contact and returns the contact. `tag_added` tells you whether the tag was new. # Assign a contact to a team member Source: https://docs.blujay.io/api-reference/contacts/assign-a-contact-to-a-team-member /api-reference/openapi.yaml patch /v1/contacts/{contact_id}/assignee Assigns the contact to a team member and returns the contact. Get member IDs from `GET /v1/team-members/assignable`. `assignment_changed` tells you whether the assignee changed. # Create a contact Source: https://docs.blujay.io/api-reference/contacts/create-a-contact /api-reference/openapi.yaml post /v1/contacts Creates a contact. `phone_number` is required; `email` and `name` are optional. Returns `409 CONTACT_ALREADY_EXISTS` if the phone number or email already belongs to a contact in your workspace. Creating a contact through the API doesn't send a webhook or start workflows that run on new contacts. To create or update in one call, use `POST /v1/contacts/import`. # Create or update a contact Source: https://docs.blujay.io/api-reference/contacts/create-or-update-a-contact /api-reference/openapi.yaml post /v1/contacts/import Creates or updates a contact by phone number. `phone_number` is required; `email` and `name` are optional. - If a contact has this phone number, Blujay updates it (`200`), including its email and name when you send them. - If no contact has this phone number, Blujay creates one (`201`). - If you send an email that already belongs to a different contact, Blujay returns `409 CONTACT_MATCH_CONFLICT` and changes nothing. This happens even when no contact has the phone number. The response's `created` and `updated` flags tell you which happened. Like create, import doesn't send a webhook or start workflows that run on new contacts. # Delete a contact Source: https://docs.blujay.io/api-reference/contacts/delete-a-contact /api-reference/openapi.yaml delete /v1/contacts/{contact_id} Deletes the contact. If the contact had opted out, the opt-out stays in place, so you still can't message that phone number or email. # Get a contact Source: https://docs.blujay.io/api-reference/contacts/get-a-contact /api-reference/openapi.yaml get /v1/contacts/{contact_id} Returns one contact, including its tags, stage, assignee and opt-out state. # List contacts Source: https://docs.blujay.io/api-reference/contacts/list-contacts /api-reference/openapi.yaml get /v1/contacts Returns the contacts in your workspace. # Mark a contact completed Source: https://docs.blujay.io/api-reference/contacts/mark-a-contact-completed /api-reference/openapi.yaml patch /v1/contacts/{contact_id}/stage Sets the contact's stage to `Completed` and returns the contact. `Completed` is the only stage you can set through the API. `stage_changed` tells you whether the stage changed. # Opt out a contact Source: https://docs.blujay.io/api-reference/contacts/opt-out-a-contact /api-reference/openapi.yaml post /v1/contacts/{contact_id}/suppress Opts the contact out of messages. Sends to an opted-out contact are rejected with `403 RECIPIENT_SUPPRESSED`. # Opt out a phone number or email Source: https://docs.blujay.io/api-reference/contacts/opt-out-a-phone-number-or-email /api-reference/openapi.yaml post /v1/contacts/suppressions Opts out a phone number or email address, even if it doesn't belong to a contact yet. Sends to that address are rejected with `403 RECIPIENT_SUPPRESSED`. # Remove a contact's opt-out Source: https://docs.blujay.io/api-reference/contacts/remove-a-contacts-opt-out /api-reference/openapi.yaml post /v1/contacts/{contact_id}/unsuppress Removes an explicit opt-out from the contact. # Search contacts Source: https://docs.blujay.io/api-reference/contacts/search-contacts /api-reference/openapi.yaml get /v1/contacts/search Finds contacts by phone number, email address, or free text (`q`). Returns up to `limit` best matches. Results aren't paginated. # Update a contact Source: https://docs.blujay.io/api-reference/contacts/update-a-contact /api-reference/openapi.yaml patch /v1/contacts/{contact_id} Changes a contact's phone number, email or name. Fields you leave out keep their values. A phone number or email that already belongs to another contact returns `409 CONTACT_ALREADY_EXISTS`. To change tags, stage or assignee, use their own endpoints. # API reference Source: https://docs.blujay.io/api-reference/introduction Every Blujay API endpoint, with a playground you can call from your browser. The reference is generated from Blujay's OpenAPI 3.1 description. Each endpoint page shows its parameters, request and response schemas, the errors worth handling, and ready-to-run examples in cURL, JavaScript, Python, Go, Ruby and PHP. > **[Download the OpenAPI file](https://raw.githubusercontent.com/blujayhq/api-docs/main/api-reference/openapi.yaml)** > > Import it into Postman, Insomnia or a code generator. > **[Postman collection](https://raw.githubusercontent.com/blujayhq/api-docs/main/api-reference/blujay.postman_collection.json)** > > Every endpoint as a ready-made request. Import it into Postman, set `apiKey`, and send. > **[Build with AI](/build-with-ai)** > > `llms.txt`, the docs MCP server and prompts for your assistant. > **[Quickstart](/quickstart)** > > Send your first message in about five minutes. ## Use the Postman collection 1. In Postman, choose **Import**, paste this link and import: ```text theme={null} https://raw.githubusercontent.com/blujayhq/api-docs/main/api-reference/blujay.postman_collection.json ``` 2. Open the collection's **Variables** and paste your API key into `apiKey`. `baseUrl` is already set to `https://api.blujay.io/v1`. 3. Pick a request and send it. > [!WARNING] > > Requests that take an `Idempotency-Key` fill it with `{{$guid}}`, a fresh value on every click. So clicking **Send** twice on **Start a chat** sends two messages. To retry safely, replace the header with a fixed value first. List requests ship with a placeholder `cursor` variable; leave it empty or the request returns [`INVALID_CURSOR`](/errors/invalid-cursor). Requests act on your real workspace: a send really sends a message. ## Base URL ```text theme={null} https://api.blujay.io/v1 ``` ## Authentication Send `Authorization: Bearer blu_your_api_key` with your workspace [API key](/authentication). In the playground, paste your key into the **Authorization** field to try a call. ## Try it Use **Try it** on any endpoint page to send a request with your API key. > [!WARNING] > > The playground sends real requests with your live key. There is no test mode: a send from the playground really sends a message, and a delete really deletes. Use your own phone number as the recipient. ## Next steps > **[Making requests](/making-requests)** > > Response format, IDs, pagination and tracing. > **[Errors](/errors)** > > Every error code, what it means and how to fix it. # Edit a message Source: https://docs.blujay.io/api-reference/messages/edit-a-message /api-reference/openapi.yaml patch /v1/messages/{message_id} Changes the text of an iMessage you sent from this workspace. The edit also shows on the recipient's device. Apple's limits apply: you can edit a message within 15 minutes of sending it, up to 5 times. Only text-only messages can be edited, so `part_index` is always `0`. If the message already has the text you send, Blujay returns the message without editing it again. Send an `Idempotency-Key` so a retry after a lost response is safe. To hear about every edit, including edits your contacts make, subscribe to the `message.edited` webhook. If editing is switched off for your workspace, the request returns `400 FEATURE_NOT_ENABLED`. # Get a message Source: https://docs.blujay.io/api-reference/messages/get-a-message /api-reference/openapi.yaml get /v1/messages/{message_id} Returns one message, including its current status and the service that carried it. Add `include=edit_history` to get earlier versions of an edited message. # List a reply thread Source: https://docs.blujay.io/api-reference/messages/list-a-reply-thread /api-reference/openapi.yaml get /v1/messages/{message_id}/thread Given any message in an iMessage reply thread, returns the original message and every reply, including replies that are still sending. Reactions are never part of a thread. A message that isn't in a thread, or a reaction, returns only itself. One request covers at most the 1,000 nearest messages in the thread, in the order you ask for. # List messages Source: https://docs.blujay.io/api-reference/messages/list-messages /api-reference/openapi.yaml get /v1/messages Returns messages across all chats in your workspace, one page at a time, most recently updated first. Filter by `status`, or by `updated_after` to pick up changes since your last sync. Sort a page by `created_at` to display messages in order. # List messages in a chat Source: https://docs.blujay.io/api-reference/messages/list-messages-in-a-chat /api-reference/openapi.yaml get /v1/chats/{chat_id}/messages Returns the messages in one chat, one page at a time, most recently updated first (a read receipt or an edit moves an older message up). Sort a page by `created_at` to display a conversation in order. # Retry a failed message Source: https://docs.blujay.io/api-reference/messages/retry-a-failed-message /api-reference/openapi.yaml post /v1/messages/{message_id}/retry Sends a failed outbound message again. Only direct-chat messages that failed before they left Blujay can be retried. Group messages, and messages that may already have reached the recipient, return `409 RETRY_NOT_ALLOWED`. `RETRY_NOT_ALLOWED` means Blujay can't safely send this message again, often because it may already have reached the recipient. Don't automatically send a replacement. Check the message's status and the conversation first, for example for a reply. Send a new message only when you're sure the first one didn't arrive, or when a possible duplicate is acceptable. Inbound messages return `400 INVALID_MESSAGE`. Send an `Idempotency-Key` so you can safely retry this call. # Send a message Source: https://docs.blujay.io/api-reference/messages/send-a-message /api-reference/openapi.yaml post /v1/chats/{chat_id}/messages Sends a message in an existing chat. Blujay returns `202` as soon as the message is accepted and sends it in the background. Follow its progress with message webhooks or `GET /v1/messages/{message_id}`. A message can have `text`, `link`, `media` and `vcard` parts. A `link` part must be the only part. Optional features: - `reply_to` sends an iMessage threaded reply. It isn't available for SMS or RCS chats. - `effect` adds an iMessage screen or bubble effect to a text message. - `text_decorations` styles text in direct iMessage chats. - `preferred_service` picks iMessage, RCS or SMS for a direct chat. Omit it to let Blujay choose. Group chats don't accept it. If an effect or text style can't be delivered because the message goes out as SMS or RCS, the text is still sent and the message lists what was dropped in `degradations`. These limits protect your number's reputation. Until the contact replies to this number with a text or a tapback: - Links, media and contact cards return `400 PRE_REPLY_CONTENT_BLOCKED`. - Text longer than 300 characters returns `400 PRE_REPLY_LENGTH_EXCEEDED`. At any time, before or after a first reply, at most 6 texts go to a contact in a row without a response (a text or tapback from them, or a message from them in a group) on any of your numbers. Calls don't count as a response. The next returns `400 PRE_REPLY_LIMIT_REACHED`. With an API key, `pre_reply_override: true` sends a 7th once. The count starts again when the contact responds. In a group, the content limits apply until someone in the group replies, and the 6-text limit counts the group's texts since anyone in it last wrote. These errors are never retryable and never `429`. Send an `Idempotency-Key` header (or `message.idempotency_key`) so you can safely retry. Repeating a request with the same key returns `200` with the original response instead of sending again. Errors worth handling: - `400 PRE_REPLY_*`: the contact hasn't replied yet, or hasn't responded to your last 6 texts (see the limits above). - `403 RECIPIENT_SUPPRESSED`: the recipient opted out. - `403 CONTACT_NOT_VERIFIED`: on the Free Plan, the contact hasn't texted your Free Plan number to verify yet. - `403 CONTACT_MUST_TEXT_FIRST`: the number is on the AI Agent plan: it only replies to people who text it first, and can't send to group chats. - `429 RATE_LIMIT_EXCEEDED` or `429 QUEUE_FULL`: the sending number hit a limit. Wait `Retry-After` seconds. Each number's current limits are in `GET /v1/phone-numbers` under `limits`. - `409 SENDER_RETIRED`: the chat's number was retired. Start a new chat from an active number. - `503 QUEUE_UNAVAILABLE`: Blujay couldn't accept the message right now. Retry with the same `Idempotency-Key`. # Unsend a message Source: https://docs.blujay.io/api-reference/messages/unsend-a-message /api-reference/openapi.yaml post /v1/messages/{message_id}/unsend Unsends a text-only iMessage you sent from this workspace. Apple allows this within 2 minutes of sending. Recipients on older Apple software may still see the message. The message stays readable through the API with `unsent_at` and `unsent_scope` set, and its text is removed from `parts`. The unsent text is never returned. Repeating an unsend returns the current message. Send an `Idempotency-Key` so you can safely retry. # Get a phone number Source: https://docs.blujay.io/api-reference/phone-numbers/get-a-phone-number /api-reference/openapi.yaml get /v1/phone-numbers/{phone_number_id} Returns one of your Blujay numbers with the same detail as the list. A number that has been retired returns `status: unavailable` and never reports current health, capacity, label or calling readiness. # List phone numbers Source: https://docs.blujay.io/api-reference/phone-numbers/list-phone-numbers /api-reference/openapi.yaml get /v1/phone-numbers Returns the Blujay numbers in your workspace. For each number you get whether it can send right now (`send_status`), what it supports (`capabilities`), and its link rules (`outbound_policy`). Dedicated numbers also report `health_status`, `queue`, and `limits`. Sending limits and queue capacity, such as how many new contacts a number can message per day, are specific to each number and change over time. Read the live values from `limits` and `queue` instead of hard-coding them. # List assignable team members Source: https://docs.blujay.io/api-reference/team-members/list-assignable-team-members /api-reference/openapi.yaml get /v1/team-members/assignable Returns the active team members in your workspace that you can assign contacts to. Pass a member's `id` to `PATCH /v1/contacts/{contact_id}/assignee`. # Delete a saved vCard Source: https://docs.blujay.io/api-reference/vcards/delete-a-saved-vcard /api-reference/openapi.yaml delete /v1/vcards/{vcard_id} Deletes a saved contact card. Messages that already included the card keep their copy. # Get a saved vCard Source: https://docs.blujay.io/api-reference/vcards/get-a-saved-vcard /api-reference/openapi.yaml get /v1/vcards/{vcard_id} Returns one saved contact card. # List saved vCards Source: https://docs.blujay.io/api-reference/vcards/list-saved-vcards /api-reference/openapi.yaml get /v1/vcards Returns your workspace's saved contact cards, most recently updated first. # Save a vCard Source: https://docs.blujay.io/api-reference/vcards/save-a-vcard /api-reference/openapi.yaml post /v1/vcards Saves a contact card for your workspace, such as your support line or a colleague's details. To send it, add a `vcard` part with its ID to a message. Card photos are managed in the Blujay app. # Update a saved vCard Source: https://docs.blujay.io/api-reference/vcards/update-a-saved-vcard /api-reference/openapi.yaml patch /v1/vcards/{vcard_id} Changes only the fields you send. The card must keep at least one phone number or email. # Create a webhook subscription Source: https://docs.blujay.io/api-reference/webhooks/create-a-webhook-subscription /api-reference/openapi.yaml post /v1/webhook-subscriptions Subscribes an HTTPS endpoint to the events you choose. The response includes `signing_secret`, which is shown only this once. Store it securely; you need it to verify webhook signatures. To change the secret, create a new subscription and delete the old one. New subscriptions start `active` unless you set `status: disabled`. Leave out `phone_number_filter` to get events for all of your numbers, including numbers you add later. The URL must use HTTPS and resolve to a public address. Errors worth handling (all `400`): - `UNSUPPORTED_WEBHOOK_EVENT`: an event type isn't recognized. - `FEATURE_NOT_ENABLED`: an event type is recognized but can't be subscribed to. - `WEBHOOK_URL_INVALID`, `WEBHOOK_URL_UNSAFE` or `WEBHOOK_URL_UNRESOLVABLE`: the URL isn't HTTPS, points to a private address, or doesn't resolve. # Delete a webhook subscription Source: https://docs.blujay.io/api-reference/webhooks/delete-a-webhook-subscription /api-reference/openapi.yaml delete /v1/webhook-subscriptions/{subscription_id} Deletes the subscription so it stops receiving new events. Deleting it again returns `404`. # Get a webhook subscription Source: https://docs.blujay.io/api-reference/webhooks/get-a-webhook-subscription /api-reference/openapi.yaml get /v1/webhook-subscriptions/{subscription_id} Returns one webhook subscription. The signing secret isn't included. # List webhook delivery attempts Source: https://docs.blujay.io/api-reference/webhooks/list-webhook-delivery-attempts /api-reference/openapi.yaml get /v1/webhook-subscriptions/{subscription_id}/deliveries Returns the attempts Blujay made to deliver events to this subscription. Each attempt shows the HTTP status your endpoint returned and, when it failed, an `error_code` and `error_message`. Filter by `event_id` to see every attempt for one event. # List webhook subscriptions Source: https://docs.blujay.io/api-reference/webhooks/list-webhook-subscriptions /api-reference/openapi.yaml get /v1/webhook-subscriptions Returns your workspace's webhook subscriptions. Signing secrets aren't included; each subscription shows only `signing_secret_hint`. # Replay a webhook event Source: https://docs.blujay.io/api-reference/webhooks/replay-a-webhook-event /api-reference/openapi.yaml post /v1/webhook-subscriptions/{subscription_id}/events/{event_id}/replay Requests that a stored event be sent to this subscription again. The event keeps its original `event_id`. The subscription must be `active` and still include the event's type, or you get `409 WEBHOOK_SUBSCRIPTION_INACTIVE`. If the subscription's `phone_number_filter` excludes the number the event belongs to, you get `409 WEBHOOK_PHONE_NUMBER_EXCLUDED`. Send an `Idempotency-Key` so you can safely retry this call. # Update a webhook subscription Source: https://docs.blujay.io/api-reference/webhooks/update-a-webhook-subscription /api-reference/openapi.yaml patch /v1/webhook-subscriptions/{subscription_id} Changes only the fields you send. Set `status: disabled` to pause a subscription; disabled subscriptions don't receive new events. Changing `webhook_version` affects only events created after the change. Errors match create: `UNSUPPORTED_WEBHOOK_EVENT`, `FEATURE_NOT_ENABLED` for event types you can't add, and the `WEBHOOK_URL_*` codes for a bad URL. # Webhook event Source: https://docs.blujay.io/api-reference/webhooks/webhook-event /api-reference/openapi.yaml webhook publicEvent Blujay sends each event as an HTTPS `POST` with a JSON body to the URL on your subscription. Respond with any `2xx` status to acknowledge it. You can see every delivery attempt with `GET /v1/webhook-subscriptions/{subscription_id}/deliveries`. **Verify the signature.** Webhooks are signed following the [Standard Webhooks](https://www.standardwebhooks.com) specification, so you can use any Standard Webhooks library. To verify by hand: 1. Take the `webhook-id` and `webhook-timestamp` headers and the raw request body, exactly as received. 2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{body}`. 3. Remove the `whsec_` prefix from your subscription's `signing_secret` and base64-decode the rest. That is your key. 4. Compute an HMAC-SHA256 of the signed content with the key, and base64-encode the result. 5. Compare `v1,` with the `webhook-signature` header using a constant-time comparison. The Standard Webhooks format allows several space-separated signatures in this header, so accept the request if any of them matches. Also reject requests whose `webhook-timestamp` is more than about five minutes from your server's clock. **Handle duplicates.** The same event can arrive more than once. `webhook-id` equals the body's `event_id` and stays the same for every delivery of an event, so use it to skip events you've already processed. # Enroll a contact in a workflow Source: https://docs.blujay.io/api-reference/workflows/enroll-a-contact-in-a-workflow /api-reference/openapi.yaml post /v1/workflows/{workflow_id}/enrollments Starts a manual workflow for one contact. `request_id` is required and makes retries safe: sending the same `request_id` again returns `200` with the original result instead of enrolling the contact twice. A `200` can also mean no new run started; `enrollment_status` and `no_op_reason` say what happened. `metadata` is optional context about the enrollment. It isn't available as workflow variables. Returns `409 WORKFLOW_LIFECYCLE_ERROR` when the workflow isn't active or doesn't start manually, and `422 WORKFLOW_VALIDATION_ERROR` when the workflow can't run yet, for example because it has no published version. # List workflows Source: https://docs.blujay.io/api-reference/workflows/list-workflows /api-reference/openapi.yaml get /v1/workflows Returns the active, published workflows in your workspace that start manually. Each has a `workflow_id` and `label`; use the ID to enroll a contact. Workflow steps and run history aren't returned. # Authentication Source: https://docs.blujay.io/authentication Authenticate every request with your workspace API key. Every request needs your workspace API key as a bearer token in the `Authorization` header: ```bash theme={null} curl https://api.blujay.io/v1/me \ -H "Authorization: Bearer blu_your_api_key" ``` ## API keys Workspace owners and admins find the key in [Blujay](https://app.blujay.io) under **API** in the sidebar, in the **API Token** card. Use the eye button to reveal it. A workspace has one API key today. It has full access to its workspace: it can send messages from your numbers and read every chat. Treat it like a password. * Keep it on your server, in an environment variable or a secret manager. * Never put it in a web page, a mobile app, a URL or source control. * Don't log it, and don't send it to support. Send the `trace_id` instead. > [!WARNING] > > **Call the API from your server.** Browsers block requests to the API from other websites, so code running in a web page can't call it directly, and shouldn't: it would expose your key. Send requests from your backend and pass the results to your page. To rotate the key, or if it leaks, email [support@blujay.io](mailto:support@blujay.io). There is no self-service rotation and no second key yet. A revoked key returns [`API_KEY_REVOKED`](/errors/api-key-revoked). The key from the **API** tab doesn't expire; a key that support issued with an expiry date returns [`API_KEY_EXPIRED`](/errors/api-key-expired) after that date. ## Test and live There is one mode: live. Keys don't have a test or sandbox variant, and every call acts on your real workspace. Messages you send are delivered, and contacts you create are saved. To test safely: * Send to your own phone number first. * Use a clear prefix in your `Idempotency-Key` values (for example `test-…`) so you can find test sends quickly. * Point a webhook subscription at a test endpoint. If you have more than one number, filter the subscription to the number you test with. See [Select phone numbers](/webhooks/overview#select-phone-numbers). ## Check a credential `GET /v1/me` tells you which workspace and key a request uses: ```json theme={null} { "status": "success", "data": { "workspace_id": "workspace_abc123", "workspace_label": "Acme Support", "credential": { "type": "api_key", "id": "3f6c2a7e-1b9d-4e5a-9c1f-2d7b8a6e4c10", "status": "active" }, "api_key": { "id": "3f6c2a7e-1b9d-4e5a-9c1f-2d7b8a6e4c10", "status": "active" } }, "trace_id": "trc_9d1f0a7c2b4e", "timestamp": "2026-10-14T17:00:00.000Z" } ``` `credential` and `api_key` describe the same key. Prefer `credential`; `api_key` stays for older clients. Credential IDs are plain UUIDs, not prefixed IDs. ## Authentication errors | Code | Status | Meaning | | - | - | - | | [`UNAUTHORIZED`](/errors/unauthorized) | 401 | No API key, or not a valid one. | | [`API_KEY_REVOKED`](/errors/api-key-revoked) | 401 | The key was revoked. | | [`API_KEY_EXPIRED`](/errors/api-key-expired) | 401 | A key that support issued with an expiry date passed it. | Repeated failed authentication from one IP address is limited to 60 attempts a minute. See [Rate limits](/rate-limits). ## Next steps > **[Quickstart](/quickstart)** > > Send your first message in about five minutes. > **[Making requests](/making-requests)** > > Response format, IDs, pagination and tracing. # Sending best practices Source: https://docs.blujay.io/best-practices Advice for integrations that get replies and keep your Blujay numbers healthy. The hard rules live on Sending rules. Apple and the carriers decide whether your messages arrive based on how people respond to them. Messages that get replies keep a number healthy. Messages that go unanswered, or get reported as junk, get a number filtered or blocked, for everyone it messages. These rules apply to every message your integration sends. ## Every new contact **1. Say who you are** Open with your name and why you're writing, in one short line. **2. Ask one easy question** Something they can answer from the lock screen, with no link or attachment. **3. Wait for a reply** Don't send more until they answer. A reply is what tells Apple and the carriers your messages are wanted. **4. Then send links, photos and files** Once they've replied, follow up with anything you held back, with a sentence that says what it is. **5. Answer quickly** Reply soon after they write back, and [show you're typing](#use-typing-indicators) while you prepare a longer answer. ## Dos and don'ts | Do | Don't | | - | - | | ✅ Send messages a person would send: short, specific, one idea each | ❌ Send long, templated blasts | | ✅ Wait for a reply before sending links, photos or files | ❌ Send links or attachments before they reply (Blujay rejects them) | | ✅ Spread new conversations across the day and across your numbers | ❌ Start hundreds of new conversations at once from one number | | ✅ Read `send_status` and `limits` before a batch | ❌ Retry blindly until a send succeeds | | ✅ Stop after two unanswered follow-ups | ❌ Keep texting someone who doesn't respond (Blujay rejects the 7th in a row) | | ✅ Opt out anyone who asks you to stop | ❌ Keep messaging after a "stop" | ## Write like a person Each message should read as if one person wrote it to one other person. * Say who you are and why you're writing in the first line. * Ask one question that's easy to answer from a lock screen. * Keep it to a line or two. Send a second message instead of a paragraph. * Vary the wording between recipients. Identical messages sent to many people read as automated, to people and to spam filters. ## The rules Blujay enforces Blujay rejects links, attachments, contact cards and texts over 300 characters until a contact replies to your number, and sends at most 6 texts in a row to anyone who hasn't responded. These are hard rules with their own error codes. They are listed once, with the exact errors, on [Sending rules](/messaging/sending-rules). Everything on this page is advice for staying well inside them. ## Pace new conversations A Dedicated number can message up to 50 people it has never texted before per day. A new or recently reassigned number may start lower and reaches the full limit over about its first month. People who messaged you first never count against it, and you can always keep messaging people you already talk to. The live values are in `limits` on [`GET /v1/phone-numbers`](/phone-numbers). * Read [`GET /v1/phone-numbers`](/phone-numbers) before a batch, and stop when `limits.new_contacts.remaining_today` or `queue.remaining` reaches zero. * Spread sends over the day instead of sending them all at once. * If you have several numbers, spread new conversations across them. * Keep each person on the same number. Once a number has texted someone, keep using it for that person: switching numbers mid-conversation looks like spam to carriers. * On [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded), wait for `Retry-After` or schedule the send for the next day. Don't switch numbers to get around a limit for the same recipient. ## Follow up sparingly * At most two follow-ups a week to any one person, and stop after two follow-ups with no reply. That's well under Blujay's limit of 6 in a row, which is a backstop, not a target. * Count everything a person gets from you, including messages from automations and from teammates on other numbers. Blujay counts them all toward the limit. * To restart a quiet conversation, ask something short: *"Still worth keeping this open?"* ## Respect opt-outs When someone asks you to stop, [opt them out](/opt-outs) right away. Blujay then refuses API sends to them with [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed), so a bug in your code can't message them again. ## Aim for replies A number stays healthy when the people it texts write back. Aim for **at least 3 in 10** of the people you start a conversation with to reply. Below that, the number's [health score](/phone-numbers#number-health) drops. Short, personal, specific messages get the most replies. ## Use typing indicators Before you send a reply in an iMessage chat, start a typing indicator with [`POST /v1/chats/{chat_id}/typing`](/messaging/typing-and-read-receipts). The other person sees the typing bubble, so an automated answer feels attended. It's best effort and shows only in iMessage chats; SMS and RCS chats accept the request and show nothing. ## Watch your numbers * Check `send_status.allowed` and `health_status` on [`GET /v1/phone-numbers`](/phone-numbers#number-health) regularly. A `degraded` or `critical` number needs fewer new conversations and more replies. * Reply quickly when someone answers. Conversations with real back-and-forth are what keep a number healthy. * Subscribe to [`message.failed`](/webhooks/events#message-lifecycle) and watch for a rise in failures. ## Check your integration with AI When your integration works, have your AI coding assistant review it against these docs. The prompt is on [Build with AI](/build-with-ai#review-your-integration). ## Next steps > **[Sending rules](/messaging/sending-rules)** > > The hard rules and their error codes. > **[Phone numbers and limits](/phone-numbers)** > > Live sending status, limits and health. > **[Rate limits](/rate-limits)** > > How limits work and how to back off. # Build with AI Source: https://docs.blujay.io/build-with-ai The machine-readable docs (llms.txt, OpenAPI, Postman, MCP) and the prompts that let an AI coding assistant write and review your integration. These docs are built to be read by AI coding assistants such as Claude, ChatGPT, Cursor and Codex, as well as by people. This page is the one place for every machine-readable form of the docs. ## Give your assistant the whole docs | What | Address | Use it for | | - | - | - | | `llms.txt` | `https://docs.blujay.io/llms.txt` | A short index of every page. | | `llms-full.txt` | `https://docs.blujay.io/llms-full.txt` | Every page in one file. Paste the address into your assistant. | | OpenAPI 3.1 file | `https://raw.githubusercontent.com/blujayhq/api-docs/main/api-reference/openapi.yaml` | The exact definition of every endpoint, for code generators and assistants. The [API reference](/api-reference/introduction) is generated from it. | | Postman collection | `https://raw.githubusercontent.com/blujayhq/api-docs/main/api-reference/blujay.postman_collection.json` | Every endpoint as a ready-made request. See [Use the Postman collection](/api-reference/introduction#use-the-postman-collection) before you click **Send**. | | Docs MCP server | `https://docs.blujay.io/mcp` | Lets an MCP-capable assistant search these docs while it works. | ## Copy one page Every page has a **Copy page** menu at the top. Use it to copy the page as Markdown, or to open it directly in ChatGPT, Claude or Cursor. ## A starting prompt Paste this into your assistant, then describe what you want to build: ```text theme={null} You are helping me integrate the Blujay API (iMessage, RCS and SMS messaging). Read https://docs.blujay.io/llms-full.txt first and follow it exactly. Rules: keep the API key on the server; send an Idempotency-Key on every send and reuse it on retries; until a contact replies, send short text only (no links, photos, files or contact cards, 300 characters or less) and never more than 6 texts in a row without a response; recipients are US phone numbers; treat 202 as queued, not delivered; verify webhook signatures on the raw body and deduplicate by event_id; check GET /v1/phone-numbers send_status and limits before sending in bulk; treat error.retryable as the only signal to retry. ``` ## Review your integration When your integration works, ask your assistant to check it against the docs. This is the only copy of this prompt; other pages link here. ```text theme={null} Review my Blujay integration against https://docs.blujay.io/llms-full.txt. List anything that could send a duplicate message, message someone who opted out, send a link, attachment or long text before the contact has replied, miss or double-process a webhook, ignore Retry-After, or leak the API key. ``` ## Next steps > **[Quickstart](/quickstart)** > > Send your first message. > **[API reference](/api-reference/introduction)** > > Every endpoint, with a playground. > **[Examples](/examples)** > > Complete programs to copy. # Contacts Source: https://docs.blujay.io/contacts Create, find, update and delete contacts, and set their tags, stage and assignee. A contact is a person saved in your Blujay workspace, identified by a US phone number, with an optional email address and name. Contacts carry tags, a stage and an assigned team member, and Blujay links them to their chats and messages. Use these endpoints to keep Blujay in sync with your own system: create or update contacts as they change on your side, look them up before you send, and set their tags, stage and assignee. Opt-out endpoints are covered in [Opt-outs](/opt-outs). ## Create a contact `POST /v1/contacts` creates a contact. `phone_number` is required; `email` and `name` are optional. A contact created through the API always has a phone number. Contacts created in the Blujay app can have only an email; the API returns them with no `phone_number`. Sends take phone numbers, so an email-only contact can't be messaged through the API until a number is added. Blujay accepts any US number written with 10 digits, or 11 digits starting with `1`, with or without `+`, spaces, dashes or parentheses: `+15555550123`, `15555550123`, `(555) 555-0123` and `555-555-0123` are the same number. A `+` followed by anything other than `1` is rejected. Blujay stores and returns the number in E.164 format (`+15555550123`). Anything else returns `400` [`VALIDATION_ERROR`](/errors/validation-error). ```bash curl theme={null} curl https://api.blujay.io/v1/contacts \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+15555550123", "email": "ava@example.com", "name": "Ava Chen" }' ``` ```json Response (201) theme={null} { "status": "success", "data": { "contact_id": "contact_abc123", "phone_number": "+15555550123", "email": "ava@example.com", "name": "Ava Chen", "tags": [], "stage": "Active", "assigned_team_member": null, "opted_out": false, "messaging_allowed": true, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:00:00.000Z" }, "trace_id": "trc_1a3c5e7b9d0f", "timestamp": "2026-10-14T17:00:00.050Z" } ``` If the phone number or email already belongs to a contact in your workspace, you get `409` [`CONTACT_ALREADY_EXISTS`](/errors/contact-already-exists). To create or update in one call, use import instead. Creating a contact doesn't send a webhook and doesn't start workflows that run when a contact is created. This endpoint doesn't use an `Idempotency-Key`; a repeated create returns `409`, so treat that as success when you are retrying. ## Create or update with import `POST /v1/contacts/import` takes the same body as create and matches on the phone number: * If no contact has this phone number, Blujay creates one and returns `201` with `"created": true`. * If a contact has this phone number, Blujay updates its `email` and `name` with the values you send and returns `200` with `"updated": true`. * If the email you send already belongs to another contact, one that doesn't have this phone number, Blujay returns `409` [`CONTACT_MATCH_CONFLICT`](/errors/contact-match-conflict) and changes nothing. ```bash curl theme={null} curl https://api.blujay.io/v1/contacts/import \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+15555550123", "email": "ava.chen@example.com", "name": "Ava Chen" }' ``` ```json Response (200) theme={null} { "status": "success", "data": { "contact_id": "contact_abc123", "phone_number": "+15555550123", "email": "ava.chen@example.com", "name": "Ava Chen", "tags": ["VIP"], "stage": "Active", "assigned_team_member": null, "opted_out": false, "messaging_allowed": true, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:30:00.000Z", "created": false, "updated": true }, "trace_id": "trc_7d9f1b3a5c2e", "timestamp": "2026-10-14T17:30:00.040Z" } ``` Like create, import doesn't send a webhook and doesn't start workflows that run when a contact is created. Import is safe to repeat: sending the same body again updates the same contact. ## Find contacts List every contact, most recently updated first, with `GET /v1/contacts`. Pages hold 25 contacts by default and up to 100 with `limit`. When `has_more` is `true`, pass `next_cursor` as `cursor`. ```bash curl theme={null} curl "https://api.blujay.io/v1/contacts?limit=50" \ -H "Authorization: Bearer blu_your_api_key" ``` To look someone up, use `GET /v1/contacts/search` with at least one of these: * `phone_number`: an exact match, in any accepted US format. * `email`: an exact match, case-insensitive. * `q`: a case-insensitive substring match against name, phone number (as stored, `+1…`) and email. `q=555-0123` finds nothing; `q=5550123` does. A contact matches when any of the parameters you send matches it. Search returns up to `limit` matches (10 by default, 25 at most), most recently updated first. Results aren't paginated, so narrow the search rather than page it. ```bash curl theme={null} curl "https://api.blujay.io/v1/contacts/search?phone_number=%2B15555550123" \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "data": [ { "contact_id": "contact_abc123", "phone_number": "+15555550123", "email": "ava.chen@example.com", "name": "Ava Chen", "tags": ["VIP"], "stage": "Active", "assigned_team_member": null, "opted_out": false, "messaging_allowed": true, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:30:00.000Z" } ] }, "trace_id": "trc_3e5a7c9b1d0f", "timestamp": "2026-10-14T17:31:00.000Z" } ``` > [!WARNING] > > URL-encode `+` as `%2B` in query strings. A raw `+` arrives as a space, and `phone_number= 15555550123` returns [`VALIDATION_ERROR`](/errors/validation-error). Read one contact with `GET /v1/contacts/{contact_id}`. ### Fields to check before you send | Field | Meaning | | - | - | | `opted_out` | `true` when the person has opted out, through the API, in Blujay, or by texting STOP to a Dedicated number. `opted_out_at` and `opted_out_source` say when and how; see [Opt-outs](/opt-outs). | | `messaging_allowed` | `true` when Blujay will accept a send to this person. Today it is `false` exactly when `opted_out` is `true`. Check `messaging_allowed`: it is the field that will reflect any other block Blujay adds, while `opted_out` stays specific to opt-outs. | | `stage` | The contact's stage, as a string. New contacts start as `Active`. Stages are set in the Blujay app; the API can only set `Completed` (see below). | Neither field tells you about Free Plan verification or the pre-reply rules. See [Sending rules](/messaging/sending-rules#who-you-can-message). ## Update a contact `PATCH /v1/contacts/{contact_id}` changes `phone_number`, `email` or `name`. Send at least one field; fields you leave out keep their values. A phone number or email that already belongs to another contact returns `409` [`CONTACT_ALREADY_EXISTS`](/errors/contact-already-exists). ```bash curl theme={null} curl -X PATCH https://api.blujay.io/v1/contacts/contact_abc123 \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "name": "Ava M. Chen" }' ``` Tags, stage and assignee have their own endpoints, described below. ## Delete a contact `DELETE /v1/contacts/{contact_id}` deletes the contact and returns `204 No Content`. ```bash curl theme={null} curl -X DELETE https://api.blujay.io/v1/contacts/contact_abc123 \ -H "Authorization: Bearer blu_your_api_key" ``` What deleting does: * The contact record is removed. Reading it again returns `404` [`NOT_FOUND`](/errors/not-found). * Chats and messages with that person stay, and you can still read them. They no longer link to a contact, so they stop carrying this `contact_id`. A contact you create later with the same number gets a new `contact_id`. * An opt-out stays in place for their phone number or email, so you still can't message them. See [Opt-outs](/opt-outs). * On the Free Plan, the person's verification is removed with the contact. They have to text your number and be verified again before you can message them; see [Plans](/plans#free-plan). ## Add a tag `POST /v1/contacts/{contact_id}/tags` adds one tag (up to 100 characters) and returns the contact. `tag_added` is `true` when the tag was new to the contact and `false` when the contact already had it. ```bash curl theme={null} curl https://api.blujay.io/v1/contacts/contact_abc123/tags \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "tag": "VIP" }' ``` When the tag is new, Blujay sends the `contact.tag_added` webhook and starts any workflows that run when that tag is added. Adding a tag the contact already has does neither. There is no endpoint to remove a tag or to list the tags in your workspace; remove tags in the Blujay app. ## Mark a contact completed `PATCH /v1/contacts/{contact_id}/stage` sets the contact's stage. `Completed` is the only stage you can set through the API; any other value returns `400` [`VALIDATION_ERROR`](/errors/validation-error). `stage_changed` tells you whether the stage changed. There is no way to set a contact back to an earlier stage through the API. ```bash curl theme={null} curl -X PATCH https://api.blujay.io/v1/contacts/contact_abc123/stage \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "stage": "Completed" }' ``` ## Assign a contact First get the team members you can assign with `GET /v1/team-members/assignable`. It returns the active members of your workspace. ```bash curl theme={null} curl https://api.blujay.io/v1/team-members/assignable \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "team_members": [ { "id": "member_abc123", "name": "Sam Rivera", "email": "sam@example.com" } ] }, "trace_id": "trc_9b1d3f5a7c0e", "timestamp": "2026-10-14T17:40:00.000Z" } ``` Then pass the member's `id` as `team_member_id` to `PATCH /v1/contacts/{contact_id}/assignee`. The response is the contact with `assigned_team_member` set and `assignment_changed`, which is `true` when the assignee changed. A change also sends the `contact.assigned` webhook. ```bash curl theme={null} curl -X PATCH https://api.blujay.io/v1/contacts/contact_abc123/assignee \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "team_member_id": "member_abc123" }' ``` There is no endpoint to unassign a contact. `contact.assigned` carries `assigned_team_member: null` only when someone unassigns the contact in the Blujay app. ## Limitations * US phone numbers only. Any other number returns `400` [`VALIDATION_ERROR`](/errors/validation-error). * One contact per phone number and one per email in a workspace: `409` [`CONTACT_ALREADY_EXISTS`](/errors/contact-already-exists) or [`CONTACT_MATCH_CONFLICT`](/errors/contact-match-conflict). * No tag removal, no tag list, no unassign, and no stage other than `Completed` through the API. * No filter on `GET /v1/contacts` by tag, stage, assignee or `opted_out`. Search covers name, phone number and email only, up to 25 results. * Creating or updating a contact doesn't make them messageable. Free Plan verification ([`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified)), the AI Agent inbound-first rule ([`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)) and opt-outs ([`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed)) still apply. See [Plans](/plans). * Creating a contact sends no webhook and starts no workflow. Only a new tag does. ## Next steps > **[Opt-outs](/opt-outs)** > > Stop messages to a contact or an address, and lift an opt-out. > **[Workflows](/workflows)** > > Enroll a contact in a manual workflow. > **[Sending rules](/messaging/sending-rules)** > > Who you can message, and what a first message can contain. > **[Webhook events](/webhooks/events)** > > Hear about tag and assignment changes. # Debugging Source: https://docs.blujay.io/debugging Trace a request, find out why a message didn't arrive, and fix the most common problems. ## Trace a request Every response carries a trace ID in the `X-Trace-ID` header, and as `trace_id` in JSON bodies. Send your own `X-Request-ID` header too, and Blujay echoes it as `client_request_id` in error responses and most success responses (not on send responses; see [Making requests](/making-requests#tracing-requests)). Log both. ```bash theme={null} curl -i https://api.blujay.io/v1/me \ -H "Authorization: Bearer blu_your_api_key" \ -H "X-Request-ID: my-job-1042" ``` ```http theme={null} HTTP/2 200 x-trace-id: trc_9d1f0a7c2b4e ratelimit-limit: 600 ratelimit-remaining: 599 ratelimit-reset: 42 ``` Webhook events for a message you sent through the API carry the `trace_id` of the request that sent it, so you can match a send to its delivery updates. When you contact [support@blujay.io](mailto:support@blujay.io), include the trace ID, your request ID, the endpoint and the time. See [Support](/support). ## Follow one message 1. Read it: `GET /v1/messages/{message_id}`. Check `status` and `service`. A failed message has a `failure_code` only for a few causes you can act on; the full reason is in the `message.failed` webhook as `failure.code`. See [Track a message](/messaging/lifecycle#failures). 2. Find its events. Each webhook event you received for the message carries its `message_id` and `event_id`. With an `event_id`, list every delivery attempt: `GET /v1/webhook-subscriptions/{subscription_id}/deliveries?event_id=evt_abc123`. There is no lookup from a message to its events, so if you received nothing, list the subscription's recent deliveries and look for the `message_id`. See [Delivery and retries](/webhooks/delivery). 3. If your endpoint missed an event, fix it and [replay the event](/webhooks/delivery#replay-an-event). ## Common problems | Symptom | Likely cause | Fix | | - | - | - | | `401` on every call | Missing `Bearer`, a typo in the key, or a revoked key | Check the header is `Authorization: Bearer blu_…`. See [`UNAUTHORIZED`](/errors/unauthorized). | | `400` [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) | A link, or text that looks like a web address, in a first message | Send the link as a follow-up after the person replies. See [Sending rules](/messaging/sending-rules). | | `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) or [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded) | A link, photo, file or contact card, or a text over 300 characters, before the contact replied | Send a short text and wait for the reply. Not retryable. See [Sending rules](/messaging/sending-rules). | | `400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached) | 6 texts in a row with no response from the contact | Wait for them to respond. `pre_reply_override: true` sends one more, once. | | `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | Free Plan: the person hasn't texted your number first and been verified in the app | Verify them in the app, or move to a Dedicated number. See [Plans](/plans). | | `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | AI Agent: the person hasn't texted this number yet | Wait for them to text first. See [Plans](/plans). | | `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | The person opted out | Don't retry. See [Opt-outs](/opt-outs). | | `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) on sends | A sending limit on the number | Wait for `Retry-After`, check `limits` on [`GET /v1/phone-numbers`](/phone-numbers). | | `404` [`NOT_FOUND`](/errors/not-found) for an ID you received a moment ago | The ID belongs to another workspace, or the wrong key is in use | Check `GET /v1/me` shows the workspace you expect. | | `422` [`FROM_NUMBER_REQUIRED`](/errors/from-number-required) | Blujay couldn't pick a number for a direct chat | Pass `from` explicitly. A group chat always needs `from`. | | Message stuck at `sent` | SMS never reports delivery; iMessage and RCS report it when the phone receives it | Treat `sent` as final for SMS. See [Track a message](/messaging/lifecycle). | | `message.failed` with `failure.code: recipient_unavailable` | You required iMessage and the person can't receive it | Send with Auto, or check first with [iMessage, RCS or SMS](/messaging/choosing-a-service). | | No webhooks arriving | Subscription disabled, wrong events, a filtered phone number, or your endpoint returning non-`2xx` | Check the subscription and its deliveries list. | | Every webhook signature fails | Wrong secret, secret not base64-decoded, or a parsed body | See [Verify signatures](/webhooks/verify-signatures#troubleshooting). | | The same event arrives twice | Delivery is at least once | Deduplicate by `event_id`. | | `409` [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) | The same `Idempotency-Key` reused for a different request, for example on another chat | Make keys unique per business event. See [Idempotency](/idempotency#route-families). | ## Next steps > **[Errors](/errors)** > > Every error code and how to fix it. > **[FAQ](/faq)** > > Quick answers to common questions. # API_KEY_EXPIRED Source: https://docs.blujay.io/errors/api-key-expired API key expired | HTTP status | Retryable | | - | - | | `401` | No | ## What happened The API key has passed its expiry date. ## How to fix it Use a current key from the **API** tab in Blujay, or contact [support@blujay.io](mailto:support@blujay.io) if you need a new one. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "API_KEY_EXPIRED", "message": "API key expired", "type": "auth", "http_status": 401, "retryable": false, "doc_url": "https://docs.blujay.io/errors/api-key-expired", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # API_KEY_REVOKED Source: https://docs.blujay.io/errors/api-key-revoked API key revoked | HTTP status | Retryable | | - | - | | `401` | No | ## What happened The API key has been revoked and can no longer be used. ## How to fix it Use a current key from the **API** tab in Blujay, or contact [support@blujay.io](mailto:support@blujay.io) if you need a new one. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "API_KEY_REVOKED", "message": "API key revoked", "type": "auth", "http_status": 401, "retryable": false, "doc_url": "https://docs.blujay.io/errors/api-key-revoked", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_CAPACITY_EXCEEDED Source: https://docs.blujay.io/errors/attachment-capacity-exceeded Too many pending uploads | HTTP status | Retryable | | - | - | | `429` | Yes | ## What happened Your workspace has too many attachments waiting for their file bytes, or too many pending bytes. ## How to fix it Finish or abandon pending uploads, wait for `Retry-After` seconds, and try again. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_CAPACITY_EXCEEDED", "message": "Too many pending uploads", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/attachment-capacity-exceeded", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_FETCH_FAILED Source: https://docs.blujay.io/errors/attachment-fetch-failed Media URL could not be fetched | HTTP status | Retryable | | - | - | | `422` | No | ## What happened Blujay could not safely download the media URL. It may be private, redirect somewhere unsafe, time out, or return the wrong type. ## How to fix it Use a public HTTPS URL that returns the file directly, or upload the file with `POST /v1/attachments`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_FETCH_FAILED", "message": "Media URL could not be fetched", "type": "validation", "http_status": 422, "retryable": false, "doc_url": "https://docs.blujay.io/errors/attachment-fetch-failed", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_NOT_READY Source: https://docs.blujay.io/errors/attachment-not-ready Attachment not uploaded yet | HTTP status | Retryable | | - | - | | `409` | Yes | ## What happened The attachment exists, but its file bytes have not been uploaded. ## How to fix it PUT the file to the `upload_url` from `POST /v1/attachments`, then retry the read or send. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_NOT_READY", "message": "Attachment not uploaded yet", "type": "validation", "http_status": 409, "retryable": true, "doc_url": "https://docs.blujay.io/errors/attachment-not-ready", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_STORAGE_UNAVAILABLE Source: https://docs.blujay.io/errors/attachment-storage-unavailable Storage temporarily unavailable | HTTP status | Retryable | | - | - | | `503` | Yes | ## What happened Attachment storage is briefly unavailable. ## How to fix it Wait for `Retry-After` seconds and retry. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_STORAGE_UNAVAILABLE", "message": "Storage temporarily unavailable", "type": "server", "http_status": 503, "retryable": true, "doc_url": "https://docs.blujay.io/errors/attachment-storage-unavailable", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_TOO_LARGE Source: https://docs.blujay.io/errors/attachment-too-large Attachment too large | HTTP status | Retryable | | - | - | | `413` | No | ## What happened The file is over a size limit. Uploads can be up to 100 MiB, media URLs up to 10 MiB, and SMS/MMS messages up to 5 MiB of media in total. ## How to fix it Send a smaller file, or upload it with `POST /v1/attachments` if you used a URL. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_TOO_LARGE", "message": "Attachment too large", "type": "validation", "http_status": 413, "retryable": false, "doc_url": "https://docs.blujay.io/errors/attachment-too-large", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # ATTACHMENT_UPLOAD_INVALID Source: https://docs.blujay.io/errors/attachment-upload-invalid Upload does not match | HTTP status | Retryable | | - | - | | `422` | No | ## What happened The uploaded bytes do not match the declared size or content type. ## How to fix it Create a new attachment with the correct `size_bytes` and `content_type`, and upload the file again. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "ATTACHMENT_UPLOAD_INVALID", "message": "Upload does not match", "type": "validation", "http_status": 422, "retryable": false, "doc_url": "https://docs.blujay.io/errors/attachment-upload-invalid", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CAPABILITY_CHECK_UNAVAILABLE Source: https://docs.blujay.io/errors/capability-check-unavailable Capability check unavailable | HTTP status | Retryable | | - | - | | `503` | Yes | ## What happened Capability checks are briefly unavailable. ## How to fix it Wait for `Retry-After` seconds and retry. You can also send with Auto and let Blujay choose the service. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CAPABILITY_CHECK_UNAVAILABLE", "message": "Capability check unavailable", "type": "phone_number", "http_status": 503, "retryable": true, "doc_url": "https://docs.blujay.io/errors/capability-check-unavailable", "details": [], "retry_after": 30 }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CHANGE_REJECTED Source: https://docs.blujay.io/errors/change-rejected Change refused | HTTP status | Retryable | | - | - | | `502` | No | ## What happened The phone number could not make the edit or unsend. ## How to fix it Do not retry the same change. Send a follow-up message instead. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CHANGE_REJECTED", "message": "Change refused", "type": "phone_number", "http_status": 502, "retryable": false, "doc_url": "https://docs.blujay.io/errors/change-rejected", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CHANGE_UNCONFIRMED Source: https://docs.blujay.io/errors/change-unconfirmed Change not confirmed | HTTP status | Retryable | | - | - | | `503` | Yes | ## What happened The phone number did not confirm the edit or unsend in time. It may or may not have applied. ## How to fix it Retry with the same idempotency key. Blujay checks whether the change already applied before trying again. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CHANGE_UNCONFIRMED", "message": "Change not confirmed", "type": "phone_number", "http_status": 503, "retryable": true, "doc_url": "https://docs.blujay.io/errors/change-unconfirmed", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CONTACT_ALREADY_EXISTS Source: https://docs.blujay.io/errors/contact-already-exists Contact already exists | HTTP status | Retryable | | - | - | | `409` | No | ## What happened Another contact in your workspace already has this phone number or email. ## How to fix it Find it with `GET /v1/contacts/search` and update that contact, or use `POST /v1/contacts/import` to create or update by phone number. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CONTACT_ALREADY_EXISTS", "message": "Contact already exists", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/contact-already-exists", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CONTACT_MATCH_CONFLICT Source: https://docs.blujay.io/errors/contact-match-conflict Email belongs to another contact | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The email you sent belongs to a different contact than the one with this phone number (or no contact has this phone number), so Blujay can't tell which contact to create or update. Nothing was changed. ## How to fix it Leave out the email, or send the phone number of the contact that already has this email. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CONTACT_MATCH_CONFLICT", "message": "Email belongs to another contact", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/contact-match-conflict", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CONTACT_MUST_TEXT_FIRST Source: https://docs.blujay.io/errors/contact-must-text-first Number only replies to people who text it first | HTTP status | Retryable | | - | - | | `403` | No | ## What happened The phone number is on the AI Agent plan, which only replies. For a direct message, the person hasn't texted that number yet. Group chats aren't available on these numbers at all, even when everyone in the group has texted the number. ## How to fix it For a direct message, don't retry: wait for the person to text your number, then reply. For a group chat, or to start conversations yourself, send from a number on a plan that can, or contact [support@blujay.io](mailto:support@blujay.io) to change the plan. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CONTACT_MUST_TEXT_FIRST", "message": "Number only replies to people who text it first", "type": "authorization", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/contact-must-text-first", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # CONTACT_NOT_VERIFIED Source: https://docs.blujay.io/errors/contact-not-verified Contact not verified | HTTP status | Retryable | | - | - | | `403` | No | ## What happened Your workspace is on the Free Plan, and this contact hasn't verified yet. On the Free Plan, you can message only contacts who have texted your Free Plan number to verify. ## How to fix it Don't retry until the contact is verified. In Blujay, go to **Contacts** and choose **Add verified contact**, then have the contact text your Free Plan number (Blujay shows a QR code that fills in the text for them). Once Blujay shows them as verified, send again. To message people without verifying them, upgrade to a dedicated phone number. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "CONTACT_NOT_VERIFIED", "message": "Contact not verified", "type": "authorization", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/contact-not-verified", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # EDIT_LIMIT_REACHED Source: https://docs.blujay.io/errors/edit-limit-reached Edit limit reached | HTTP status | Retryable | | - | - | | `409` | No | ## What happened An iMessage can be edited at most 5 times. ## How to fix it Send a follow-up message instead. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "EDIT_LIMIT_REACHED", "message": "Edit limit reached", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/edit-limit-reached", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # EDIT_UNCHANGED Source: https://docs.blujay.io/errors/edit-unchanged Text unchanged | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The new text is the same as the current text. ## How to fix it Send different text, or skip the edit. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "EDIT_UNCHANGED", "message": "Text unchanged", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/edit-unchanged", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # EDIT_WINDOW_EXPIRED Source: https://docs.blujay.io/errors/edit-window-expired Edit window expired | HTTP status | Retryable | | - | - | | `409` | No | ## What happened iMessages can be edited only within 15 minutes of sending. ## How to fix it Send a follow-up message instead. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "EDIT_WINDOW_EXPIRED", "message": "Edit window expired", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/edit-window-expired", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # FEATURE_NOT_ENABLED Source: https://docs.blujay.io/errors/feature-not-enabled Feature not enabled | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The request asks for something that is not available in this context. Examples are a message effect on the first message of a new chat, a text-decoration animation, a webhook event type that cannot be subscribed to, or editing when editing is turned off for your workspace. ## How to fix it Read `error.message` for the specific field. Remove that field or send it in a supported context. The guide for the feature lists where it is supported. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "FEATURE_NOT_ENABLED", "message": "Feature not enabled", "type": "feature_gate", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/feature-not-enabled", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # FIRST_MESSAGE_LINK_NOT_ALLOWED Source: https://docs.blujay.io/errors/first-message-link-not-allowed Links not allowed in a first message | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The first message to a new chat contains a link. Links in a first message hurt deliverability, so Blujay rejects them. ## How to fix it Send the first message without a link. After the contact replies, send the link as a follow-up with `POST /v1/chats/{chat_id}/messages`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "FIRST_MESSAGE_LINK_NOT_ALLOWED", "message": "Links not allowed in a first message", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/first-message-link-not-allowed", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # FROM_NUMBER_REQUIRED Source: https://docs.blujay.io/errors/from-number-required Blujay can't tell which number to send from | HTTP status | Retryable | | - | - | | `422` | No | ## What happened You left out `from` when starting a direct chat. The person hasn't texted any of your numbers, you have several, and the contact isn't assigned to a teammate with an active default line. Blujay never guesses a number. ## How to fix it Send `from`, or assign the contact to a teammate and set that teammate's default line in Team Members. The error message names the teammate when one is assigned. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "FROM_NUMBER_REQUIRED", "message": "Blujay can't tell which number to send from", "type": "validation", "http_status": 422, "retryable": false, "doc_url": "https://docs.blujay.io/errors/from-number-required", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # GROUP_MINIMUM_PARTICIPANTS Source: https://docs.blujay.io/errors/group-minimum-participants Group too small | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The change would leave the group with fewer people than a group chat needs. ## How to fix it Add a participant first, or leave the group as it is. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "GROUP_MINIMUM_PARTICIPANTS", "message": "Group too small", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/group-minimum-participants", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # IDEMPOTENCY_IN_PROGRESS Source: https://docs.blujay.io/errors/idempotency-in-progress Request with this key still in progress | HTTP status | Retryable | | - | - | | `409` | Yes | ## What happened An earlier request with the same `Idempotency-Key` is still being processed. ## How to fix it Wait a moment and retry with the same key and the same request. You will get the original result once it finishes. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "IDEMPOTENCY_IN_PROGRESS", "message": "Request with this key still in progress", "type": "idempotency", "http_status": 409, "retryable": true, "doc_url": "https://docs.blujay.io/errors/idempotency-in-progress", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # IDEMPOTENCY_KEY_MISMATCH Source: https://docs.blujay.io/errors/idempotency-key-mismatch Idempotency keys disagree | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The `Idempotency-Key` header and the body `idempotency_key` field are both present and have different values. ## How to fix it Send the key in one place, or make both values identical. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "IDEMPOTENCY_KEY_MISMATCH", "message": "Idempotency keys disagree", "type": "idempotency", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/idempotency-key-mismatch", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # IDEMPOTENCY_REQUEST_MISMATCH Source: https://docs.blujay.io/errors/idempotency-request-mismatch Idempotency key reused with a different request | HTTP status | Retryable | | - | - | | `409` | No | ## What happened You sent an `Idempotency-Key` that was already used for a request with a different body or target. ## How to fix it Use a new key for a new request. Reuse a key only to retry the exact same request. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "IDEMPOTENCY_REQUEST_MISMATCH", "message": "Idempotency key reused with a different request", "type": "idempotency", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/idempotency-request-mismatch", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # Errors Source: https://docs.blujay.io/errors/index Every error has a stable code, a retry signal and a page that explains how to fix it. Every error response uses the same envelope. Branch on `error.code`, which never changes for a given condition. `error.message` is written for people and may change. ```json theme={null} { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/rate-limit-exceeded", "details": [{ "limit_type": "public_api_write", "limit": 120, "remaining": 0, "reset_at": "2026-10-14T17:31:00.000Z" }], "retry_after": 12, "reset_at": "2026-10-14T17:31:00.000Z" }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` | Field | Meaning | | - | - | | `error.code` | Stable, machine-readable code. Each code has its own page, linked from `doc_url`. | | `error.type` | Broad category: `validation`, `auth`, `authorization`, `not_found`, `idempotency`, `rate_limit`, `queue`, `feature_gate`, `phone_number` or `server`. | | `error.http_status` | The HTTP status, repeated in the body. | | `error.retryable` | `true` when the same request can succeed later. Retry it with the same `Idempotency-Key`. | | `error.details` | Extra context, such as the field that failed validation or the limit that was hit. | | `error.retry_after` | Seconds to wait before retrying. Also sent as the `Retry-After` header. | | `trace_id` | Identifies this request. Also sent as the `X-Trace-ID` header on every response. | | `client_request_id` | Your own `X-Request-ID` header, echoed back when you send one. | ## Handling errors * **Retryable errors** (`retryable: true`): wait for `Retry-After` when present, otherwise back off exponentially, and retry with the **same** `Idempotency-Key`. Blujay returns the original result instead of sending twice. Check the flag, not the status: most `409` codes are not retryable, and `429` and `503` usually are. * **Everything else** (`retryable: false`): retrying the same request returns the same error. Fix the request first. The [sending rules](/messaging/sending-rules) errors are all in this group. * **Support**: when you contact [support@blujay.io](mailto:support@blujay.io), include the `trace_id` and your `X-Request-ID`. Never send your API key. A failed **message** is different from a failed **request**. A request can succeed (`202 Accepted`) and the message can fail later. Failed messages report `failure_code` on the message and in `message.failed` webhooks. See [Message lifecycle](/messaging/lifecycle). ## All error codes ## Request errors | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`VALIDATION_ERROR`](/errors/validation-error) | 400 | No | Read `error.message` and `error.details` for the field that failed, correct the request, and send it again. | | [`INVALID_PUBLIC_ID`](/errors/invalid-public-id) | 400 | No | Use the exact ID returned by the API for that resource type. | | [`INVALID_CURSOR`](/errors/invalid-cursor) | 400 | No | Pass `next_cursor` from the previous page unchanged, with the same filters. | | [`NOT_FOUND`](/errors/not-found) | 404 | No | Check the ID and the path. | | [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | 400 | No | Read `error.message` for the specific field. | ## Authentication | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`UNAUTHORIZED`](/errors/unauthorized) | 401 | No | Send `Authorization: Bearer ` with your workspace API key (`blu_...`). | | [`API_KEY_REVOKED`](/errors/api-key-revoked) | 401 | No | Use a current key from the **API** tab in Blujay, or contact [support@blujay.io](mailto:support@blujay.io) if you need a new one. | | [`API_KEY_EXPIRED`](/errors/api-key-expired) | 401 | No | Use a current key from the **API** tab in Blujay, or contact [support@blujay.io](mailto:support@blujay.io) if you need a new one. | | [`OAUTH_TOKEN_INVALID`](/errors/oauth-token-invalid) | 401 | No | If you're calling the API yourself, use your workspace API key as the bearer token. | | [`OAUTH_SCOPE_REQUIRED`](/errors/oauth-scope-required) | 403 | No | If you're calling the API yourself, use your workspace API key. | | [`OAUTH_ROUTE_NOT_ENABLED`](/errors/oauth-route-not-enabled) | 403 | No | Call it with your workspace API key. | ## Idempotency | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) | 409 | No | Use a new key for a new request. | | [`IDEMPOTENCY_KEY_MISMATCH`](/errors/idempotency-key-mismatch) | 409 | No | Send the key in one place, or make both values identical. | | [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress) | 409 | Yes | Wait a moment and retry with the same key and the same request. | ## Rate limits and capacity | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | 429 | Yes | Wait for the number of seconds in the `Retry-After` header (also `error.retry_after`; `RateLimit-Reset` is the same number of seconds), then retry with the same idempotency key. | | [`QUEUE_FULL`](/errors/queue-full) | 429 | Yes | Wait for `Retry-After` seconds, then retry with the same idempotency key. | | [`ATTACHMENT_CAPACITY_EXCEEDED`](/errors/attachment-capacity-exceeded) | 429 | Yes | Finish or abandon pending uploads, wait for `Retry-After` seconds, and try again. | ## Sending messages | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) | 400 | No | Send the first message without a link. | | [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) | 400 | No | Send a short text without the link or attachment. | | [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded) | 400 | No | Shorten the message to 300 characters or less. | | [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached) | 400 | No | Wait for the contact to respond, or reach them another way. | | [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | 403 | No | Do not retry. | | [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | 403 | No | Don't retry until the contact is verified. | | [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | 403 | No | For a direct message, don't retry: wait for the person to text your number, then reply. | | [`SENDER_SUSPENDED`](/errors/sender-suspended) | 403 | No | Do not retry. | | [`SENDER_RETIRED`](/errors/sender-retired) | 409 | No | Call `GET /v1/phone-numbers` and send from an active number. | | [`FROM_NUMBER_REQUIRED`](/errors/from-number-required) | 422 | No | Send `from`, or assign the contact to a teammate and set that teammate's default line in Team Members. | | [`INVALID_MESSAGE`](/errors/invalid-message) | 400 | No | Retry only messages you sent: the IDs that came back from your own send responses, or messages whose `status` is `failed` rather than `received`. | | [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed) | 409 | No | Don't automatically send a replacement. | ## Chats and groups | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) | 400 | No | Check the chat's capabilities with `GET /v1/chats/{chat_id}` and the feature guide for where the operation is supported. | | [`REPLY_TARGET_NOT_FOUND`](/errors/reply-target-not-found) | 404 | No | Use a message ID from this chat, for example from `GET /v1/chats/{chat_id}/messages`. | | [`REPLY_TARGET_NOT_IN_CHAT`](/errors/reply-target-not-in-chat) | 400 | No | Send the reply to the chat that contains the target message. | | [`REPLY_TARGET_NOT_REPLYABLE`](/errors/reply-target-not-replyable) | 400 | No | Reply to a sent iMessage, or send a normal message without `reply_to`. | | [`REPLY_TARGET_NOT_SENT`](/errors/reply-target-not-sent) | 409 | Yes | Wait until the target message is sent, then retry with the same idempotency key. | | [`PHONE_NUMBER_REQUIRED`](/errors/phone-number-required) | 400 | No | Include `from_phone_number_id`. | | [`LAST_PHONE_NUMBER_IN_GROUP`](/errors/last-phone-number-in-group) | 409 | No | No action is possible through the API. | | [`GROUP_MINIMUM_PARTICIPANTS`](/errors/group-minimum-participants) | 409 | No | Add a participant first, or leave the group as it is. | | [`PHONE_NUMBER_HAS_SCHEDULED_MESSAGES`](/errors/phone-number-has-scheduled-messages) | 409 | No | Cancel or wait for the scheduled messages, then try again. | | [`PHONE_NUMBER_HAS_MESSAGES_SENDING`](/errors/phone-number-has-messages-sending) | 409 | Yes | Wait until they finish, then retry. | ## Edit and unsend | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`MESSAGE_NOT_SENT_YET`](/errors/message-not-sent-yet) | 409 | Yes | Retry shortly with the same idempotency key. | | [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable) | 409 | No | Do not retry. | | [`EDIT_WINDOW_EXPIRED`](/errors/edit-window-expired) | 409 | No | Send a follow-up message instead. | | [`EDIT_LIMIT_REACHED`](/errors/edit-limit-reached) | 409 | No | Send a follow-up message instead. | | [`UNSEND_WINDOW_EXPIRED`](/errors/unsend-window-expired) | 409 | No | Send a follow-up message if you need to correct something. | | [`EDIT_UNCHANGED`](/errors/edit-unchanged) | 400 | No | Send different text, or skip the edit. | | [`SENDER_UNAVAILABLE`](/errors/sender-unavailable) | 409 | No | The message cannot be changed. | | [`CHANGE_REJECTED`](/errors/change-rejected) | 502 | No | Do not retry the same change. | | [`CHANGE_UNCONFIRMED`](/errors/change-unconfirmed) | 503 | Yes | Retry with the same idempotency key. | ## Media | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready) | 409 | Yes | PUT the file to the `upload_url` from `POST /v1/attachments`, then retry the read or send. | | [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid) | 422 | No | Create a new attachment with the correct `size_bytes` and `content_type`, and upload the file again. | | [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large) | 413 | No | Send a smaller file, or upload it with `POST /v1/attachments` if you used a URL. | | [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type) | 415 | No | Send a supported type. | | [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed) | 422 | No | Use a public HTTPS URL that returns the file directly, or upload the file with `POST /v1/attachments`. | | [`ATTACHMENT_STORAGE_UNAVAILABLE`](/errors/attachment-storage-unavailable) | 503 | Yes | Wait for `Retry-After` seconds and retry. | ## Contacts | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`CONTACT_ALREADY_EXISTS`](/errors/contact-already-exists) | 409 | No | Find it with `GET /v1/contacts/search` and update that contact, or use `POST /v1/contacts/import` to create or update by phone number. | | [`CONTACT_MATCH_CONFLICT`](/errors/contact-match-conflict) | 409 | No | Leave out the email, or send the phone number of the contact that already has this email. | ## Workflows | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`WORKFLOW_LIFECYCLE_ERROR`](/errors/workflow-lifecycle-error) | 409 | No | Activate the workflow in Blujay, or enroll the contact in a manual workflow from `GET /v1/workflows`. | | [`WORKFLOW_VALIDATION_ERROR`](/errors/workflow-validation-error) | 422 | No | Read `error.message`. | ## Webhooks | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`UNSUPPORTED_WEBHOOK_EVENT`](/errors/unsupported-webhook-event) | 400 | No | Use event names from the webhook events list. | | [`WEBHOOK_URL_INVALID`](/errors/webhook-url-invalid) | 400 | No | Use a plain `https://` URL on a public host. | | [`WEBHOOK_URL_UNSAFE`](/errors/webhook-url-unsafe) | 400 | No | Use a publicly reachable HTTPS endpoint. | | [`WEBHOOK_URL_UNRESOLVABLE`](/errors/webhook-url-unresolvable) | 400 | No | Check the host name and its DNS records, then try again. | | [`WEBHOOK_VERSION_UNSUPPORTED`](/errors/webhook-version-unsupported) | 409 | No | Update the subscription to a supported version with `PATCH /v1/webhook-subscriptions/{subscription_id}`. | | [`WEBHOOK_SUBSCRIPTION_INACTIVE`](/errors/webhook-subscription-inactive) | 409 | No | Re-enable the subscription or add the event type back, then try again. | | [`WEBHOOK_PHONE_NUMBER_EXCLUDED`](/errors/webhook-phone-number-excluded) | 409 | No | Add the phone number to the subscription's `phone_number_filter`, or set the filter to all numbers. | ## Server and availability | Code | HTTP | Retryable | What to do | | - | - | - | - | | [`CAPABILITY_CHECK_UNAVAILABLE`](/errors/capability-check-unavailable) | 503 | Yes | Wait for `Retry-After` seconds and retry. | | [`QUEUE_UNAVAILABLE`](/errors/queue-unavailable) | 503 | Yes | Wait for `Retry-After` seconds and retry with the same idempotency key. | | [`PHONE_NUMBER_UNAVAILABLE`](/errors/phone-number-unavailable) | 503 | Yes | Wait for `Retry-After` seconds and retry. | | [`INTERNAL_ERROR`](/errors/internal-error) | 500 | Yes | Retry with the same idempotency key after a short wait. | # INTERNAL_ERROR Source: https://docs.blujay.io/errors/internal-error Internal error | HTTP status | Retryable | | - | - | | `500` | Yes | ## What happened Something went wrong on Blujay's side. ## How to fix it Retry with the same idempotency key after a short wait. If it keeps happening, email [support@blujay.io](mailto:support@blujay.io) with the `trace_id`. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "INTERNAL_ERROR", "message": "Internal error", "type": "server", "http_status": 500, "retryable": true, "doc_url": "https://docs.blujay.io/errors/internal-error", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # INVALID_CURSOR Source: https://docs.blujay.io/errors/invalid-cursor Invalid pagination cursor | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The `cursor` value is not one Blujay issued for this list, or it was modified. ## How to fix it Pass `next_cursor` from the previous page unchanged, with the same filters. To start over, omit `cursor`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "INVALID_CURSOR", "message": "Invalid pagination cursor", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/invalid-cursor", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # INVALID_MESSAGE Source: https://docs.blujay.io/errors/invalid-message Message cannot be retried | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The message isn't one you sent, so it can't be retried. Only outbound messages can be. ## How to fix it Retry only messages you sent: the IDs that came back from your own send responses, or messages whose `status` is `failed` rather than `received`. A failed outbound message that can't be retried returns `RETRY_NOT_ALLOWED` instead. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "INVALID_MESSAGE", "message": "Message cannot be retried", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/invalid-message", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # INVALID_PUBLIC_ID Source: https://docs.blujay.io/errors/invalid-public-id Invalid ID | HTTP status | Retryable | | - | - | | `400` | No | ## What happened An ID in the request is malformed or has the wrong prefix for this resource, for example a `contact_` ID where a `chat_` ID is expected. ## How to fix it Use the exact ID returned by the API for that resource type. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "INVALID_PUBLIC_ID", "message": "Invalid ID", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/invalid-public-id", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # LAST_PHONE_NUMBER_IN_GROUP Source: https://docs.blujay.io/errors/last-phone-number-in-group Last phone number in the group | HTTP status | Retryable | | - | - | | `409` | No | ## What happened Your number cannot leave the group because it is your workspace's only number in it. ## How to fix it No action is possible through the API. Keep the number in the group. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "LAST_PHONE_NUMBER_IN_GROUP", "message": "Last phone number in the group", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/last-phone-number-in-group", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # MESSAGE_NOT_EDITABLE Source: https://docs.blujay.io/errors/message-not-editable Message cannot be changed | HTTP status | Retryable | | - | - | | `409` | No | ## What happened Only text-only iMessages your workspace sent can be edited or unsent. Inbound, SMS, RCS and media messages cannot, and neither can a text that contains a link, because iMessage attaches a link preview to it. ## How to fix it Do not retry. Send a follow-up message instead. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "MESSAGE_NOT_EDITABLE", "message": "Message cannot be changed", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/message-not-editable", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # MESSAGE_NOT_SENT_YET Source: https://docs.blujay.io/errors/message-not-sent-yet Message not sent yet | HTTP status | Retryable | | - | - | | `409` | Yes | ## What happened The message has not finished sending, so it cannot be edited or unsent yet. ## How to fix it Retry shortly with the same idempotency key. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "MESSAGE_NOT_SENT_YET", "message": "Message not sent yet", "type": "phone_number", "http_status": 409, "retryable": true, "doc_url": "https://docs.blujay.io/errors/message-not-sent-yet", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # NOT_FOUND Source: https://docs.blujay.io/errors/not-found Not found | HTTP status | Retryable | | - | - | | `404` | No | ## What happened The resource or route does not exist in your workspace, or, on a participant removal, the handle is not in the group. Resources in other workspaces are reported as not found. ## How to fix it Check the ID and the path. IDs are scoped to the workspace that owns the credential. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "NOT_FOUND", "message": "Not found", "type": "not_found", "http_status": 404, "retryable": false, "doc_url": "https://docs.blujay.io/errors/not-found", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # OAUTH_ROUTE_NOT_ENABLED Source: https://docs.blujay.io/errors/oauth-route-not-enabled Endpoint requires an API key | HTTP status | Retryable | | - | - | | `403` | No | ## What happened This endpoint can't be called with a connected app's access token. ## How to fix it Call it with your workspace API key. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "OAUTH_ROUTE_NOT_ENABLED", "message": "Endpoint requires an API key", "type": "authorization", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/oauth-route-not-enabled", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # OAUTH_SCOPE_REQUIRED Source: https://docs.blujay.io/errors/oauth-scope-required OAuth scope required | HTTP status | Retryable | | - | - | | `403` | No | ## What happened The request used a connected app's access token that doesn't include the permission this endpoint needs. ## How to fix it If you're calling the API yourself, use your workspace API key. If this came from a connected app, reconnect it in Blujay so it asks for the permission it needs; `error.details` names it. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "OAUTH_SCOPE_REQUIRED", "message": "OAuth scope required", "type": "authorization", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/oauth-scope-required", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # OAUTH_TOKEN_INVALID Source: https://docs.blujay.io/errors/oauth-token-invalid OAuth token invalid | HTTP status | Retryable | | - | - | | `401` | No | ## What happened The request used a connected app's access token (not a workspace API key) that is invalid, has expired, or was revoked. Access tokens last one hour. ## How to fix it If you're calling the API yourself, use your workspace API key as the bearer token. If this came from a connected app, reconnect it in Blujay so it gets a fresh token. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "OAUTH_TOKEN_INVALID", "message": "OAuth token invalid", "type": "auth", "http_status": 401, "retryable": false, "doc_url": "https://docs.blujay.io/errors/oauth-token-invalid", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PHONE_NUMBER_HAS_MESSAGES_SENDING Source: https://docs.blujay.io/errors/phone-number-has-messages-sending Messages still sending | HTTP status | Retryable | | - | - | | `409` | Yes | ## What happened Your number has messages to this group that are still sending. ## How to fix it Wait until they finish, then retry. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PHONE_NUMBER_HAS_MESSAGES_SENDING", "message": "Messages still sending", "type": "validation", "http_status": 409, "retryable": true, "doc_url": "https://docs.blujay.io/errors/phone-number-has-messages-sending", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PHONE_NUMBER_HAS_SCHEDULED_MESSAGES Source: https://docs.blujay.io/errors/phone-number-has-scheduled-messages Scheduled messages pending | HTTP status | Retryable | | - | - | | `409` | No | ## What happened Your number has scheduled messages waiting to go to this group. ## How to fix it Cancel or wait for the scheduled messages, then try again. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PHONE_NUMBER_HAS_SCHEDULED_MESSAGES", "message": "Scheduled messages pending", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/phone-number-has-scheduled-messages", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PHONE_NUMBER_REQUIRED Source: https://docs.blujay.io/errors/phone-number-required Phone number required | HTTP status | Retryable | | - | - | | `400` | No | ## What happened Blujay needs to know which of your phone numbers to use: for a group operation, one of your numbers in the group; for a capability check, the number to check from when your workspace has several. ## How to fix it Include `from_phone_number_id`. List your numbers with `GET /v1/phone-numbers`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PHONE_NUMBER_REQUIRED", "message": "Phone number required", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/phone-number-required", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PHONE_NUMBER_UNAVAILABLE Source: https://docs.blujay.io/errors/phone-number-unavailable Phone number temporarily unreachable | HTTP status | Retryable | | - | - | | `503` | Yes | ## What happened The phone number could not take this action right now. ## How to fix it Wait for `Retry-After` seconds and retry. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PHONE_NUMBER_UNAVAILABLE", "message": "Phone number temporarily unreachable", "type": "phone_number", "http_status": 503, "retryable": true, "doc_url": "https://docs.blujay.io/errors/phone-number-unavailable", "details": [], "retry_after": 30 }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PRE_REPLY_CONTENT_BLOCKED Source: https://docs.blujay.io/errors/pre-reply-content-blocked Links and attachments wait for a reply | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The contact hasn't replied to this phone number yet, and the message has a link (a link part or a URL in the text), a media part, or a contact card. Before a reply, Blujay sends text only, to protect your number's reputation. ## How to fix it Send a short text without the link or attachment. After the contact replies to this number, send the link or attachment as a follow-up. `pre_reply_override` doesn't apply to this rule. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PRE_REPLY_CONTENT_BLOCKED", "message": "Links and attachments wait for a reply", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/pre-reply-content-blocked", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PRE_REPLY_LENGTH_EXCEEDED Source: https://docs.blujay.io/errors/pre-reply-length-exceeded Text too long before a reply | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The contact hasn't replied to this phone number yet, and the message's text is longer than 300 characters. Long first texts read like marketing and hurt your number's reputation. ## How to fix it Shorten the message to 300 characters or less. After the contact replies to this number, longer messages are fine. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PRE_REPLY_LENGTH_EXCEEDED", "message": "Text too long before a reply", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/pre-reply-length-exceeded", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # PRE_REPLY_LIMIT_REACHED Source: https://docs.blujay.io/errors/pre-reply-limit-reached Too many texts in a row without a reply | HTTP status | Retryable | | - | - | | `400` | No | ## What happened Your workspace has sent the contact 6 texts in a row, across all your phone numbers, without a response from them (a text or tapback, or a message from them in a group; calls don't count). This applies before and after their first reply. The details say how many texts are unanswered. ## How to fix it Wait for the contact to respond, or reach them another way. With an API key, `pre_reply_override: true` sends a 7th text once. Nothing more is sent until the contact responds, and then the count starts again. Zapier and other connected apps can't use the override. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "PRE_REPLY_LIMIT_REACHED", "message": "Too many texts in a row without a reply", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/pre-reply-limit-reached", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # QUEUE_FULL Source: https://docs.blujay.io/errors/queue-full Phone number queue full | HTTP status | Retryable | | - | - | | `429` | Yes | ## What happened The phone number already has as many messages waiting as it can hold. ## How to fix it Wait for `Retry-After` seconds, then retry with the same idempotency key. `queue.remaining` on `GET /v1/phone-numbers` shows current room. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "QUEUE_FULL", "message": "Phone number queue full", "type": "queue", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/queue-full", "details": [ { "limit_type": "phone_number_queue", "scope": "phone_number", "phone_number_id": "phone_abc123", "remaining": 0, "reset_at": "2026-10-14T17:35:48.000Z" } ], "retry_after": 300 }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # QUEUE_UNAVAILABLE Source: https://docs.blujay.io/errors/queue-unavailable Queue unavailable | HTTP status | Retryable | | - | - | | `503` | Yes | ## What happened Blujay could not accept the work for processing right now. ## How to fix it Wait for `Retry-After` seconds and retry with the same idempotency key. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "QUEUE_UNAVAILABLE", "message": "Queue unavailable", "type": "queue", "http_status": 503, "retryable": true, "doc_url": "https://docs.blujay.io/errors/queue-unavailable", "details": [], "retry_after": 30 }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # RATE_LIMIT_EXCEEDED Source: https://docs.blujay.io/errors/rate-limit-exceeded Rate limit exceeded | HTTP status | Retryable | | - | - | | `429` | Yes | ## What happened You hit a limit. It can be the API request rate for your credential, a sending limit on the phone number, or the capability-check quota. `error.details[0].limit_type` says which (for capability checks, `error.details[0].limit`). ## How to fix it Wait for the number of seconds in the `Retry-After` header (also `error.retry_after`; `RateLimit-Reset` is the same number of seconds), then retry with the same idempotency key. For sends, check `send_status` and `limits` on `GET /v1/phone-numbers` before sending more. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/rate-limit-exceeded", "details": [ { "limit_type": "public_api_write", "scope": "api_key", "limit": 120, "remaining": 0, "reset_at": "2026-10-14T17:31:00.000Z" } ], "retry_after": 12 }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # RECIPIENT_SUPPRESSED Source: https://docs.blujay.io/errors/recipient-suppressed Recipient opted out | HTTP status | Retryable | | - | - | | `403` | No | ## What happened The recipient opted out or was suppressed in your workspace, so Blujay will not message them. ## How to fix it Do not retry. If the recipient opts back in, remove the suppression with `POST /v1/contacts/{contact_id}/unsuppress`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "RECIPIENT_SUPPRESSED", "message": "Recipient opted out", "type": "authorization", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/recipient-suppressed", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # REPLY_TARGET_NOT_FOUND Source: https://docs.blujay.io/errors/reply-target-not-found Reply target not found | HTTP status | Retryable | | - | - | | `404` | No | ## What happened The message in `reply_to.message_id` does not exist on this chat's phone number. ## How to fix it Use a message ID from this chat, for example from `GET /v1/chats/{chat_id}/messages`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "REPLY_TARGET_NOT_FOUND", "message": "Reply target not found", "type": "not_found", "http_status": 404, "retryable": false, "doc_url": "https://docs.blujay.io/errors/reply-target-not-found", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # REPLY_TARGET_NOT_IN_CHAT Source: https://docs.blujay.io/errors/reply-target-not-in-chat Reply target is in another chat | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The message you are replying to belongs to a different chat. ## How to fix it Send the reply to the chat that contains the target message. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "REPLY_TARGET_NOT_IN_CHAT", "message": "Reply target is in another chat", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/reply-target-not-in-chat", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # REPLY_TARGET_NOT_REPLYABLE Source: https://docs.blujay.io/errors/reply-target-not-replyable Message cannot be replied to | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The target is a reaction, an unsent message, or an SMS or RCS message. Threaded replies work on iMessages only. ## How to fix it Reply to a sent iMessage, or send a normal message without `reply_to`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "REPLY_TARGET_NOT_REPLYABLE", "message": "Message cannot be replied to", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/reply-target-not-replyable", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # REPLY_TARGET_NOT_SENT Source: https://docs.blujay.io/errors/reply-target-not-sent Reply target not sent yet | HTTP status | Retryable | | - | - | | `409` | Yes | ## What happened The message you are replying to has not been sent yet. ## How to fix it Wait until the target message is sent, then retry with the same idempotency key. > [!TIP] > > This error is safe to retry. Wait for the `Retry-After` header when it is present. If the endpoint takes an `Idempotency-Key`, reuse the same key so a retry never creates a duplicate. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "REPLY_TARGET_NOT_SENT", "message": "Reply target not sent yet", "type": "validation", "http_status": 409, "retryable": true, "doc_url": "https://docs.blujay.io/errors/reply-target-not-sent", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # RETRY_NOT_ALLOWED Source: https://docs.blujay.io/errors/retry-not-allowed Retry not allowed | HTTP status | Retryable | | - | - | | `409` | No | ## What happened Blujay can't safely send this message again. It may already have reached the recipient, or it is a kind of message that can't be retried, such as a group message. ## How to fix it Don't automatically send a replacement. Check the message with `GET /v1/messages/{message_id}` and the conversation, for example for a reply. Send a new message only when you're sure the first one didn't arrive, or when a possible duplicate is acceptable. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "RETRY_NOT_ALLOWED", "message": "Retry not allowed", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/retry-not-allowed", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # SENDER_RETIRED Source: https://docs.blujay.io/errors/sender-retired Phone number retired | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The phone number you sent from was retired, for example when your workspace moved to a new number. ## How to fix it Call `GET /v1/phone-numbers` and send from an active number. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "SENDER_RETIRED", "message": "Phone number retired", "type": "phone_number", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/sender-retired", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # SENDER_SUSPENDED Source: https://docs.blujay.io/errors/sender-suspended Sending suspended | HTTP status | Retryable | | - | - | | `403` | No | ## What happened Outbound sending is suspended for this phone number or workspace. ## How to fix it Do not retry. Contact [support@blujay.io](mailto:support@blujay.io) with the `trace_id`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "SENDER_SUSPENDED", "message": "Sending suspended", "type": "phone_number", "http_status": 403, "retryable": false, "doc_url": "https://docs.blujay.io/errors/sender-suspended", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # SENDER_UNAVAILABLE Source: https://docs.blujay.io/errors/sender-unavailable Phone number unavailable | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The phone number that sent this message is no longer available to your workspace. ## How to fix it The message cannot be changed. Send a follow-up from an active number. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "SENDER_UNAVAILABLE", "message": "Phone number unavailable", "type": "phone_number", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/sender-unavailable", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # UNAUTHORIZED Source: https://docs.blujay.io/errors/unauthorized Unauthorized | HTTP status | Retryable | | - | - | | `401` | No | ## What happened The request has no `Authorization` header, or the credential in it is not valid. ## How to fix it Send `Authorization: Bearer ` with your workspace API key (`blu_...`). Repeated failed attempts from one IP address are rate limited. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "UNAUTHORIZED", "message": "Unauthorized", "type": "auth", "http_status": 401, "retryable": false, "doc_url": "https://docs.blujay.io/errors/unauthorized", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # UNSEND_WINDOW_EXPIRED Source: https://docs.blujay.io/errors/unsend-window-expired Unsend window expired | HTTP status | Retryable | | - | - | | `409` | No | ## What happened iMessages can be unsent only within 2 minutes of sending. ## How to fix it Send a follow-up message if you need to correct something. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "UNSEND_WINDOW_EXPIRED", "message": "Unsend window expired", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/unsend-window-expired", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # UNSUPPORTED_ATTACHMENT_TYPE Source: https://docs.blujay.io/errors/unsupported-attachment-type Unsupported file type | HTTP status | Retryable | | - | - | | `415` | No | ## What happened The file type is not supported, or not supported by the service the message is going out on. ## How to fix it Send a supported type. iMessage accepts most safe file types; RCS and SMS/MMS accept images and videos. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "UNSUPPORTED_ATTACHMENT_TYPE", "message": "Unsupported file type", "type": "validation", "http_status": 415, "retryable": false, "doc_url": "https://docs.blujay.io/errors/unsupported-attachment-type", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # UNSUPPORTED_CHAT_OPERATION Source: https://docs.blujay.io/errors/unsupported-chat-operation Not supported for this chat | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The chat cannot do what you asked. Examples are a threaded reply in an SMS chat, or a group action on a group that is not an established iMessage group. ## How to fix it Check the chat's capabilities with `GET /v1/chats/{chat_id}` and the feature guide for where the operation is supported. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "UNSUPPORTED_CHAT_OPERATION", "message": "Not supported for this chat", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/unsupported-chat-operation", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # UNSUPPORTED_WEBHOOK_EVENT Source: https://docs.blujay.io/errors/unsupported-webhook-event Unknown webhook event | HTTP status | Retryable | | - | - | | `400` | No | ## What happened An entry in `event_types` is not a Blujay webhook event. ## How to fix it Use event names from the webhook events list. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "UNSUPPORTED_WEBHOOK_EVENT", "message": "Unknown webhook event", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/unsupported-webhook-event", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # VALIDATION_ERROR Source: https://docs.blujay.io/errors/validation-error Validation error | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The request body, query string or path parameters did not pass validation, or the body is not valid JSON. ## How to fix it Read `error.message` and `error.details` for the field that failed, correct the request, and send it again. Retrying the same request returns the same error. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "VALIDATION_ERROR", "message": "Validation error", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/validation-error", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_PHONE_NUMBER_EXCLUDED Source: https://docs.blujay.io/errors/webhook-phone-number-excluded Phone number not selected | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The event belongs to a phone number this subscription no longer selects. ## How to fix it Add the phone number to the subscription's `phone_number_filter`, or set the filter to all numbers. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_PHONE_NUMBER_EXCLUDED", "message": "Phone number not selected", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-phone-number-excluded", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_SUBSCRIPTION_INACTIVE Source: https://docs.blujay.io/errors/webhook-subscription-inactive Subscription not active for this event | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The subscription is disabled, or it no longer includes this event's type. ## How to fix it Re-enable the subscription or add the event type back, then try again. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_SUBSCRIPTION_INACTIVE", "message": "Subscription not active for this event", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-subscription-inactive", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_URL_INVALID Source: https://docs.blujay.io/errors/webhook-url-invalid Webhook URL invalid | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The URL is not HTTPS, contains credentials or a fragment, is longer than 2048 characters, or has no host. ## How to fix it Use a plain `https://` URL on a public host. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_URL_INVALID", "message": "Webhook URL invalid", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-url-invalid", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_URL_UNRESOLVABLE Source: https://docs.blujay.io/errors/webhook-url-unresolvable Webhook host not found | HTTP status | Retryable | | - | - | | `400` | No | ## What happened Blujay could not resolve the URL's host name in DNS. ## How to fix it Check the host name and its DNS records, then try again. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_URL_UNRESOLVABLE", "message": "Webhook host not found", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-url-unresolvable", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_URL_UNSAFE Source: https://docs.blujay.io/errors/webhook-url-unsafe Webhook URL not allowed | HTTP status | Retryable | | - | - | | `400` | No | ## What happened The URL points to a host Blujay will not deliver to, such as localhost or a private or internal IP address. ## How to fix it Use a publicly reachable HTTPS endpoint. For local testing, use a tunnel that gives you a public HTTPS URL. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_URL_UNSAFE", "message": "Webhook URL not allowed", "type": "validation", "http_status": 400, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-url-unsafe", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WEBHOOK_VERSION_UNSUPPORTED Source: https://docs.blujay.io/errors/webhook-version-unsupported Webhook version not supported | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The subscription is pinned to a `webhook_version` Blujay no longer supports, so it can't be read or delivered to. A version you send in a request that isn't valid returns `VALIDATION_ERROR` instead. ## How to fix it Update the subscription to a supported version with `PATCH /v1/webhook-subscriptions/{subscription_id}`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WEBHOOK_VERSION_UNSUPPORTED", "message": "Webhook version not supported", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/webhook-version-unsupported", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WORKFLOW_LIFECYCLE_ERROR Source: https://docs.blujay.io/errors/workflow-lifecycle-error Workflow not accepting enrollments | HTTP status | Retryable | | - | - | | `409` | No | ## What happened The workflow is not active, or it does not start manually, so it cannot accept enrollments. ## How to fix it Activate the workflow in Blujay, or enroll the contact in a manual workflow from `GET /v1/workflows`. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WORKFLOW_LIFECYCLE_ERROR", "message": "Workflow not accepting enrollments", "type": "validation", "http_status": 409, "retryable": false, "doc_url": "https://docs.blujay.io/errors/workflow-lifecycle-error", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # WORKFLOW_VALIDATION_ERROR Source: https://docs.blujay.io/errors/workflow-validation-error Enrollment invalid | HTTP status | Retryable | | - | - | | `422` | No | ## What happened The workflow or the enrollment input is not valid, for example because the workflow has no published version. ## How to fix it Read `error.message`. Publish the workflow or correct the input, then send the enrollment again. ## Example response An illustrative example. The `message` wording and the contents of `details` vary; branch on `error.code`. ```json theme={null} { "status": "error", "error": { "code": "WORKFLOW_VALIDATION_ERROR", "message": "Enrollment invalid", "type": "validation", "http_status": 422, "retryable": false, "doc_url": "https://docs.blujay.io/errors/workflow-validation-error", "details": [] }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:30:48.000Z" } ``` ## Still stuck? Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the error response. See [Errors](/errors) for the response format. # Examples Source: https://docs.blujay.io/examples Complete, copy-paste programs for the most common Blujay integrations. Each example is a single file you can run with Node.js 18 or later. They are Node only; the API is plain HTTPS and JSON, so the same calls work from any language, and the [API reference](/api-reference/introduction) shows each request in cURL, JavaScript, Python, Go, Ruby and PHP. Set `BLUJAY_API_KEY` (and, for webhooks, `BLUJAY_WEBHOOK_SECRET`) in your environment first. ## Reply to incoming messages A webhook receiver that verifies each event and answers a new direct message with an acknowledgement. Subscribe it to `message.received`. It sends the reply before it answers the webhook. If the send fails with a retryable error, the receiver returns `503`, so Blujay delivers the event again later (up to 10 attempts). Using the event ID as the `Idempotency-Key` means a redelivered event never sends a second reply. Acknowledge once per conversation, not once per message: the example remembers the last acknowledgement per chat and stays quiet for 12 hours after it, so a contact's "thanks" doesn't get another acknowledgement. Every acknowledgement also counts toward the [6 texts in a row](/messaging/sending-rules) the contact must respond to. ```javascript reply-bot.mjs theme={null} import crypto from "node:crypto"; import express from "express"; const API = "https://api.blujay.io/v1"; const KEY = process.env.BLUJAY_API_KEY; const SECRET = Buffer.from(process.env.BLUJAY_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64"); const QUIET_MS = 12 * 60 * 60 * 1000; // one acknowledgement per chat per 12 hours const lastAckByChat = new Map(); // use your database in production function verified(headers, rawBody) { const id = headers["webhook-id"]; const ts = headers["webhook-timestamp"]; const sigs = headers["webhook-signature"]; if (!id || !ts || !sigs) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = crypto.createHmac("sha256", SECRET).update(`${id}.${ts}.${rawBody}`).digest(); return sigs.split(" ").some((entry) => { const [version, value] = entry.split(","); const given = Buffer.from(value ?? "", "base64"); return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } const app = express(); app.post("/webhooks/blujay", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body.toString("utf8"); if (!verified(req.headers, rawBody)) return res.status(400).end(); const event = JSON.parse(rawBody); if (event.event_type !== "message.received" || !event.data.contact_id) { return res.status(200).end(); // nothing to do; direct chats only } const chatId = event.data.chat_id; const lastAck = lastAckByChat.get(chatId) ?? 0; if (Date.now() - lastAck < QUIET_MS) { return res.status(200).end(); // already acknowledged this conversation recently } try { const response = await fetch(`${API}/chats/${chatId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", "Idempotency-Key": `ack-${event.event_id}`, }, body: JSON.stringify({ message: { parts: [{ type: "text", text: "Thanks, we got your message and will reply shortly." }] }, }), signal: AbortSignal.timeout(8000), // answer the webhook within its 10-second limit }); const body = await response.json(); if (body.status === "success") { lastAckByChat.set(chatId, Date.now()); return res.status(200).end(); } if (body.error.retryable) return res.status(503).end(); // Blujay redelivers; same key, no duplicate console.error("Reply not sent:", body.error.code, body.trace_id); // e.g. RECIPIENT_SUPPRESSED, PRE_REPLY_LIMIT_REACHED return res.status(200).end(); } catch { return res.status(503).end(); // timeout or network error: let Blujay redeliver } }); app.listen(3000, () => console.log("Listening on :3000")); ``` For anything slower than a single API call, store the event in your own database first, acknowledge, and do the work from a job queue that retries. See [Webhooks](/webhooks/overview) and [Receive messages](/messaging/receive-messages). ## Send a batch safely Sends a first message to a list of people, one at a time, from the first number that can send. Before each send it checks the number's live `limits.new_contacts.remaining_today` and `queue.remaining` and stops when either reaches zero, and it also stops on a `429`. Each recipient's idempotency key makes the script safe to run again: finished sends are never repeated. ```javascript batch-send.mjs theme={null} const API = "https://api.blujay.io/v1"; const headers = { Authorization: `Bearer ${process.env.BLUJAY_API_KEY}`, "Content-Type": "application/json" }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const recipients = [ { phone: "+15555550123", name: "Ava" }, { phone: "+15555550124", name: "Jordan" }, ]; async function getSender() { const body = await (await fetch(`${API}/phone-numbers`, { headers })).json(); if (body.status === "error") throw new Error(`${body.error.code} (trace ${body.trace_id})`); return body.data.phone_numbers.find((n) => n.status === "active" && n.send_status.allowed); } function hasRoom(sender) { if (!sender.limits) return true; // Free Plan numbers don't report limits return sender.limits.new_contacts.remaining_today > 0 && sender.queue.remaining > 0; } let sender = await getSender(); if (!sender) throw new Error("No phone number can send right now"); for (const person of recipients) { sender = await getSender(); // re-read: other sends use up room too if (!sender || !hasRoom(sender)) { console.log("No room left today; run again after", sender?.limits?.new_contacts.reset_at ?? "the number can send again"); break; } const response = await fetch(`${API}/chats`, { method: "POST", headers: { ...headers, "Idempotency-Key": `welcome-2026-10-14-${person.phone}` }, body: JSON.stringify({ from: sender.display_number, to: [person.phone], message: { parts: [{ type: "text", text: `Hi ${person.name}, thanks for signing up. Any questions so far?` }] }, }), }); const body = await response.json(); if (body.status === "success") { console.log(person.phone, "queued", body.data.message.id); } else if (body.error.code === "RATE_LIMIT_EXCEEDED" || body.error.code === "QUEUE_FULL") { console.log("Number is at its limit; run again after", body.error.details[0].reset_at); break; } else if (body.error.retryable) { console.log("Temporary error", body.error.code, "- run the script again; finished sends won't repeat"); break; } else { console.log(person.phone, "skipped:", body.error.code); // e.g. RECIPIENT_SUPPRESSED, CONTACT_NOT_VERIFIED } await sleep(2000); // spread sends out } ``` See [Sending rules](/messaging/sending-rules) and [Sending best practices](/best-practices) before sending to many people. ## Next steps > **[Verify signatures](/webhooks/verify-signatures)** > > The signature check used above, explained. > **[Phone numbers and limits](/phone-numbers)** > > The live fields the batch script reads. > **[Build with AI](/build-with-ai)** > > Have your assistant review your integration. # FAQ Source: https://docs.blujay.io/faq Quick answers to the questions developers ask most. ## Getting started > **Is there a test mode or sandbox?** > > No. Every call acts on your real workspace and messages are delivered. Test by sending to your own phone. See [Test and live](/authentication#test-and-live). > **How do I get an API key, or rotate it?** > > Workspace owners and admins find the key under **API** in the Blujay sidebar. A workspace has one key today. To rotate it, or if it leaks, email [support@blujay.io](mailto:support@blujay.io). See [Authentication](/authentication). > **What does Free Plan, Dedicated or AI Agent let me do?** > > **Free Plan**: a shared number that can message up to 10 verified contacts, people who texted it first and were verified in the app; no group chats. **Dedicated**: your own number with full functionality. **AI Agent**: your own number that only replies to people who text it first, with no group chats. See [Plans](/plans). > **Do API messages show up in Blujay?** > > Yes. Messages you send through the API appear in your team's inbox in Blujay like any other text, and replies land in the same chat. They also appear in CRMs you've connected to Blujay. > **Where do I report a problem?** > > Email [support@blujay.io](mailto:support@blujay.io) with the `trace_id` from the response. See [Debugging](/debugging) and [Support](/support). ## Sending > **Do I choose iMessage or SMS?** > > You don't have to. Leave out `preferred_service` (Auto) and Blujay uses iMessage when the person can receive it, and RCS or SMS when they can't. You can also check first or require a service. See [iMessage, RCS or SMS](/messaging/choosing-a-service). > **Why does my second request fail?** > > Until a contact replies to your number, Blujay rejects a link, photo, file or contact card, and any text over 300 characters, with a `400`. At most 6 texts go out in a row without a response. These errors are never retryable: wait for the reply. See [Sending rules](/messaging/sending-rules). > **Why was my message with a link rejected?** > > Until a contact replies to your number, Blujay rejects links with [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) on a first message and [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) on follow-ups. Send a short text first, then send the link after they reply. Text that looks like a web address counts as a link. See [Sending rules](/messaging/sending-rules). > **What counts as a reply?** > > A text or a reaction (such as a tapback) from the contact, or a message from them in a group chat. Calls don't count. Links, attachments and longer texts unlock once the contact replies to that specific number. > **How do I avoid sending a message twice?** > > Send an `Idempotency-Key` header and reuse it on retries. See [Idempotency](/idempotency). > **Can I send to group chats?** > > Yes, from a Dedicated number. Send to two or more phone numbers to start a group chat. In iMessage groups you can add and remove participants and rename the group. See [Group chats](/messaging/group-chats). > **Can I edit or unsend a message?** > > Yes, for text-only iMessages your workspace sent: edit within 15 minutes, unsend within 2 minutes. See [Edit and unsend](/messaging/edit-and-unsend). > **I got a 204 but no typing bubble showed. Why?** > > Typing is best effort and shows only in iMessage chats. SMS and RCS chats, and chats whose service isn't confirmed yet, accept the request and show nothing. Check the chat's `supports_typing_indicators` first. See [Typing and read receipts](/messaging/typing-and-read-receipts). > **Can I send one-time passcodes?** > > Use a dedicated SMS provider for passcodes. Blujay is built for two-way conversations: texts to people who haven't replied are limited, which doesn't suit codes sent to someone who may never answer. > **What happens when someone texts STOP or asks to stop?** > > On a Dedicated number, Blujay opts the contact out automatically when they text exactly `STOP` and nothing else. For any other wording, or on other plans, opt them out with the API. Either way Blujay then refuses sends to them with [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed). There is no webhook for opt-outs yet; read `opted_out` on the contact. See [Opt-outs](/opt-outs). ## Replies and webhooks > **How do I get replies?** > > Subscribe a webhook to `message.received`. See [Webhooks](/webhooks/overview) and [Receive messages](/messaging/receive-messages). > **Will I know when a message is delivered and read?** > > For iMessage, yes: you get `delivered`, and `read` when the person has read receipts on. RCS reports them when the carrier does. SMS stops at `sent`, because carriers don't report delivery reliably. See [Track a message](/messaging/lifecycle). > **I received the same webhook twice. Is that a bug?** > > No. Delivery is at least once, so an event can arrive more than once, for example after a retry. Store each `event_id` you've handled and skip repeats. See [Delivery and retries](/webhooks/delivery). > **My server was down. Did I lose webhook events?** > > Blujay retries each event 10 times over about 25 minutes. After that, list the missed deliveries and replay them. See [Delivery and retries](/webhooks/delivery). ## Numbers and limits > **How many messages can I send?** > > A Dedicated number can message up to 50 new contacts a day once established (a new number starts lower and ramps up over about its first month), has room for 1,500 pending messages in its queue, and sends at most 30 messages a minute to the same person. At most 6 texts go to anyone in a row without a response. There's no hourly limit on texts. The live values are on each number in `GET /v1/phone-numbers`. API requests are limited to 600 reads, 120 writes and 60 attachment requests a minute. See [Phone numbers and limits](/phone-numbers) and [Rate limits](/rate-limits). > **What happens if one of my numbers is replaced?** > > You get a [`phone_number.status_updated`](/webhooks/events#phone-numbers) webhook for the old number and one for the new number. Switch your sends to the new number's `phone_number_id`. > **What should I do if a number's health drops?** > > Start fewer new conversations from it, focus on people who reply, and answer quickly. A `critical` number should stop starting new conversations; contact [support@blujay.io](mailto:support@blujay.io). See [Number health](/phone-numbers#number-health). ## Tools and AI > **Is there an SDK?** > > Not yet. Every example uses plain HTTP. There's a ready-made [Postman collection](/api-reference/introduction#use-the-postman-collection), and the [OpenAPI file](/build-with-ai#give-your-assistant-the-whole-docs) works with code generators. > **Can my AI assistant read these docs?** > > Yes. Give it [every page in one file](https://docs.blujay.io/llms-full.txt), connect it to the docs MCP server, or use the **Copy page** menu on any page. See [Build with AI](/build-with-ai). ## Next steps > **[Glossary](/glossary)** > > Every term the docs use. > **[Support](/support)** > > How to get help. # Glossary Source: https://docs.blujay.io/glossary The words the Blujay API docs use, each defined once. | Term | Meaning | | - | - | | **Workspace** | Your Blujay account. An API key belongs to one workspace and can see everything in it. | | **Phone number** | A Blujay number your workspace messages from. It has a plan, a live `send_status`, and on Dedicated and AI Agent plans, `limits`, `queue` and `health_status`. | | **Plan** | Free Plan, Dedicated or AI Agent. Decides who a number can message and which features it can use. See [Plans](/plans). | | **Shared number** | The Free Plan number, shared with other Free Plan workspaces. `access_type: "shared"`. | | **Dedicated number** | A number that is yours alone. `access_type: "dedicated"`. Both Dedicated and AI Agent numbers are dedicated numbers. | | **Contact** | A person saved in your workspace, with a phone number and optionally an email, name, tags, stage and assigned team member. | | **Verified contact** | On the Free Plan, a person who texted your shared number first and was verified in Blujay. Only verified contacts can be messaged. | | **New contact** | Someone a phone number has never texted before. The daily new-contact limit counts these. People who text you first never count. | | **Recipient** | Who a message goes to: the `to` addresses on a send. | | **Participant** | A person in a group chat. Your own numbers in the group are listed separately, in the chat's `phone_numbers`. | | **Active chat** | A chat Blujay still treats as current. Typing, read receipts, replies and effects need an active chat; otherwise they return `UNSUPPORTED_CHAT_OPERATION`. See [Group chats](/messaging/group-chats). | | **Established iMessage group** | A group chat whose first message has gone out over iMessage. Rename, add, remove, replies and effects need one. See [Group chats](/messaging/group-chats). | | **Sender** | On an inbound group message, the participant who wrote it (`sender` object). | | **Chat** | A conversation on one of your numbers. `direct` with one person, or `group` with several. A chat has one `chat_id` for its whole life. | | **Direct chat** | A chat between one of your numbers and one person. | | **Group chat** | A chat between one of your numbers and two or more people. iMessage groups support rename, add and remove; see [Group chats](/messaging/group-chats). | | **Message** | One message in a chat, inbound or outbound, with a `status`. | | **Part** | What a message contains: `text`, `link`, `media` or `vcard`. A message is a list of parts in order. | | **Attachment** | A file you uploaded once, referenced by `attachment_id`, or a file someone sent you. | | **Contact card** | A vCard you saved in Blujay and can send as a native contact card. Not the same as your number's own Name & Photo, which the API doesn't manage today. | | **Service** | How a message travels: `iMessage`, `RCS` or `SMS`. Webhook payloads use lowercase. | | **Auto** | Leaving out `preferred_service`. Blujay picks iMessage when the person can receive it, otherwise RCS or SMS. | | **Degradation** | Something Blujay could not deliver because the message went out as RCS or SMS, for example an effect. Listed in `degradations`. | | **Capability check** | `POST /v1/capability/evaluate_service`: whether a number can receive iMessage, RCS or SMS. | | **Reply** | A text or a tapback from the contact. Replies lift the pre-reply rules and restart the 6-text count. Calls never count. | | **Pre-reply rules** | The rules on sends before a contact replies: no links, no attachments, 300 characters. See [Sending rules](/messaging/sending-rules). | | **Opt-out** | A record that a phone number or email must not be messaged. Sends return `RECIPIENT_SUPPRESSED`. | | **Idempotency key** | The `Idempotency-Key` header that makes a retried write safe. See [Idempotency](/idempotency). | | **Trace ID** | `trace_id` in every response body and the `X-Trace-ID` header. Identifies one request. Include it when you contact support. | | **Webhook subscription** | An HTTPS address where Blujay sends events, with the event types and phone numbers you chose. | | **Event** | One thing that happened, delivered to a subscription with an `event_id`. | | **Webhook version** | The date-named payload shape a subscription is pinned to. See [Versions](/webhooks/versions). | | **Workflow** | An automation built in Blujay. The API can enroll a contact in one. | ## Next steps > **[Key concepts](/key-concepts)** > > How the resources fit together. > **[Plans](/plans)** > > What each plan can do. # Idempotency Source: https://docs.blujay.io/idempotency Retry sends and other changes safely, without ever sending a message twice. Networks fail. A request can time out after Blujay has already sent your message. An idempotency key lets you retry without risk: Blujay recognizes the key and returns the original result instead of doing the work again. ```bash theme={null} curl https://api.blujay.io/v1/chats \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-welcome" \ -d '{"from":"+15555550100","to":["+15555550123"],"message":{"parts":[{"type":"text","text":"Hi Ava, your order has shipped."}]}}' ``` ## How it works | You send | Blujay returns | | - | - | | A new key | Does the work and remembers the key with the result. | | The same key and the same request, after it finished | The original response body with `200 OK` (instead of `202`). Nothing is sent again. | | The same key while the first request is still running | [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress) (`409`, retryable). Retry shortly. | | The same key with a different request | [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) (`409`, not retryable). Nothing is sent. | * **Format:** any string of 1 to 255 characters. Use something tied to the business event, such as `order-1042-welcome`, or a UUID you store with the job. * **Scope:** a key is scoped to your workspace, your API key and a route family (below). The same key on a different route family is independent. * **Lifetime:** Blujay keeps a key for 24 hours after the request that used it. Retry within that window. * **Optional, but use it:** without a key, every retry is a new request and can send a duplicate message. ## Route families Each idempotent endpoint belongs to a route family. A key is unique within its family, not within one URL. | Route family | Endpoint | Purpose | | - | - | - | | `chats.create` | `POST /v1/chats` | Start a chat with a first message | | `chats.messages.create` | `POST /v1/chats/{chat_id}/messages` | Send a follow-up message | | `chats.participants.add` | `POST /v1/chats/{chat_id}/participants` | Add a participant to a group chat | | `chats.participants.remove` | `DELETE /v1/chats/{chat_id}/participants` | Remove a participant from a group chat | | `messages.edit` | `PATCH /v1/messages/{message_id}` | Edit an iMessage | | `messages.unsend` | `POST /v1/messages/{message_id}/unsend` | Unsend an iMessage | | `messages.retry` | `POST /v1/messages/{message_id}/retry` | Retry a failed message | | `webhook_events.replay` | `POST /v1/webhook-subscriptions/{subscription_id}/events/{event_id}/replay` | Replay a webhook event | > [!WARNING] > > **Common mistake:** `chats.messages.create` is one family for every chat. Sending `Idempotency-Key: order-1042-link` to `chat_abc123` and then the same key to `chat_grp789` is a different request under the same key, so the second returns [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch). Put something unique to the chat or the business event in the key. The two send endpoints also accept the key as an `idempotency_key` field in the body. If you send both, they must match, or you get [`IDEMPOTENCY_KEY_MISMATCH`](/errors/idempotency-key-mismatch). ## Writes that ignore the key These endpoints accept an `Idempotency-Key` header without error but don't use it. A retry does the work again, so check before you repeat one. | Endpoint | What a repeat does | How to stay safe | | - | - | - | | `POST /v1/webhook-subscriptions` | Creates a second subscription, and your endpoint gets every event twice | List subscriptions before retrying | | `POST /v1/vcards` | Creates a second saved contact card | List saved cards first | | `POST /v1/contacts` | Returns [`CONTACT_ALREADY_EXISTS`](/errors/contact-already-exists) for the same phone number | Treat that error as success | | `POST /v1/contacts/import` | Matches the same contact again and updates it; nothing is duplicated | Safe to repeat | | `POST /v1/attachments` | Starts a second upload | Reuse the `attachment_id` you already have | | `PATCH /v1/chats/{chat_id}` (rename) | Renames again to the same name | Harmless to repeat | | `POST /v1/chats/{chat_id}/leave` | Tries to take the number out again | Read the chat's participants first | | `POST /v1/chats/{chat_id}/typing`, `DELETE …/typing`, `POST …/read` | Repeats the signal | Harmless to repeat | Workflow enrollments use their own required `request_id` field instead. See [Workflows](/workflows). ## Rejected requests are not remembered Blujay remembers a key only when the request was accepted (`202`, or `200` for an edit or unsend). A request rejected with a `4xx`, for example [`VALIDATION_ERROR`](/errors/validation-error) or [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked), releases the key. You can send the fixed request with the same key or a new one; both run as a new request. If Blujay never finished the original request (for example your connection dropped mid-way and the work was lost), the key is released after about 5 minutes, and a retry with the same key runs the request. Until then the retry returns [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress). ## When to retry | Result | What to do | | - | - | | Timeout, connection error, `500`, `503` | Retry with the same key. You get the original result if it had already succeeded. | | `429` | Wait for `Retry-After` seconds, then retry with the same key. | | `409` with `retryable: true` | Retry with the same key after about 1 second, backing off. | | Any other `4xx` | Don't retry as is. Fix the request; the key was released, so reuse it or pick a new one. | | `202 Accepted`, then the message fails later | Don't resend automatically. Read the reason from the `message.failed` webhook (`failure.code`), or `failure_code` on the message when it has one, fix the cause, then try `POST /v1/messages/{message_id}/retry`. It works only for direct-chat messages that failed before leaving Blujay; anything else returns [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed), and the message may already have arrived. Check before sending a new message with a new key. | Back off between retries, for example 1, 2, 4, 8 and 16 seconds with some random jitter, and give up after about 5 attempts or a few minutes. Keep the key with the job so a retry after a restart uses the same key. If a `Retry-After` is longer than you want to wait (for example a daily limit), schedule the send for later; a key is honored for 24 hours. ## Next steps > **[Errors](/errors)** > > Every error code and whether it is retryable. > **[Rate limits](/rate-limits)** > > Request limits and how to back off. > **[Track a message](/messaging/lifecycle)** > > What happens after `202`, and when to retry a failed message. # Blujay API Source: https://docs.blujay.io/index Send and receive iMessage, RCS and SMS from your Blujay phone numbers. The Blujay API lets your product message people from your Blujay phone numbers. Blujay picks the best service for each recipient (iMessage, RCS or SMS), tracks each message as far as that service reports (delivery and reads for iMessage and RCS; SMS stops at `sent`), and tells your server what happened through signed webhooks. > **[Quickstart](/quickstart)** > > Send your first message in about five minutes. > **[API reference](/api-reference/introduction)** > > Every endpoint, with a playground you can call from the browser. > **[Webhooks](/webhooks/overview)** > > Get replies, delivery updates and more pushed to your server. > **[Errors](/errors)** > > Every error code, what it means and how to fix it. ## Send a message in one request ```bash theme={null} curl https://api.blujay.io/v1/chats \ -H "Authorization: Bearer $BLUJAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-welcome" \ -d '{ "from": "+15555550100", "to": ["+15555550123"], "message": { "parts": [{ "type": "text", "text": "Hi! Thanks for reaching out." }] } }' ``` Blujay answers `202 Accepted` and sends it as iMessage, RCS or SMS. The [Quickstart](/quickstart) walks you through the full setup in about five minutes. Words like [phone number](/glossary), chat, contact and recipient are defined in the [Glossary](/glossary). > [!TIP] > > **Building with AI?** Give your assistant [every page in one file](https://docs.blujay.io/llms-full.txt), connect it to the [docs MCP server](/build-with-ai#give-your-assistant-the-whole-docs), or start from the prompts in [Build with AI](/build-with-ai). ## What you need * **A Blujay workspace.** [Sign up free](https://app.blujay.io/?auth=register) if you don't have one. Blujay has three plans: Free Plan, Dedicated and AI Agent. Each one changes who your numbers can message. See [Plans](/plans). * **Workspace owner or admin access.** Only workspace owners and admins see the **API** tab, where your API key lives. * **US recipients.** Blujay sends to US phone numbers only. * **An HTTPS address on your server**, if you want replies and delivery updates sent to you as they happen. ## Common uses * **Instant replies** to new inquiries, so no one waits for a callback. * **Follow-ups and check-ins** with people you're already talking to. * **Appointment confirmations and reminders**, with replies that come straight back to you. * **Two-way customer support** over text. * **AI assistants that text**, answering questions and handing off to a person. * **Texts kept in sync with your CRM** or internal tools. Blujay is built for conversations. For one-way bulk alerts or one-time passcodes, use a dedicated SMS provider. ## What the API does | Area | What the API does | | - | - | | [Messaging](/messaging/send-messages) | Start chats, send text, links, photos, videos, files and contact cards, and read the full history. The [sending rules](/messaging/sending-rules) say what a message can contain before the contact replies. | | [Service choice](/messaging/choosing-a-service) | Check whether a number can get iMessage, RCS or SMS before you send, or let Blujay choose. | | [Group chats](/messaging/group-chats) | Create iMessage groups, add and remove participants, rename the group. | | iMessage features | [Threaded replies](/messaging/replies), [screen and bubble effects and text styles](/messaging/effects), [edit and unsend](/messaging/edit-and-unsend), [typing indicators and read receipts](/messaging/typing-and-read-receipts). | | [Contacts](/contacts) | Create, update, tag and assign contacts, and manage opt-outs. | | [Workflows](/workflows) | Enroll contacts in your Blujay workflows from your own systems. | | [Webhooks](/webhooks/overview) | Receive 18 event types, signed with the Standard Webhooks scheme and pinned to a payload version. | ## Built to be safe to retry * **Idempotent sends.** Send an `Idempotency-Key` and a retried request never sends twice. See [Idempotency](/idempotency). * **Stable errors.** Every error has a stable `code`, a `retryable` flag and a [page that explains the fix](/errors). * **Traceable.** Every response carries a trace ID in the `X-Trace-ID` header (and as `trace_id` in JSON bodies). Include it when you contact support. * **Honest status.** A `202 Accepted` means queued, not delivered. Delivery, read and failure arrive as [webhooks](/webhooks/events) or on the message itself. See [Track a message](/messaging/lifecycle). ## Base URL ```text theme={null} https://api.blujay.io/v1 ``` All requests use HTTPS and JSON. Authenticate with your workspace [API key](/authentication). > [!NOTE] > > Every API call acts on your real workspace. There is no separate test mode, so messages you send through the API are delivered. Test with your own phone number first. ## Questions? Email [support@blujay.io](mailto:support@blujay.io). Include the `trace_id` from any response you're asking about. See [Support](/support). # Key concepts Source: https://docs.blujay.io/key-concepts The handful of things the Blujay API is built around, and how they fit together. ```mermaid theme={null} flowchart LR W[Workspace] --> P[Phone numbers] W --> C[Contacts] P --> CH[Chats] CH --> M[Messages] M --> PT[Parts: text, link, media, contact card] W --> WH[Webhook subscriptions] M -. events .-> WH ``` | Concept | What it is | | - | - | | **Workspace** | Your Blujay account. An API key belongs to one workspace and can see everything in it. IDs start with `workspace_`. | | **Phone number** | A Blujay number your workspace sends from. Each one has a live sending status, limits and health. See [Phone numbers and limits](/phone-numbers). IDs start with `phone_`. | | **Plan** | What your numbers can do. **Free Plan**: a shared number that can message up to 10 verified contacts. **Dedicated**: your own number with full functionality. **AI Agent**: your own number that only replies to people who text it first. See [Plans](/plans). | | **Contact** | A person saved in Blujay, with a phone number (required when you create one through the API), name, tags, stage and assigned team member. See [Contacts](/contacts). IDs start with `contact_`. | | **Recipient** | Who a message goes to. A recipient doesn't have to be a contact yet; Blujay creates the contact when you start a chat. | | **Chat** | A conversation on one of your phone numbers: a **direct chat** with one person, or a **group chat** with several **participants**. You start one with its first message. IDs start with `chat_`. | | **Message** | One message in a chat, inbound or outbound. An outbound message has a `status` that changes as it is sent, delivered and read. IDs start with `msg_`. | | **Parts** | What a message contains: `text`, a `link`, `media` (a photo, video or file) or a `vcard` contact card. See [Send messages](/messaging/send-messages). | | **Service** | How a message travels: `iMessage` (blue bubbles), or `RCS` and `SMS` (green bubbles). Leave out `preferred_service` (**Auto**) and Blujay chooses, or ask for one. See [iMessage, RCS or SMS](/messaging/choosing-a-service). | | **New contact** | Someone a phone number has never texted before. Each Dedicated number can message a limited number of new contacts a day. See [Phone numbers and limits](/phone-numbers#new-contacts-per-day). | | **Verified contact** | On the Free Plan, a person who texted your number first and was verified in the app. A Free Plan number can message only verified contacts. | | **Attachment** | A file you upload once and send many times. IDs start with `att_`. See [Media](/messaging/media). | | **Webhook subscription** | An HTTPS address on your server where Blujay sends events, such as new replies and delivery updates. IDs start with `whsub_`. See [Webhooks](/webhooks/overview). | | **Event** | One thing that happened, delivered to your webhook. IDs start with `evt_`. See [Events](/webhooks/events). | | **Workflow** | An automation you build in Blujay. The API can enroll contacts in one. See [Workflows](/workflows). | Every term, including the ones other pages lean on, is in the [Glossary](/glossary). ## How a message flows 1. You send with `POST /v1/chats` (first message) or `POST /v1/chats/{chat_id}/messages` (follow-up). Blujay answers `202 Accepted` right away. 2. Blujay checks the [sending rules](/messaging/sending-rules), picks the service and sends from your phone number. 3. The message moves through its statuses: `accepted`, `sending`, `sent`, then `delivered` and `read` where the service reports them, or `failed`. Inbound messages have the status `received`. See [Track a message](/messaging/lifecycle). 4. Each change is sent to your [webhooks](/webhooks/overview). Replies arrive as `message.received`. See [Receive messages](/messaging/receive-messages). ## Rules that apply everywhere * **One envelope.** Every response has `status`, `data` or `error`, `trace_id` and `timestamp`. See [Making requests](/making-requests). * **Safe retries.** Send an `Idempotency-Key` on sends and other changes, and a retry never sends twice. See [Idempotency](/idempotency). * **Stable errors.** Every error has a fixed `code` with its own page. See [Errors](/errors). * **Live only.** There is no test mode; every call acts on your real workspace. See [Authentication](/authentication#test-and-live). * **US numbers only.** Recipients must be US phone numbers. ## Next steps > **[Quickstart](/quickstart)** > > Send your first message. > **[Sending rules](/messaging/sending-rules)** > > What a message can contain before and after a reply. > **[Plans](/plans)** > > What each plan lets your numbers do. > **[Glossary](/glossary)** > > Every term the docs use. # Making requests Source: https://docs.blujay.io/making-requests Base URL, response format, IDs, pagination, tracing and versioning. ## Base URL and format ```text theme={null} https://api.blujay.io/v1 ``` Send JSON with `Content-Type: application/json` and authenticate with `Authorization: Bearer …` ([Authentication](/authentication)). Call the API from your server only: keys must never reach a browser or mobile app. * **Timestamps** are ISO 8601 in UTC, for example `2026-10-14T17:00:00.000Z`. * **Phone numbers** are E.164, for example `+15555550123`. Recipients must be US numbers. * **Services** are `iMessage`, `RCS` and `SMS` in API responses. Webhook payloads use lowercase `imessage`, `rcs` and `sms`. * **Unknown body fields** are rejected with [`VALIDATION_ERROR`](/errors/validation-error), and so is a body that isn't valid JSON. > [!WARNING] > > **Common mistake:** in a query string, `+` means a space. URL-encode a phone number as `%2B15555550123`, for example `GET /v1/contacts/search?phone_number=%2B15555550123`. In a JSON body, write `+15555550123` as is. ## Responses Every successful response has the same envelope. The result is in `data`: ```json theme={null} { "status": "success", "data": { "chat_id": "chat_abc123" }, "trace_id": "trc_4c8e2a91f0d7", "timestamp": "2026-10-14T17:01:00.000Z" } ``` Errors use `status: "error"` and an `error` object with a stable `code`. Check `status` before you read `data`: an error response has no `data`. See [Errors](/errors). | Status | Meaning | | - | - | | `200 OK` | Done. | | `201 Created` | A resource was created: a contact, a saved contact card, an attachment, a webhook subscription or a workflow enrollment. | | `202 Accepted` | Accepted for processing, for example a message send. The work finishes later; watch for it with [webhooks](/webhooks/overview). | | `204 No Content` | Done, with no body, for example typing indicators and read receipts. | | `4xx` | Something about the request needs to change. Retry only when `error.retryable` is `true`: that is every `429`, and a few `409`s such as [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress). Most `409`s are not retryable. | | `5xx` | A problem on Blujay's side, or a change that couldn't be made. Retry with the same `Idempotency-Key` when `error.retryable` is `true`. | ## IDs Resource IDs are opaque strings with a prefix that tells you the type. Store them as strings and don't parse them. (Credential IDs in `GET /v1/me` are plain UUIDs.) | Prefix | Resource | | - | - | | `workspace_` | Workspace | | `phone_` | Phone number | | `chat_` | Chat (direct or group) | | `msg_` | Message | | `contact_` | Contact | | `att_` | Attachment | | `vcard_` | Saved contact card | | `workflow_` | Workflow | | `member_` | Team member | | `whsub_` | Webhook subscription | | `evt_` | Webhook event | | `trc_` | Trace (one request) | An ID with the wrong prefix for an endpoint returns [`INVALID_PUBLIC_ID`](/errors/invalid-public-id). IDs from another workspace return [`NOT_FOUND`](/errors/not-found). ## Pagination Most list endpoints return a page of results and a cursor. These return everything at once, with no cursor: `GET /v1/phone-numbers`, `GET /v1/contacts/search` (up to `limit` matches), `GET /v1/workflows`, `GET /v1/team-members/assignable` and `GET /v1/webhook-subscriptions`. ```json theme={null} { "status": "success", "data": { "data": [{ "chat_id": "chat_abc123" }, { "chat_id": "chat_def456" }], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoi…", "limit": 25 }, "trace_id": "trc_1a2b3c4d5e6f", "timestamp": "2026-10-14T17:02:00.000Z" } ``` * `limit` sets the page size: 1 to 100, default 25. Contact search is the exception: 1 to 25, default 10, with no cursor. * While `has_more` is `true`, pass `next_cursor` as `cursor` to get the next page. Keep the same filters. * Cursors are opaque and tamper-proof. A changed or foreign cursor returns [`INVALID_CURSOR`](/errors/invalid-cursor). Leave `cursor` out of the first request; a placeholder such as `cursor=string` is rejected the same way. ```bash theme={null} curl "https://api.blujay.io/v1/chats?limit=50&cursor=eyJ2IjoxLCJrIjoi…" \ -H "Authorization: Bearer blu_your_api_key" ``` ## Tracing requests * Every response includes `trace_id` in the body and an `X-Trace-ID` header. * Send your own `X-Request-ID` header to correlate with your logs. Blujay echoes it back as `client_request_id` in error responses and in most success responses. The send responses (`POST /v1/chats`, `POST /v1/chats/{chat_id}/messages` and `POST /v1/messages/{message_id}/retry`) leave it out, so map your request ID to the returned `message.id` yourself there. Log both. When you contact [support@blujay.io](mailto:support@blujay.io), include the `trace_id`, your `X-Request-ID` and the time of the request. Never include your API key. ## Retries and timeouts Use a client timeout of about 30 seconds. If a write times out or returns a retryable error, retry it with the **same** `Idempotency-Key`, so a request that actually succeeded is never repeated. See [Idempotency](/idempotency) and [Rate limits](/rate-limits). ## Time zones and `reset_at` Every timestamp is UTC. One limit follows a local clock: a number's new-contact count resets at midnight in your workspace's time zone, which the API doesn't expose. Don't compute the reset yourself. Read `limits.new_contacts.reset_at` on the number, or `error.details[0].reset_at` on a `429`, and wait until then. See [Phone numbers and limits](/phone-numbers#when-limits-reset). ## Versioning The version is in the path: `/v1`. Within v1, Blujay works to keep a well-written client working. **Changes Blujay can make at any time:** * adding endpoints, optional request fields and response fields; * adding error codes for new situations, webhook event types and enum values; * rewording `error.message` and other text meant for people; * adjusting sending limits and messaging-safety rules to protect delivery. These always use documented error codes, and Dedicated numbers report their current limits in [`GET /v1/phone-numbers`](/phone-numbers). **Changes Blujay avoids:** * removing or renaming an endpoint, field, error code or enum value; * making an optional request field required; * changing what a field means or its type; * returning a different error code for the same situation; * changing a webhook payload within a version you've pinned. If one of these is ever needed, we'll email the admins of workspaces that use the API ahead of time, and the API reference will mark the affected item **Deprecated**. The only exception is a change needed right away for security or legal reasons. Write your client to ignore fields it doesn't know and to handle unknown enum values. Branch on `error.code`, never on `error.message`. Webhook payload shapes are versioned separately by date, and a subscription stays on the version you pin. See [Webhook versions](/webhooks/versions). ## Next steps > **[Idempotency](/idempotency)** > > Retry safely without sending twice. > **[Rate limits](/rate-limits)** > > Request limits and how to back off. > **[Phone numbers and limits](/phone-numbers)** > > Sending limits and the live fields that report them. > **[Errors](/errors)** > > Every error code and whether it is retryable. # iMessage, RCS or SMS Source: https://docs.blujay.io/messaging/choosing-a-service What works on each service, how to check which service a phone number gets, and when to skip the check and send with Auto. Blujay sends each direct message over iMessage, RCS or SMS. You usually don't need to decide: leave out `preferred_service` and Blujay picks for you (Auto). This page shows what each service can carry, how to check a number in advance when your app needs to know, and when the check isn't worth it. ## What works on each service | Feature | iMessage | RCS | SMS | | - | - | - | - | | Text | ✅ | ✅ | ✅ | | Links (after the contact replies) | ✅ with a link preview | ✅ preview depends on the recipient's phone | ✅ preview depends on the recipient's phone | | Photos and videos | ✅ | ✅ | ✅ up to 5 MiB per message (MMS) | | PDFs and other files | ✅ | ❌ | ❌ | | [Contact cards](/messaging/contact-cards) | ✅ | ✅ | ✅ | | Voice memos | ❌ Sending isn't supported: an audio file sends as an ordinary media file. Voice memos you receive arrive as audio media. | ❌ | ❌ | | [Effects](/messaging/effects) | ✅ | Text sent, effect dropped | Text sent, effect dropped | | [Text styles](/messaging/effects) (direct chats) | ✅ | ❌ | ❌ | | [Threaded replies](/messaging/replies) | ✅ | ❌ | ❌ | | [Edit and unsend](/messaging/edit-and-unsend) | ✅ | ❌ | ❌ | | Reactions (tapbacks) | Receive only, as `reaction.added` and `reaction.removed` webhooks. Sending one returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). | ❌ | ❌ | | [Typing indicators](/messaging/typing-and-read-receipts), sent and received | ✅ | ❌ | ❌ | | [Read receipts you send](/messaging/typing-and-read-receipts) (direct chats) | ✅ | ✅ | ❌ | | [Group changes](/messaging/group-chats): add, remove, rename, leave | ✅ | ❌ | ❌ | | Group icon | ❌ Not supported (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)) | ❌ | ❌ | | `delivered` status | ✅ | When reported | ❌ | | `read` status | ✅ when the recipient has read receipts on | When reported | ❌ | Links, media and contact cards are all rejected until the contact replies, whatever the service. See [Sending rules](/messaging/sending-rules). ## Check a phone number When your app needs the service in advance, for example to show the recipient's service in your interface or to keep large media out of an SMS, check the number with `POST /v1/capability/evaluate_service`. Send the US number you want to check and the Blujay phone number you'll send from: ```bash theme={null} curl https://api.blujay.io/v1/capability/evaluate_service \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "number": "+15555550123", "from_phone_number_id": "phone_abc123" }' ``` ```json theme={null} { "status": "success", "data": { "number": "+15555550123", "service": "iMessage", "checked_at": "2026-10-14T17:00:00.000Z", "source": "fresh" }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:00:00.000Z" } ``` | Field | Meaning | | - | - | | `service` | `iMessage`, `RCS` or `SMS`. | | `source` | `fresh` when this request ran a check (or joined one already running), `cached` when Blujay reused a recent answer. | | `checked_at` | When the check ran. For a cached answer, it's earlier than your request. | Always send `from_phone_number_id`. Find your numbers' IDs with [`GET /v1/phone-numbers`](/phone-numbers). You can leave it out only when your workspace has exactly one active number; with several, the request returns `400` [`PHONE_NUMBER_REQUIRED`](/errors/phone-number-required). An unknown or inactive number returns `404` [`NOT_FOUND`](/errors/not-found), and a number that isn't a valid US phone number returns `400` [`VALIDATION_ERROR`](/errors/validation-error). The check counts toward your read request limit, not your write limit; see [Rate limits](/rate-limits). ## Read an `SMS` answer carefully `iMessage` and `RCS` answers are confirmed. `SMS` means one of two things: * the number is confirmed to use SMS, or * Blujay couldn't confirm iMessage or RCS this time. The response doesn't say which. If an `SMS` answer matters, check again later: an inconclusive answer is only reused for 30 seconds, so a later check runs fresh. ## Cached answers are free When Blujay recently checked the same number from the same Blujay phone number, you get that answer back with `source: cached`. It doesn't matter whether the earlier check came from this endpoint, from your team in the Blujay app, or from a send. | Answer | Reused for | | - | - | | Confirmed (`iMessage`, `RCS`, or confirmed `SMS`) | 5 minutes | | Inconclusive (`SMS` because Blujay couldn't confirm) | 30 seconds | Cached answers are free and never limited. ## Fresh check limits A fresh check runs from your Blujay phone number and shares its capacity with your messages, which always go first. Each of your numbers can run: * **30 fresh checks per clock hour**, and * **100 fresh checks per UTC day**. A fresh check counts as soon as it starts, even if it ends inconclusive. Cached answers don't count. When a number can't run another fresh check yet, the call returns `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) with a `Retry-After` header. `error.details[0].limit` tells you why: | `limit` | Meaning | | - | - | | `line_capacity` | The number is busy with messages or your recent checks. | | `line_hourly_quota` | The number has run 30 fresh checks this clock hour. | | `line_daily_quota` | The number has run 100 fresh checks this UTC day. | ```json theme={null} { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "This phone number cannot run another capability check yet", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/rate-limit-exceeded", "details": [{ "limit": "line_hourly_quota" }], "retry_after": 1260 }, "trace_id": "trc_6d2a9c41e8b3", "timestamp": "2026-10-14T17:39:00.000Z" } ``` Wait `Retry-After` seconds before checking from that number again. If checks are briefly unavailable, you get `503` [`CAPABILITY_CHECK_UNAVAILABLE`](/errors/capability-check-unavailable), also with `Retry-After`. ## When to send with Auto You don't need to check a number before you send to it. With Auto, Blujay works out the service when it sends, and sending never uses your capability-check limits. | You want to | Do this | | - | - | | Send a message and let Blujay pick the best service | Send with Auto (leave out `preferred_service`). Skip the check. | | Show the recipient's service in your app before anyone sends | Check, and cache the answer on your side. | | Change the content by service, for example a smaller file for SMS | Check, then send. | | Send only if the recipient has iMessage | Send with `preferred_service: "iMessage"`. The message fails instead of going out another way, so you don't need a separate check. | | Keep a message off iMessage | Send with `preferred_service: "RCS"` or `"SMS"`. | Don't check every number before every send, especially for bulk sends: you'll spend your fresh checks for nothing. The service that actually carried a message is always in its `service` field afterwards; see [Track a message](/messaging/lifecycle). > [!WARNING] > > **Common mistakes** > > * Leaving out `from_phone_number_id` with more than one number returns `400` [`PHONE_NUMBER_REQUIRED`](/errors/phone-number-required). Pick the number to check from. > * An `SMS` answer may be inconclusive. Don't treat it as proof the person has no iMessage. > * `error.details[0].limit` on this endpoint names the quota (`line_hourly_quota`). On sends, the same error uses `details[0].limit_type`. > * Group chats have no `preferred_service`. Blujay picks for the whole group, and the chat's `service` reports what the group runs on (`iMessage` or `SMS`). ## Limitations * The check covers direct chats only. A group chat doesn't accept `preferred_service` (`400` [`VALIDATION_ERROR`](/errors/validation-error)), and a group's `service` on `GET /v1/chats/{chat_id}` reports the group's service rather than a per-recipient check. * An `SMS` answer can mean "not confirmed". There is no field that says which. * 30 fresh checks per clock hour and 100 per UTC day per number (`429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded)). Cached answers are unlimited. * The check needs an active number. An inactive or unknown `from_phone_number_id` returns `404` [`NOT_FOUND`](/errors/not-found). ## Next steps > **[Send messages](/messaging/send-messages)** > > Send with Auto or a preferred service. > **[Track a message](/messaging/lifecycle)** > > See which service carried each message. > **[Phone numbers and limits](/phone-numbers)** > > Find your numbers and their IDs. > **[Rate limits](/rate-limits)** > > Request limits and how to back off. # Contact cards Source: https://docs.blujay.io/messaging/contact-cards Save vCards once and send them as native contact cards that recipients can save in one tap. A contact card (vCard) arrives on the recipient's phone as a native card they can save to their contacts in one tap. Save a card once with `POST /v1/vcards` and send it by ID with a `vcard` part, or send your own `.vcf` file as media. Contact cards work in direct and group chats, over iMessage, RCS and SMS, once the contact has replied to your number. > [!WARNING] > > A contact card can't go in a first message or in any message before the contact replies. Blujay rejects it with `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) and nothing is sent. Introduce yourself in text first, then send the card. See [Sending rules](/messaging/sending-rules). > [!NOTE] > > A contact card is a file the recipient can save. It doesn't change the name or photo that Messages shows for your Blujay number. ## Save a card ```bash theme={null} curl https://api.blujay.io/v1/vcards \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Northwind", "last_name": "Support", "company": "Northwind Supply", "title": "Customer Support", "phones": [ { "number": "+15555550100", "type": "work" } ], "emails": [ { "address": "support@example.com", "type": "work" } ], "website": "https://example.com/support" }' ``` ```json 201 Created theme={null} { "status": "success", "data": { "id": "vcard_abc123", "first_name": "Northwind", "last_name": "Support", "company": "Northwind Supply", "title": "Customer Support", "phones": [{ "number": "+15555550100", "type": "work" }], "emails": [{ "address": "support@example.com", "type": "work" }], "website": "https://example.com/support", "has_photo": false, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:00:00.000Z" }, "trace_id": "trc_8b1e4d07a2c5", "timestamp": "2026-10-14T17:00:00.000Z" } ``` The card's ID comes back as `id`, not `vcard_id`. Most Blujay resources name their ID `_id`; saved cards and team members use `id`. Use that `id` as `vcard_id` in a `vcard` part and in the `/v1/vcards/{vcard_id}` path. | Field | Rules | | - | - | | `first_name` | Required. Up to 80 characters. | | `last_name` | Optional. Up to 80 characters. | | `company`, `title` | Optional. Up to 120 characters each. | | `phones` | Up to 5. Each has a `number` in E.164 (`+15555550100`) and a `type`: `mobile` (the default), `work`, `home` or `main`. The first number is the card's preferred number. | | `emails` | Up to 5. Each has an `address` and a `type`: `work` (the default), `home` or `other`. | | `website` | Optional. An `http` or `https` URL. | A card needs at least one phone number or email. Recipients see each `type` as the label next to the number or address. Unknown fields are rejected with `400` [`VALIDATION_ERROR`](/errors/validation-error). Card photos are managed in the Blujay app. `has_photo` tells you whether a card has one, and a card with a photo is sent with it. ## Send a saved card Add a `vcard` part with the card's ID to a follow-up in a chat where the contact has already replied: ```bash theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: support-card-chat_abc123" \ -d '{ "message": { "parts": [ { "type": "text", "text": "Save our support number so you always know it is us." }, { "type": "vcard", "vcard_id": "vcard_abc123" } ] } }' ``` ```json 202 Accepted theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_ghi789", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:05:00.000Z", "parts": [ { "type": "text", "text": "Save our support number so you always know it is us." }, { "type": "media", "attachment_id": "att_def456" } ] } }, "trace_id": "trc_c5a0e93f17b2", "timestamp": "2026-10-14T17:05:00.000Z" } ``` Blujay builds the card file when it accepts the message, so the `vcard` part comes back as a `media` part with its own `attachment_id`. Message reads and webhooks show it the same way: a `media` part with `content_type` `text/vcard` and a `.vcf` file name built from the card's name, such as `Northwind Support.vcf`. The `vcard_id` isn't kept on the message, so a read can't tell you which saved card was sent. If you need that, record it against the `message_id` yourself when you send. * A `vcard` part counts as a media part. It can sit next to `text` parts but not a `link`, and a message can have up to 40 `media` and `vcard` parts in total. * Because the file is built at acceptance, later edits to the card never change a message you already sent. Retrying with the same `Idempotency-Key` returns the original message and doesn't build a second file. * An unknown or deleted `vcard_id` returns `404` [`NOT_FOUND`](/errors/not-found) and nothing is sent. * Before the contact replies, the send returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) and nothing is sent. A card a contact sends you arrives the same way: a `media` part with `content_type` `text/vcard`. Download it like any other [inbound media](/messaging/media#media-in-received-messages). ## Send your own `.vcf` file If you already have a vCard file, send it like any other media, either uploaded or from a URL: ```json theme={null} { "type": "media", "url": "https://example.com/cards/northwind-support.vcf" } ``` Serve a vCard URL as `text/vcard` or `text/x-vcard`, or as a generic binary type with a `.vcf` file name. See [Media](/messaging/media) for uploads and URL rules. ## Manage saved cards | Endpoint | What it does | | - | - | | `GET /v1/vcards` | Lists your saved cards, most recently updated first. Paginate with `limit` and `cursor`. | | `GET /v1/vcards/{vcard_id}` | Returns one card. | | `PATCH /v1/vcards/{vcard_id}` | Changes only the fields you send. The card must keep at least one phone number or email. | | `DELETE /v1/vcards/{vcard_id}` | Deletes the card and returns `204`. Messages that already included it keep their copy. | To update a card's title: ```bash theme={null} curl -X PATCH https://api.blujay.io/v1/vcards/vcard_abc123 \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "title": "Customer Support, 24/7" }' ``` Sending `phones` or `emails` in a `PATCH` replaces the whole list. Send `null` for `last_name`, `company`, `title` or `website` to clear it. Saved cards are shared across your workspace, and your team sees the same cards in the Blujay app. Creating a card ignores `Idempotency-Key`; if a create times out, list your cards before you create it again. > [!WARNING] > > **Common mistakes** > > * Sending a card before the contact replies returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked). > * The create response names the ID `id`. Everywhere else it's `vcard_id`. > * A `vcard` part can't share a message with a `link` part. > * Reads show the card as `media` with `text/vcard`, never as a `vcard` part, and without the `vcard_id`. ## Limitations * No contact cards until the contact replies (`400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)). See [Sending rules](/messaging/sending-rules). * Free Plan numbers can't send to unverified contacts (`403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified)); AI Agent numbers only reply to people who text first (`403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)). See [Plans](/plans). * Card photos can only be set in the Blujay app. `has_photo` is read-only. * A card needs at least one phone number or email, up to 5 of each (`400` [`VALIDATION_ERROR`](/errors/validation-error)). * Sending a card doesn't change the name or photo Messages shows for your number. ## Next steps > **[Media](/messaging/media)** > > Upload files and send media from URLs. > **[Send messages](/messaging/send-messages)** > > Message parts, first messages and follow-ups. > **[Group chats](/messaging/group-chats)** > > Share a card with everyone in a group. > **[Contacts](/contacts)** > > Manage the people you message. # Edit and unsend Source: https://docs.blujay.io/messaging/edit-and-unsend Change the text of an iMessage you sent, take one back, and track edits and unsends through webhooks. iMessage lets the sender change a message's text for a short time after sending it, or take it back completely. You edit with `PATCH /v1/messages/{message_id}` and unsend with `POST /v1/messages/{message_id}/unsend`, and the change shows on the recipient's device. Both endpoints honor `Idempotency-Key`. ## Know which messages you can change You can edit or unsend a message when all of these are true: * Your workspace sent it. Messages your contacts sent can't be changed. * It went out as iMessage. SMS and RCS messages can't be changed. * It has been sent. A failed message, or one that is still sending, can't be changed. * It is text only. Messages with media or contact cards can't be changed. * It hasn't been unsent already. Apple's time limits apply: | Action | Limit | | - | - | | Edit | Within 15 minutes of sending, up to 5 edits | | Unsend | Within 2 minutes of sending | ## Edit a message Send the new `text`. Only text-only messages can be edited, so if you send `part_index`, it must be `0`. A text that contains a link counts as having an attachment, because iMessage adds a link preview to it, so it can't be edited or unsent yet (`409` [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable)). ```bash curl theme={null} curl -X PATCH https://api.blujay.io/v1/messages/msg_abc123 \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: edit-msg-abc123-001" \ -d '{ "text": "See you at 4pm instead." }' ``` Blujay returns `200` once the change is confirmed, with the updated message: ```json Response (200) theme={null} { "status": "success", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "delivered", "parts": [{ "type": "text", "text": "See you at 4pm instead." }], "degradations": [], "reply_to": null, "edited_at": "2026-10-14T17:04:10.000Z", "edit_count": 1, "unsent_at": null, "unsent_scope": null, "trace_id": "trc_0b2d4f6a8c1e", "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:04:10.000Z" }, "trace_id": "trc_9e1a3c5b7d2f", "timestamp": "2026-10-14T17:04:11.000Z" } ``` `parts` always holds the current text. `edited_at` is when the last edit happened and `edit_count` is how many edits the message has had. Blujay returns `200` once the change is confirmed on your phone number. If it can't confirm the change in time, you get `503` [`CHANGE_UNCONFIRMED`](/errors/change-unconfirmed); retry with the same `Idempotency-Key` to check. One rule decides what happens when `text` matches the message's current text: * If the message has been edited before (`edit_count` is 1 or more), Blujay treats the request as a repeat of that edit and returns `200` with the current message. Nothing is sent again. * If the message has never been edited, Blujay returns `400` [`EDIT_UNCHANGED`](/errors/edit-unchanged). Sending the original text to a message that was later edited is a normal edit back to that text. ## Unsend a message Unsend takes no body. ```bash curl theme={null} curl -X POST https://api.blujay.io/v1/messages/msg_abc123/unsend \ -H "Authorization: Bearer blu_your_api_key" \ -H "Idempotency-Key: unsend-msg-abc123-001" ``` The `200` response is the message with `unsent_at` and `unsent_scope` set and its text removed from `parts`. Repeating an unsend returns the current message. ```json Response (200) theme={null} { "status": "success", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "delivered", "parts": [], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": "2026-10-14T17:01:20.000Z", "unsent_scope": "all", "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:01:20.000Z" }, "trace_id": "trc_4c6e8a0b2d1f", "timestamp": "2026-10-14T17:01:21.000Z" } ``` Unsent text is never returned by the API, not in `parts`, not in edit history and not in later webhooks. Recipients on older Apple software may still see the message. ## Read edit history Add `include=edit_history` to `GET /v1/messages/{message_id}`, `GET /v1/messages` or `GET /v1/messages/{message_id}/thread` to get the earlier versions of an edited message, oldest first. ```bash curl theme={null} curl "https://api.blujay.io/v1/messages/msg_abc123?include=edit_history" \ -H "Authorization: Bearer blu_your_api_key" ``` ```json theme={null} { "message_id": "msg_abc123", "parts": [{ "type": "text", "text": "See you at 4pm instead." }], "edited_at": "2026-10-14T17:04:10.000Z", "edit_count": 1, "edit_history": [ { "text": "See you at 3pm.", "replaced_at": "2026-10-14T17:04:10.000Z" } ] } ``` An unsent message always has an empty `edit_history`, because its earlier versions are unsent text too. ## Handle errors | Error | Status | What to do | | - | - | - | | [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable) | 409 | The message isn't one you can change. See [the rules above](#know-which-messages-you-can-change). | | [`EDIT_WINDOW_EXPIRED`](/errors/edit-window-expired) | 409 | More than 15 minutes have passed. Send a new message instead. | | [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | 400 | Editing is switched off for your workspace. Contact support. | | [`EDIT_LIMIT_REACHED`](/errors/edit-limit-reached) | 409 | The message has been edited 5 times. Send a new message instead. | | [`UNSEND_WINDOW_EXPIRED`](/errors/unsend-window-expired) | 409 | More than 2 minutes have passed. | | [`EDIT_UNCHANGED`](/errors/edit-unchanged) | 400 | The new text matches the current text of a message that has never been edited. | | [`MESSAGE_NOT_SENT_YET`](/errors/message-not-sent-yet) | 409 | The message hasn't finished sending. Retry shortly. | | [`SENDER_UNAVAILABLE`](/errors/sender-unavailable) | 409 | The Blujay phone number that sent the message is no longer available. | | [`CHANGE_REJECTED`](/errors/change-rejected) | 502 | The change was refused. It didn't take effect. | | [`CHANGE_UNCONFIRMED`](/errors/change-unconfirmed) | 503 | Blujay couldn't confirm the change. Retry with the same `Idempotency-Key` to check. | | [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) | 409 | You reused an `Idempotency-Key` with different text. | A request Blujay rejected with a `4xx` isn't remembered under its `Idempotency-Key`, so you can fix the request and send it again with the same key. ## Track edits and unsends with webhooks Subscribe to `message.edited` and `message.unsent` to hear about changes to messages your workspace sent, whether they were made through the API or elsewhere in Blujay, and about changes your contacts make to their own iMessages. Only messages up to a week old are tracked for contacts' edits and unsends. A contact's message carries `contact_id` in a direct chat or `sender` in a group, as on `message.received`. ```json message.edited theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "message.edited", "event_id": "evt_abc123", "created_at": "2026-10-14T17:04:10.200Z", "trace_id": "trc_8d0f2b4a6c1e", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "status": "delivered", "direction": "outbound", "requested_service": null, "service": "imessage", "sent_at": "2026-10-14T17:00:01.000Z", "delivered_at": "2026-10-14T17:00:02.000Z", "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [{ "type": "text", "text": "See you at 4pm instead." }], "reply_to": null, "text": "See you at 4pm instead.", "edited_at": "2026-10-14T17:04:10.000Z", "edit_count": 1 } } ``` * `message.edited` carries the current text, `edited_at` and `edit_count`, never earlier versions. Read those with `include=edit_history`. * `message.unsent` carries `unsent_at` and `unsent_scope`, never text. With `unsent_scope: "all"` the whole message was taken back. `"text"` only ever applies to a contact's message: they removed the caption and the attachments remain. Messages you send through the API are text only, so your own unsends are always `"all"`. A later full unsend sends a second event with `"all"`. * Events recorded before an unsend keep their original bodies. If you stored the text from an earlier `message.received` or `message.sent`, delete it when `message.unsent` arrives. * Deliveries can be repeated or arrive out of order. Deduplicate by `event_id`, keep the newest `edited_at` for each `message_id`, and treat an unsend as final. > [!WARNING] > > **Common mistakes** > > * Edit and unsend are synchronous. A `200` means the change is confirmed; a `503` [`CHANGE_UNCONFIRMED`](/errors/change-unconfirmed) means retry with the same key, not that it failed. > * Editing a message that hasn't been sent yet returns `409` [`MESSAGE_NOT_SENT_YET`](/errors/message-not-sent-yet). Wait for `sent`. > * Resending the current text of a never-edited message returns `400` [`EDIT_UNCHANGED`](/errors/edit-unchanged); on an edited message it returns `200` with no change. > * `message.edited` never carries earlier versions. Read them with `include=edit_history`. ## Limitations * iMessage only. SMS and RCS messages return `409` [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable). * Your workspace's messages only; a contact's message can't be edited or unsent (`409` [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable)). * Text-only messages. A message with media or a contact card returns `409` [`MESSAGE_NOT_EDITABLE`](/errors/message-not-editable). * Edit within 15 minutes, up to 5 times (`409` [`EDIT_WINDOW_EXPIRED`](/errors/edit-window-expired), `409` [`EDIT_LIMIT_REACHED`](/errors/edit-limit-reached)). Unsend within 2 minutes (`409` [`UNSEND_WINDOW_EXPIRED`](/errors/unsend-window-expired)). * Only `part_index` `0` can be edited. * Contacts' edits and unsends are tracked for messages up to a week old. * Unsent text is never returned, and recipients on older Apple software may still see it. ## Next steps > **[Webhook events](/webhooks/events)** > > See the full list of message events. > **[Idempotency](/idempotency)** > > Retry edits and unsends safely after a timeout. > **[Threaded replies](/messaging/replies)** > > Reply to a specific message in its iMessage thread. > **[Track a message](/messaging/lifecycle)** > > Understand message statuses before you edit or unsend. # Effects and text styles Source: https://docs.blujay.io/messaging/effects Add iMessage screen and bubble effects to a message, and style ranges of text as bold, italic, strikethrough or underline. iMessage lets a message arrive with a full-screen effect, such as confetti, or a bubble effect, such as slam. It also lets parts of the text appear bold, italic, struck through or underlined. You add effects with `message.effect` and text styles with `text_decorations` on a text part, both on follow-up messages sent with `POST /v1/chats/{chat_id}/messages`. ## Send a message with an effect Set `message.effect` to an object with a `type` and a `name`. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: launch-confetti-001" \ -d '{ "message": { "parts": [{ "type": "text", "text": "Your account is live. Welcome aboard!" }], "effect": { "type": "screen", "name": "confetti" } } }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_abc123", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:30:00.000Z", "parts": [{ "type": "text", "text": "Your account is live. Welcome aboard!" }] } }, "trace_id": "trc_7a9c1e3b5d0f", "timestamp": "2026-10-14T17:30:00.130Z" } ``` `name` must be one of the names for its `type`. Anything else returns `400` [`VALIDATION_ERROR`](/errors/validation-error). | `type` | Valid `name` values | | - | - | | `screen` | `confetti`, `fireworks`, `lasers`, `sparkles`, `celebration`, `hearts`, `love`, `balloons`, `happy_birthday`, `echo`, `spotlight` | | `bubble` | `slam`, `loud`, `gentle`, `invisible` | ## Know where effects work Effects work on text-only follow-up messages in these chats: * **Direct chats, on any service.** You can leave `preferred_service` out or set it to any value. Effects are delivered only over iMessage. If the message goes out as SMS or RCS, the text is still sent without the effect. With `preferred_service: "iMessage"`, the message fails instead if it can't go out as iMessage. * **Established iMessage group chats.** See [Group chats](/messaging/group-chats#know-which-features-need-an-established-imessage-group). These requests return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) before anything is sent: * An effect on a chat's first message (`POST /v1/chats`). * An effect on a message with a link, media or contact card part. * An effect in a group that isn't an established iMessage group. The pre-reply rules apply too: an effect doesn't change what you can send before the contact replies. See [Sending rules](/messaging/sending-rules). ## Check whether the effect was delivered Read the message with `GET /v1/messages/{message_id}`. `effect` shows what you asked for, and `degradations` lists anything that was dropped because the message went out as SMS or RCS. `effect` appears only on messages you sent with one; Blujay doesn't report effects on messages your contacts send. ```json theme={null} { "message_id": "msg_abc123", "service": "SMS", "status": "sent", "effect": { "type": "screen", "name": "confetti" }, "degradations": [ { "code": "effect_not_delivered", "reason": "The iMessage effect was not delivered because the message was sent as SMS or RCS." } ] } ``` `degradations` is an empty array when nothing was dropped. Message webhooks carry the same entries. ## Style text Add `text_decorations` to a text part. Each decoration has a `range` and a `style`: * `range` is `[start, end)` in UTF-16 code units: `start` is the first character to style and `end` is one past the last. `end` must be greater than `start` and no greater than the text's length, and a range can't split a character that takes two UTF-16 code units, such as many emoji. * `style` is `bold`, `italic`, `strikethrough` or `underline`. * A text part can have up to 100 decorations. Style ranges can overlap, so a word can be both bold and underlined. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reminder-styled-001" \ -d '{ "message": { "parts": [ { "type": "text", "text": "Reminder: your appointment is Friday at 10am.", "text_decorations": [ { "range": [0, 8], "style": "bold" }, { "range": [30, 44], "style": "underline" } ] } ] } }' ``` > [!TIP] > > JavaScript string positions are already UTF-16 code units, so `text.indexOf("Friday")` gives you a valid `start`. In Python, convert a character position with `len(text[:i].encode("utf-16-le")) // 2`. Text styles work only on text-only follow-up messages in direct iMessage chats. These requests return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) before anything is sent: * Styles on a chat's first message. * Styles in group chats, or in direct chats that aren't iMessage. * Styles on a message that also has a link, media or contact card part. * Any decoration with `animation` instead of `style` (see [Limitations](#limitations)). To know whether a direct chat is iMessage before you send, read `service` on `GET /v1/chats/{chat_id}`, or send with `preferred_service: "iMessage"`. See [iMessage, RCS or SMS](/messaging/choosing-a-service). The checks above use the chat's confirmed `service`. If a styled message in an iMessage chat still ends up going out as SMS or RCS, the text is sent without styling and `degradations` includes `text_decorations_not_delivered`. Range problems, such as a range past the end of the text or a decoration with both `style` and `animation`, return `400` [`VALIDATION_ERROR`](/errors/validation-error). Text styles are outbound only. When a contact sends you bold or italic text, the message's text part carries the plain text with no `text_decorations`. > [!WARNING] > > **Common mistakes** > > * Effects and styles are for follow-ups. On a first message they return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). > * An effect or style on a message with a link, media or contact card returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). Send the text with the effect, then the file. > * A `202` doesn't mean the effect played. Check `degradations` on the message or the webhook. > * `range` is in UTF-16 code units, not characters. Emoji usually count as two. ## Limitations * **Text animations aren't supported today.** The API reference lists `animation` names (`big`, `small`, `shake`, `nod`, `explode`, `ripple`, `bloom`, `jitter`) in the `text_decorations` schema, but any decoration that uses `animation` returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) and nothing is sent. Use `style` only. * Effects and styles are delivered over iMessage only. Over SMS or RCS the text goes out and `degradations` records what was dropped. * Text styles work in direct chats only (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) in groups). Effects in groups need an established iMessage group. See [Group chats](/messaging/group-chats). * Neither works on a first message, on a message with a link, media or contact card, or before the contact has replied (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled), `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) for the file). See [Sending rules](/messaging/sending-rules). * Inbound effects and text styles aren't reported. A contact's slam or bold text arrives as plain text with no `effect` and no `text_decorations`. * Up to 100 decorations per text part (`400` [`VALIDATION_ERROR`](/errors/validation-error)). ## Next steps > **[iMessage, RCS or SMS](/messaging/choosing-a-service)** > > Control when a direct message goes out as iMessage, RCS or SMS. > **[Group chats](/messaging/group-chats)** > > Learn what makes a group an established iMessage group. > **[Threaded replies](/messaging/replies)** > > Reply to a specific message in its iMessage thread. > **[Track a message](/messaging/lifecycle)** > > Follow a message from `accepted` to `delivered` or `failed`. # Group chats Source: https://docs.blujay.io/messaging/group-chats Start group chats, send follow-ups, and manage a group's participants, name and membership. A group chat is an ordinary chat with `type: "group"`. You start one by sending a first message to two or more recipients, then send follow-ups, read its participants, add or remove people, rename it and take one of your numbers out of it with the endpoints on this page. Group chats need a Dedicated phone number. On the Free Plan, every group operation returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). An AI Agent number can't start a group or send to one, including replies in a group a contact added it to (`403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)); it still receives that group's messages. See [Plans](/plans). ## Start a group chat Send `POST /v1/chats` with `from` and two or more phone numbers in `to`. `from` is required for a group. Blujay creates the group on that phone number and queues the first message. ```bash curl theme={null} curl https://api.blujay.io/v1/chats \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: kickoff-group-001" \ -d '{ "from": "+15555550100", "to": ["+15555550123", "+15555550124"], "message": { "parts": [ { "type": "text", "text": "Hi both, starting a thread so we can plan Friday together." } ] } }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_grp789", "message": { "id": "msg_abc123", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123", "+15555550124"], "created_at": "2026-10-14T17:00:00.000Z", "parts": [ { "type": "text", "text": "Hi both, starting a thread so we can plan Friday together." } ] } }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:00:00.120Z" } ``` Keep these rules in mind for a group's first message: * `to` takes 2 to 31 US phone numbers. Email addresses aren't accepted. * Group chats don't accept `preferred_service` (`400` [`VALIDATION_ERROR`](/errors/validation-error)). Blujay picks the service for the whole group: iMessage when it can confirm every recipient on iMessage, otherwise SMS/MMS. The chat's `service` reports which one (`iMessage` or `SMS`), or null until Blujay knows. * If your number already has a group with exactly these recipients, Blujay sends into that group instead of creating a new one. * The same first-message rules apply as for direct chats: no links, media or contact cards, 300 characters, no effects and no `reply_to`. They apply to every `POST /v1/chats` request, even into an existing group. See [Sending rules](/messaging/sending-rules). Send an `Idempotency-Key` so you can retry safely; see [Idempotency](/idempotency). ## Send follow-ups Send the rest of the conversation to `POST /v1/chats/{chat_id}/messages`, exactly as you would in a direct chat. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_grp789/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: kickoff-group-002" \ -d '{ "message": { "parts": [{ "type": "text", "text": "Agenda is attached in the next message." }] } }' ``` Text, link, media and contact card parts all work in groups once someone in the group has replied; until then the pre-reply rules apply to the group as a whole, and anyone's reply counts. A follow-up is rejected with `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) if anyone currently in the group has opted out. See [Opt-outs](/opt-outs). ## Know which features need an established iMessage group Two terms matter on this page: * An **active** group is one Blujay still treats as current. Blujay checks this before every group operation. There is no `status` field on the chat today; for a group, `phone_numbers[].can_send` and `sendability_reasons` on `GET /v1/chats/{chat_id}` are the closest check. * An **established iMessage group** is an active group that iMessage has created on your phone number and linked to this chat. A group you start with `POST /v1/chats` becomes established once its first message has gone out over iMessage and iMessage reports the group back to Blujay. A group carried by SMS/MMS never becomes one. While the link is missing, `sendability_reasons` contains `group_not_ready`. | Feature | Works in | Otherwise | | - | - | - | | Text, link, media and contact card follow-ups | Any active group | | | `GET /v1/chats/{chat_id}/participants` | Any group | | | Threaded replies (`reply_to`) | Established iMessage groups | `400` [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) | | Screen and bubble effects (`effect`) | Established iMessage groups | `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | | Text styles (`text_decorations`) | Direct chats only | `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | | Typing indicators | Established iMessage groups | Accepted with `204`, nothing shown | | Add or remove participants, rename, leave | Established iMessage groups | `400` [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) | ## Read a group and its participants `GET /v1/chats/{chat_id}` returns the group. Your own Blujay numbers in the group are listed in `phone_numbers`, and everyone else is in `participants`. Each entry in `phone_numbers` has `can_send`, which tells you whether that number can send to the group right now. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_grp789 \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "chat_id": "chat_grp789", "type": "group", "display_name": "Friday planning", "participants": [ { "type": "phone", "handle": "+15555550123", "address": "+15555550123", "status": "active", "joined_at": "2026-10-14T17:00:00.000Z", "left_at": null, "contact_id": "contact_abc123", "display_name": "Ava Chen" }, { "type": "phone", "handle": "+15555550124", "address": "+15555550124", "status": "active", "joined_at": "2026-10-14T17:00:00.000Z", "left_at": null, "contact_id": "contact_def456", "display_name": "Jordan Lee" } ], "supports_typing_indicators": true, "phone_numbers": [ { "phone_number_id": "phone_abc123", "display_number": "+15555550100", "label": "Support", "status": "active", "is_default": true, "can_send": true, "sendability_reasons": [] } ], "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:05:00.000Z" }, "trace_id": "trc_1b7e9c3d5a2f", "timestamp": "2026-10-14T17:05:01.000Z" } ``` When `can_send` is `false`, `sendability_reasons` says why: `number_inactive`, `number_setup_incomplete` or `group_not_ready`. The chat lists only people who are still in the group. To include people who left, call `GET /v1/chats/{chat_id}/participants`. It returns every participant with `status` set to `active` or `left`, plus `joined_at` and `left_at`. `contact_id` appears only when the participant matches a contact in your workspace. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_grp789/participants \ -H "Authorization: Bearer blu_your_api_key" ``` ## Add or remove a participant Add a phone number or email address with `POST /v1/chats/{chat_id}/participants`, and remove one with `DELETE` on the same path. One of your Blujay numbers in the group makes the change, and everyone in the group sees it. Both endpoints honor `Idempotency-Key`. The `202` means the phone accepted the change; a moment later Blujay records it the same way as a change made on the phone, with a `participant.added` or `participant.removed` webhook and an entry in the chat. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_grp789/participants \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: add-participant-001" \ -d '{ "handle": "+15555550125" }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_grp789", "operation": "participant_add", "status": "accepted", "participant": { "handle": "+15555550125", "type": "phone", "status": "active" }, "from_phone_number_id": "phone_abc123" }, "trace_id": "trc_4d2a8e6f0c1b", "timestamp": "2026-10-14T17:10:00.000Z" } ``` To remove someone, send the same body with `DELETE`: ```bash curl theme={null} curl -X DELETE https://api.blujay.io/v1/chats/chat_grp789/participants \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: remove-participant-001" \ -d '{ "handle": "+15555550125" }' ``` What to expect: * Adding someone who is already in the group returns `200` with `status: "already_active"`. Removing someone who already left returns `200` with `status: "already_removed"`. Neither contacts the phone. * Removing someone who was never in the group returns `404` [`NOT_FOUND`](/errors/not-found) with the message "This handle is not a participant of the group". Read `GET /v1/chats/{chat_id}/participants` to see who is in it. * If more than one of your numbers is in the group, set `from_phone_number_id` to choose which one makes the change. Without it you get `400` [`PHONE_NUMBER_REQUIRED`](/errors/phone-number-required). * A removal that would leave the group with fewer than three members returns `409` [`GROUP_MINIMUM_PARTICIPANTS`](/errors/group-minimum-participants), the same code as leaving in that situation. * Your own Blujay numbers can't be added or removed here (`400` [`VALIDATION_ERROR`](/errors/validation-error)). To take one of your numbers out of a group, [leave the group](#leave-a-group). * If your number isn't ready to manage the group right now, you get `503` [`PHONE_NUMBER_UNAVAILABLE`](/errors/phone-number-unavailable). Retry later with the same `Idempotency-Key`. ### Confirming the change `202` means Blujay accepted the request and handed it to your phone number. The change is confirmed when the `participant.added` or `participant.removed` webhook arrives for that `chat_id` and handle, or when `GET /v1/chats/{chat_id}/participants` shows the new state. If iMessage refuses the change, no failure event is sent today: the participant list stays as it was, and you learn that only by reading it back. Give the change a minute, then read the participants before you retry. ## Rename a group Set a new group name with `PATCH /v1/chats/{chat_id}` and `display_name` (1 to 100 characters). Everyone in the group sees the new name. You can't clear a group name. Rename ignores `Idempotency-Key`; repeating a rename with the same name is harmless. The `202` means the phone accepted the rename; a moment later Blujay records it the same way as a rename made on the phone, with a `chat.group_name_updated` webhook and a "named the conversation" entry in the chat. ```bash curl theme={null} curl -X PATCH https://api.blujay.io/v1/chats/chat_grp789 \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "display_name": "Friday planning" }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_grp789", "operation": "group_name_update", "status": "accepted", "display_name": "Friday planning" }, "trace_id": "trc_9c0d2e4f6a8b", "timestamp": "2026-10-14T17:12:00.000Z" } ``` ### Confirming the change The rename is confirmed by the `chat.group_name_updated` webhook, or by `display_name` on `GET /v1/chats/{chat_id}`. If iMessage refuses the rename, no failure event is sent today; `display_name` keeps the old value. A group icon can't be set through the API: `group_chat_icon` on this endpoint returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). ## Leave a group `POST /v1/chats/{chat_id}/leave` takes one of your Blujay numbers out of an established iMessage group. A group must always keep at least one of your numbers, so you can only leave a group that has two or more of your numbers in it. `from_phone_number_id` names the number that should leave. It's optional when only one of your numbers is in the group (then the body can be `{}`), and required otherwise. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_grp789/leave \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "from_phone_number_id": "phone_def456" }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_grp789", "operation": "chat_leave", "status": "accepted", "from_phone_number_id": "phone_def456" }, "trace_id": "trc_2e4f6a8b0c1d", "timestamp": "2026-10-14T17:15:00.000Z" } ``` | Error | Status | What to do | | - | - | - | | [`PHONE_NUMBER_REQUIRED`](/errors/phone-number-required) | 400 | More than one of your numbers is in the group. Set `from_phone_number_id`. | | [`LAST_PHONE_NUMBER_IN_GROUP`](/errors/last-phone-number-in-group) | 409 | This is your only number in the group, so it can't leave. | | [`GROUP_MINIMUM_PARTICIPANTS`](/errors/group-minimum-participants) | 409 | Leaving, or removing a participant, would leave the group with fewer than three members. | | [`PHONE_NUMBER_HAS_SCHEDULED_MESSAGES`](/errors/phone-number-has-scheduled-messages) | 409 | The number has scheduled texts for this group. Wait for them to send or remove them in the app, then try again. | | [`PHONE_NUMBER_HAS_MESSAGES_SENDING`](/errors/phone-number-has-messages-sending) | 409 | The number still has messages sending in this group. Retry once they finish. | | [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) | 400 | The chat isn't an established iMessage group. | ### Confirming the change Leaving ignores `Idempotency-Key` and has no webhook event. Confirm it by reading the chat: the number disappears from `phone_numbers` on `GET /v1/chats/{chat_id}`. If a leave request times out, read `phone_numbers` before you retry. If iMessage refuses the leave, no failure event is sent; the number stays in `phone_numbers`. ## Receive group messages and changes When someone replies in a group, the `message.received` webhook identifies them with a `sender` object instead of the top-level `contact_id` that direct replies carry. `sender` has `type` (`phone` or `email`) and `address`, plus `contact_id` when the sender matches a contact in your workspace. Reading the same message with `GET /v1/messages/{message_id}` returns the same `sender` object. ```json message.received theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "message.received", "event_id": "evt_abc123", "created_at": "2026-10-14T17:20:00.100Z", "trace_id": "trc_5a7b9c1d3e5f", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "message_id": "msg_def456", "chat_id": "chat_grp789", "phone_number_id": "phone_abc123", "sender": { "type": "phone", "address": "+15555550123", "contact_id": "contact_abc123" }, "text": "Friday at 10 works for me.", "status": "received", "direction": "inbound", "requested_service": null, "service": "imessage", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [{ "type": "text", "text": "Friday at 10 works for me." }], "reply_to": null } } ``` To follow membership and name changes, including ones made outside the API, subscribe to `participant.added`, `participant.removed` and `chat.group_name_updated`. Deliveries can arrive out of order, so treat `GET /v1/chats/{chat_id}` and `GET /v1/chats/{chat_id}/participants` as the current state. See [Webhook events](/webhooks/events) and [Receive messages](/messaging/receive-messages). > [!WARNING] > > **Common mistakes** > > * `from` is required for a group and is a string. Leaving it out returns `400` [`VALIDATION_ERROR`](/errors/validation-error). > * A second `POST /v1/chats` into an existing group is still a first message: no links or media, even after replies. Use `POST /v1/chats/{chat_id}/messages`. > * `202` on add, remove, rename or leave means accepted, not done. Confirm with the webhook or a read. > * A group chat has no `preferred_service`; its `service` reports what the group runs on. Don't wait for `delivered` on group messages; see [Track a message](/messaging/lifecycle). > * Retrying a failed group message returns `409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed). ## Limitations * Dedicated numbers only. Free Plan: `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). AI Agent: `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) on create and send. See [Plans](/plans). * 2 to 31 recipients on create; phone numbers only (`400` [`VALIDATION_ERROR`](/errors/validation-error)). * No `preferred_service`; Blujay picks for the whole group and the chat doesn't report the service. * Pre-reply rules apply to the group until anyone in it replies. See [Sending rules](/messaging/sending-rules). * Participant changes, rename, leave, threaded replies, effects and typing need an established iMessage group (`400` [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) or `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)). Text styles never work in groups. * Group icon isn't supported (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)). * A group must keep at least three members (`409` [`GROUP_MINIMUM_PARTICIPANTS`](/errors/group-minimum-participants) on remove and on leave) and at least one of your numbers (`409` [`LAST_PHONE_NUMBER_IN_GROUP`](/errors/last-phone-number-in-group)). * Your own numbers can't be added through the participants endpoint. There's no API to add a second Blujay number to a group. * A failed add, remove, rename or leave has no failure event today. * Group messages can't be retried (`409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed)), and `delivered`/`read` may never be reported for them. ## Next steps > **[Threaded replies](/messaging/replies)** > > Reply to a specific message in a direct chat or an established iMessage group. > **[Effects and text styles](/messaging/effects)** > > Add screen and bubble effects to group follow-ups. > **[Webhook events](/webhooks/events)** > > See every event, including group participant and name changes. > **[Idempotency](/idempotency)** > > Retry group sends and participant changes without doing them twice. # Track a message Source: https://docs.blujay.io/messaging/lifecycle Understand message statuses, which service carried a message, why it failed, and how to follow it with webhooks or polling. After Blujay accepts a message, it moves through a series of statuses until it's read, stops at the last status the service reports, or fails. This page explains each status, what each service can report, how to read failures, and how to follow a message with webhooks or polling. ## Statuses ```mermaid theme={null} flowchart LR A[accepted] --> B[sending] --> C[sent] --> D[delivered] --> E[read] A --> F[failed] B --> F F -. rare .-> C G[received] ``` | Status | Meaning | Webhook event | | - | - | - | | `accepted` | Blujay accepted and queued the message. The `202` send response shows this as `delivery_status: queued`. | `message.accepted` | | `sending` | Your Blujay phone number is sending the message, and Blujay is waiting for confirmation. | None | | `sent` | The message left your number. | `message.sent` | | `delivered` | The recipient's device confirmed delivery. | `message.delivered` | | `read` | The recipient read the message. | `message.read` | | `failed` | The message couldn't be sent. See [Failures](#failures). | `message.failed` | | `received` | An inbound message from a contact. It never changes status. See [Receive messages](/messaging/receive-messages). | `message.received` | A message doesn't always pass through every status. It can go straight from `accepted` to `delivered`, and a message can stay at `sent` for good. Always act on the latest status rather than waiting for a particular sequence. `failed` is normally final. Rarely, Blujay confirms afterwards that a failed message did go out, and it moves on to `sent`, `delivered` or `read`. Treat the newest status as the truth. `delivered` and `read` are reported for direct chats. A group chat may not report them: a group message can stay at `sent` even when everyone has it. Don't wait for `delivered` on a group message. ## What each service reports How far a message gets depends on the service that carried it. | Service | Statuses you can expect after `accepted` | | - | - | | iMessage | `sent`, then `delivered`. `read` only when the recipient shares read receipts. | | RCS | `sent`, and `delivered` or `read` when they're reported. | | SMS | `sent`. | > [!WARNING] > > Never wait for an SMS message to reach `delivered` or `read`. For SMS, `sent` is usually the last status you get, and it's a success. ## Which service carried the message Every message has two service fields: | Field | Meaning | | - | - | | `preferred_service` | What you asked for in the send: `iMessage`, `RCS` or `SMS`, or `null` when you let Blujay choose (Auto). | | `service` | The service that actually carried the message. `null` until Blujay confirms it. | With Auto, both start as `null`. That's expected, not an error. `service` fills in as the message is sent. Webhook payloads carry the same information in lowercase (`imessage`, `rcs`, `sms`). In webhooks, the field for what you asked for is called `requested_service`. From webhook version `2026-08-20` on, it holds the same value as `preferred_service`. See [Webhook versions](/webhooks/versions). To choose a service yourself, see [Send messages](/messaging/send-messages#choose-a-service-or-use-auto) and [iMessage, RCS or SMS](/messaging/choosing-a-service). ## Failures A failed message carries `failed_at`, a `failure` object with a lowercase `code` and a readable `reason`, and `retryable`, on the message itself (`GET /v1/messages/{message_id}` and the list endpoints) and in the `message.failed` webhook alike. `retryable: true` means `POST /v1/messages/{message_id}/retry` will be accepted; `false` means it returns `409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed). For five reasons the message also carries a `failure_code` that names the exact problem: | `failure_code` | Meaning | What to do | | - | - | - | | `ATTACHMENT_FETCH_FAILED` | Blujay couldn't download a media URL. | Check the URL is public and serves the file, or upload the file instead. See [Media](/messaging/media). | | `ATTACHMENT_TOO_LARGE` | A file from a media URL is too large, or over the SMS/MMS size limit. | Send a smaller file, or upload it. | | `UNSUPPORTED_ATTACHMENT_TYPE` | A file isn't a supported type, or the service that carried the message can't send it. | Send a supported type, or upload it. | | `SENDER_RETIRED` | The sending number was retired. | Start a new chat from an active number. | | `REPLY_REQUIRES_IMESSAGE` | A threaded reply could only go out as SMS or RCS, so Blujay didn't send it. | Send it again without `reply_to`. See [Threaded replies](/messaging/replies). | Every other failure has no `failure_code`; `failure.code` still says what happened. To find failed messages, call `GET /v1/messages?status=failed`. In the `message.failed` webhook, versions `2026-08-01` and later carry the `failure` object, and `2026-09-02` and later also carry `failure_code` when there is one. | `failure.code` | Meaning | | - | - | | `recipient_unavailable` | The recipient couldn't be reached on the service you asked for, for example `preferred_service: iMessage` for a number that doesn't use iMessage. | | `service_mismatch` | The message wasn't sent with the service you asked for. | | `unsupported_content` | The message has content the selected service can't send, such as a PDF over SMS or media over the SMS/MMS size limit. `failure_code` is `UNSUPPORTED_ATTACHMENT_TYPE` or `ATTACHMENT_TOO_LARGE`. | | `reply_requires_imessage` | A threaded reply could only go out as SMS or RCS. | | `sender_unavailable` | The sending number was unavailable. | | `sender_retired` | Your phone number was replaced before the message went out. | | `dispatch_unavailable` | Blujay couldn't hand the message off for sending. | | `network_rejected` | The messaging network rejected the message. | | `delivery_failed` | Any other failure. | To send a failed message again, see [Retry a failed message](/messaging/send-messages#retry-a-failed-message). ## Degradations Some features only exist on iMessage. When a direct message asks for one of them and goes out as SMS or RCS, Blujay still sends the text and lists what it dropped in `degradations`: ```json theme={null} "degradations": [ { "code": "effect_not_delivered", "reason": "The iMessage effect was not delivered because the message was sent as SMS or RCS." } ] ``` | `code` | Dropped feature | | - | - | | `effect_not_delivered` | A screen or bubble [effect](/messaging/effects). | | `text_decorations_not_delivered` | Bold, italic, strikethrough or underline text styles. | `degradations` is an empty array when nothing was dropped. Message reads and message webhooks carry the same entries. ## Follow a message You can follow a message with webhooks, by polling, or both. ### With webhooks (recommended) Subscribe to the message events you need, and Blujay posts each status change to your server as it happens. ```json message.delivered theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "message.delivered", "event_id": "evt_abc123", "created_at": "2026-10-14T17:01:04.000Z", "trace_id": "trc_8f2a1c0e4b7d", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "status": "delivered", "direction": "outbound", "requested_service": null, "service": "imessage", "sent_at": "2026-10-14T17:01:02.000Z", "delivered_at": "2026-10-14T17:01:04.000Z", "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [ { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." } ], "reply_to": null } } ``` * Webhooks are delivered at least once and can arrive out of order. Deduplicate on `event_id`, and never replace a newer status with an older one. Use the `sent_at`, `delivered_at` and `read_at` timestamps to order them. * `message.accepted` is sent only for messages sent through the API. `message.sent`, `message.delivered`, `message.read` and `message.failed` also cover messages your team sends from Blujay. * There's no event for `sending`. * The message object and the webhook payload carry the same tracking fields: `direction`, `sent_at`, `delivered_at`, `read_at`, `failed_at`, `failure`, and `sender` on inbound group messages. Webhooks tell you when they change; reads give you the current values. Set up an endpoint in [Webhooks](/webhooks/overview), and see every payload in [Webhook events](/webhooks/events). ### By polling `GET /v1/messages/{message_id}` returns the current status: ```bash theme={null} curl https://api.blujay.io/v1/messages/msg_abc123 \ -H "Authorization: Bearer blu_your_api_key" ``` ```json theme={null} { "status": "success", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "SMS", "status": "sent", "parts": [ { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." } ], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "trace_id": "trc_8f2a1c0e4b7d", "created_at": "2026-10-14T17:01:00.000Z", "updated_at": "2026-10-14T17:01:03.000Z" }, "trace_id": "trc_51b9e07a3c6d", "timestamp": "2026-10-14T17:01:30.000Z" } ``` Poll with a growing delay (for example 2, 4, 8 seconds, up to 30 seconds between polls) and stop at a status that is final for your use: | `service` | Stop polling at | | - | - | | `SMS` | `sent` or `failed` | | `iMessage` | `delivered`, `read` or `failed`. If it stays at `sent` for 10 minutes, stop polling and rely on webhooks for any later change. | | `RCS` | `delivered`, `read` or `failed`, with the same 10-minute cap. | | `null` (not confirmed yet) | Keep polling. | Webhooks are better than polling for anything after that, including the rare `failed` that later becomes `sent`. To catch up on many messages at once, call `GET /v1/messages?updated_after=2026-10-14T17:00:00.000Z` with the time of your last sync. Reads count toward your read limit; see [Rate limits](/rate-limits). Polling is fine for testing and for reconciling. In production, use webhooks and fall back to `GET /v1/messages/{message_id}` when an event seems missing. > [!WARNING] > > **Common mistakes** > > * The `202` send response uses `id` and `delivery_status: queued`. Reads use `message_id` and `status: accepted`. They describe the same message. > * `updated_at` moves whenever anything changes, including an edit or a read receipt. Order a conversation by `created_at`. > * A `status: failed` with no `failure_code` is normal. The reason is in the `message.failed` webhook. ## Limitations * `failure_code` covers five reasons you can act on. Every failed message also carries a `failure` object with a lowercase `code` and a readable `reason`, the same one its `message.failed` webhook carries, and `retryable`, which says whether `POST /v1/messages/{message_id}/retry` would be accepted. * SMS never reports `delivered` or `read`. Group chats may not report them either. * `sending` has no webhook event. ## Next steps > **[Webhooks overview](/webhooks/overview)** > > Get status changes pushed to your server. > **[Webhook events](/webhooks/events)** > > Every message event and its payload. > **[Receive messages](/messaging/receive-messages)** > > Handle inbound texts, media and group replies. > **[Send messages](/messaging/send-messages)** > > Start chats, send follow-ups and retry failures. # Media Source: https://docs.blujay.io/messaging/media Send photos, videos and files by uploading them once or from a public URL, and download media you receive. A message can carry files as `media` parts. You can upload a file once and reuse it by its `attachment_id`, or point Blujay at a public HTTPS URL for a one-off send. Media you receive arrives as `media` parts too, and you download it through the API. > [!WARNING] > > Media is rejected until the contact has replied to your number. A `media` part in a first message or in any follow-up before the reply returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked), and nothing is sent. See [Sending rules](/messaging/sending-rules). ## Choose how to send a file | | Upload first | Send from a URL | | - | - | - | | Best for | Files you send more than once, or files that aren't images or videos | One-off images, videos and contact cards already online | | File types | Any supported file | Images, videos and vCard (`.vcf`) files | | Size | Up to 100 MiB | Up to 10 MiB | | Part | `{ "type": "media", "attachment_id": "att_abc123" }` | `{ "type": "media", "url": "https://example.com/photo.jpg" }` | Each `media` part sets exactly one of `attachment_id` or `url`. A message can have up to 40 `media` and `vcard` parts. ## Upload a file ### 1. Declare the file Tell Blujay the file's name, type and exact size in bytes: ```bash theme={null} curl https://api.blujay.io/v1/attachments \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "filename": "return-label.pdf", "content_type": "application/pdf", "size_bytes": 48213 }' ``` ```json 201 Created theme={null} { "status": "success", "data": { "attachment_id": "att_abc123", "upload_url": "https://uploads.example.com/att_abc123?signature=example", "upload_expires_at": "2026-10-14T17:15:00.000Z", "upload_headers": { "Content-Type": "application/pdf", "Content-Length": "48213" }, "download_url": "/v1/attachments/att_abc123/content" }, "trace_id": "trc_3e81f0c2d7a6", "timestamp": "2026-10-14T17:00:00.000Z" } ``` `content_type` is a lowercase MIME type without parameters, such as `image/jpeg`. `filename` is a plain file name with no folder path. `upload_url` is a signed URL on Blujay's storage, not on `api.blujay.io`. The host is returned in the response; allow it in your egress rules rather than hardcoding one. ### 2. PUT the raw bytes Send the file's raw bytes to `upload_url` with exactly the `upload_headers` from the response. Don't use `multipart/form-data`, and don't add your API key: the URL already authorizes this one upload. ```bash theme={null} curl -X PUT "UPLOAD_URL_FROM_THE_RESPONSE" \ -H "Content-Type: application/pdf" \ -H "Content-Length: 48213" \ --data-binary @return-label.pdf ``` The URL expires after 15 minutes. If it expires before you upload, declare the file again. ### 3. Send it Use the `attachment_id` in a `media` part. There's no separate "complete" call. ```bash theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: return-label-1042" \ -d '{ "message": { "parts": [ { "type": "text", "text": "Here is your return label." }, { "type": "media", "attachment_id": "att_abc123" } ] } }' ``` The first time you send or read the attachment, Blujay checks the uploaded bytes against what you declared: * If the upload hasn't finished, you get `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). Retry once the `PUT` completes. * If the bytes don't match the declared type, you get `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid). Declare the file again and upload the correct bytes. * If the bytes don't match the declared `size_bytes`, the storage service refuses the `PUT` itself with a `403` and an XML body (the signed URL is bound to the exact size). Nothing is stored and the attachment stays `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). Declare the file again with the right size and upload again. After that, reuse the same `attachment_id` in as many messages as you like. At declaration, a file over 100 MiB returns `413` [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large). Executables, scripts, HTML, SVG and XML files return `415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type). If your workspace has too many uploads waiting for their bytes, you get `429` [`ATTACHMENT_CAPACITY_EXCEEDED`](/errors/attachment-capacity-exceeded); finish or abandon some uploads and retry. ### Accepted file types Blujay identifies a file by its bytes, not only by `content_type`, and accepts these: | Kind | Types | | - | - | | Images and videos | Common image and video formats, including JPEG, PNG, HEIC and MP4. | | Documents | PDF, Word, Excel and PowerPoint (`.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`), EPUB. | | Audio | M4A/AAC, MP3, WAV, CAF, FLAC, Ogg, WebM. An audio file sends as an ordinary file, not as an iMessage voice memo. | | Text | vCard (`text/vcard`) and other plain `text/*` types, except HTML and XML. | A file whose bytes don't match the specific type you declared is rejected with `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid) when the upload is checked. A file Blujay can't identify is accepted as `application/octet-stream`; declare it as that. What a service can carry is a separate question; see [What each service can carry](#what-each-service-can-carry). ## Send from a URL For a one-off image, video or contact card, put a public HTTPS URL in the part: ```bash theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: product-photo-7731" \ -d '{ "message": { "parts": [ { "type": "text", "text": "This is the model we have in stock." }, { "type": "media", "url": "https://example.com/images/model-7731.jpg" } ] } }' ``` ```json 202 Accepted theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_ghi789", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:10:00.000Z", "parts": [ { "type": "text", "text": "This is the model we have in stock." }, { "type": "media", "attachment_id": "att_def456" } ] } }, "trace_id": "trc_9a4c17e0b2d8", "timestamp": "2026-10-14T17:10:00.000Z" } ``` Blujay checks the URL when you send, then downloads the file after it accepts the message. The response replaces the URL with a new `attachment_id`. * The URL must be public HTTPS, up to 2,048 characters. URLs with credentials or that point at private addresses are rejected with `422` [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed) before anything is sent. * The file can be up to 10 MiB and must be an image, a video or a vCard. Serve a vCard as `text/vcard` or `text/x-vcard`, or as a generic binary type with a `.vcf` file name. * Files disguised as another type are rejected. If the download fails after acceptance, the message fails with a `failure_code`: `ATTACHMENT_FETCH_FAILED`, `ATTACHMENT_TOO_LARGE` or `UNSUPPORTED_ATTACHMENT_TYPE`. See [Track a message](/messaging/lifecycle#failures). ## What each service can carry The service that carries a message decides which files it can send: | Service | Files | | - | - | | iMessage | Any file you can upload. | | RCS | Images, videos and vCards. | | SMS (MMS) | Images, videos and vCards, up to 5 MiB in total per message. | If any file in a message can't go out on the service that carries it, the whole message fails. Blujay never silently drops, converts or shrinks a file. If you might send to SMS recipients, keep files small and stick to images and videos, or [check the number first](/messaging/choosing-a-service). ## Read and download media Message parts show media with its details once the file is ready: ```json theme={null} { "type": "media", "attachment_id": "att_ghi789", "filename": "IMG_0412.jpg", "content_type": "image/jpeg", "size_bytes": 428731, "download_url": "/v1/attachments/att_ghi789/content" } ``` `download_url` is a path on the Blujay API. Download the file from `https://api.blujay.io` plus that path, with your usual `Authorization` header: ```bash theme={null} curl https://api.blujay.io/v1/attachments/att_ghi789/content \ -H "Authorization: Bearer blu_your_api_key" \ -o IMG_0412.jpg ``` The response body is the file itself, with its `Content-Type` and a `Content-Disposition` file name. To get an attachment's details without downloading it, call `GET /v1/attachments/{attachment_id}`: ```bash theme={null} curl https://api.blujay.io/v1/attachments/att_ghi789 \ -H "Authorization: Bearer blu_your_api_key" ``` ```json theme={null} { "status": "success", "data": { "attachment_id": "att_ghi789", "status": "ready", "filename": "IMG_0412.jpg", "content_type": "image/jpeg", "size_bytes": 428731, "download_url": "/v1/attachments/att_ghi789/content", "created_at": "2026-10-14T17:20:00.000Z", "ready_at": "2026-10-14T17:20:02.000Z" }, "trace_id": "trc_2f7d90a1c4e3", "timestamp": "2026-10-14T17:21:00.000Z" } ``` ### Media in received messages Media in an inbound message can show up before the file is ready. At first, the part may have only `type` and `attachment_id`. The `message.received` webhook is a snapshot and never changes, and there is no "attachment ready" event. To get the file: 1. Take `attachment_id` from the `message.received` event or the message read. 2. Call `GET /v1/attachments/{attachment_id}`. While the file is still arriving, it returns `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). 3. Wait a few seconds and call again, with a growing delay. Give up after a few minutes and log the `attachment_id`. 4. Once the response has `status: ready`, read `filename`, `content_type`, `size_bytes` and `download_url`, then download the file. Attachment reads count toward the attachment request limit; see [Rate limits](/rate-limits). > [!WARNING] > > Only send your API key to `https://api.blujay.io`. Build download links from the `download_url` path yourself rather than following a full URL from a payload. ## Delete an attachment `DELETE /v1/attachments/{attachment_id}` stops an uploaded attachment from being sent or downloaded again, effective immediately. Messages that already include it keep the reference in their history. Deleting an attachment you already deleted returns `204` again. An `attachment_id` that doesn't exist in your workspace returns `404` [`NOT_FOUND`](/errors/not-found). ```bash theme={null} curl -X DELETE https://api.blujay.io/v1/attachments/att_abc123 \ -H "Authorization: Bearer blu_your_api_key" ``` ## Limits Attachment endpoints have their own request limit, separate from other reads and writes. See [Rate limits](/rate-limits). If attachment storage is briefly unavailable, you get `503` [`ATTACHMENT_STORAGE_UNAVAILABLE`](/errors/attachment-storage-unavailable); retry shortly. > [!WARNING] > > **Common mistakes** > > * Sending media before the contact replies returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked). Send text first. > * `PUT` the raw bytes to `upload_url` with exactly the `upload_headers`. A `multipart/form-data` body or an extra `Authorization` header makes the upload fail its check. > * `size_bytes` must be the exact byte count. A mismatch returns `422` [`ATTACHMENT_UPLOAD_INVALID`](/errors/attachment-upload-invalid). > * A URL send returns `202` before the download. A bad URL shows up later as a `failed` message, not as a `4xx`. > * `download_url` is a path. Prefix it with `https://api.blujay.io` and send your API key only there. ## Limitations * No media until the contact replies (`400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)). * Uploads up to 100 MiB; URL sends up to 10 MiB and only images, videos and vCards (`413` [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large), `415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type)). * Executables, scripts, HTML, SVG and XML are never accepted (`415` [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type)). * RCS and SMS carry only images, videos and vCards; SMS/MMS up to 5 MiB per message. A message with a file the service can't carry fails as a whole. * Voice memos aren't supported. Audio goes out as a file. * There is no attachment list endpoint and no "attachment ready" event. Poll `GET /v1/attachments/{attachment_id}`. * Deleting an attachment doesn't remove it from messages that already sent it. ## Next steps > **[Contact cards](/messaging/contact-cards)** > > Send a saved vCard as a native contact card. > **[Send messages](/messaging/send-messages)** > > Message parts and follow-ups. > **[Track a message](/messaging/lifecycle)** > > Statuses and media failure codes. > **[Receive messages](/messaging/receive-messages)** > > Handle inbound media as it arrives. # Receive messages Source: https://docs.blujay.io/messaging/receive-messages How replies, photos, group messages, reactions and edits reach you, and what to do with each. Everything a contact sends to one of your phone numbers reaches you two ways: as a webhook event the moment it arrives, and on the message and chat endpoints whenever you read them. This page is the inbound half of the API in one place. Payload fields are listed in full on [Events](/webhooks/events). ## Set up 1. Create a [webhook subscription](/webhooks/overview) with `message.received`. Add `reaction.added`, `reaction.removed`, `message.edited`, `message.unsent` and `chat.typing_indicator.started` if you want those too. 2. [Verify the signature](/webhooks/verify-signatures) on every delivery, store the event by `event_id`, and answer `2xx` within 10 seconds. 3. Do your work from your own queue. If you reply from inside the webhook handler, use `ack-${event_id}` as the `Idempotency-Key` so a redelivered event never sends a second reply. See [Examples](/examples). Polling works too: `GET /v1/messages?status=received&updated_after=…` returns inbound messages across every chat, and `GET /v1/chats/{chat_id}/messages` returns one chat. An inbound message has `status: "received"`. Both lists are ordered by `updated_at`, most recently updated first, so a read receipt or an edit moves an older message up; that is what makes `updated_after` a reliable sync cursor. To show a conversation in order, sort the page by `created_at`. ## A text reply A direct reply arrives as `message.received` with `contact_id` set, the text in `text` and in `parts`, and the `chat_id` to reply into: ```json theme={null} { "message_id": "msg_def456", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "contact_id": "contact_abc123", "text": "Sounds good, see you then!", "status": "received", "direction": "inbound", "service": "imessage", "parts": [{ "type": "text", "text": "Sounds good, see you then!" }], "reply_to": null } ``` Reply with `POST /v1/chats/{chat_id}/messages`. A reply from the contact lifts the [pre-reply rules](/messaging/sending-rules) for that number, so your next message can carry links and files. If the person isn't a contact yet, Blujay creates one when their first message arrives, so `contact_id` is always present on a direct message. ## Photos, videos and files Media arrives as `media` parts. The file may still be processing when the event is sent, so a part can carry only `type` and `attachment_id` at first. The event is a snapshot and never changes: read `GET /v1/messages/{message_id}` or `GET /v1/attachments/{attachment_id}` again until `filename`, `content_type`, `size_bytes` and `download_url` are there. Until then the attachment endpoints return `409` [`ATTACHMENT_NOT_READY`](/errors/attachment-not-ready). There is no "attachment ready" event, so poll with a short backoff. ```json theme={null} { "parts": [ { "type": "text", "text": "Here is the photo." }, { "type": "media", "attachment_id": "att_abc123", "filename": "photo.jpg", "content_type": "image/jpeg", "size_bytes": 428731, "download_url": "/v1/attachments/att_abc123/content" } ] } ``` `download_url` is a path on `https://api.blujay.io`. Call it with your API key. Never send the key to a URL from any other host. See [Media](/messaging/media). Voice memos arrive as audio media, and a contact card someone shares arrives as a `text/vcard` media part. ## Messages in a group A message in a group chat has a `sender` object instead of `contact_id`: ```json theme={null} { "sender": { "type": "phone", "address": "+15555550124", "contact_id": "contact_def456" } } ``` `sender.contact_id` is present only when the sender is a contact in your workspace. `type` is `phone` or `email`. A reply from anyone in the group counts as a reply for the group's [sending rules](/messaging/sending-rules). To follow who is in the group, subscribe to `participant.added`, `participant.removed` and `chat.group_name_updated`, and read `GET /v1/chats/{chat_id}/participants` for the current state. See [Group chats](/messaging/group-chats). ## Reactions A tapback is not a message. It arrives only as `reaction.added` or `reaction.removed` with the `message_id` it applies to, the reaction type (`love`, `like`, `dislike`, `laugh`, `emphasize`, `question`) and `part_index`. It never arrives as `message.received`, and `GET /v1/messages` doesn't list it. Reactions can arrive about a minute after the person tapped. A reaction from the contact counts as a response for the [6-text rule](/messaging/sending-rules#texts-in-a-row-without-a-response). ## Replies to a specific message When a contact replies to one of your messages in a thread, `reply_to` on the inbound message names the message and part they replied to. Read the whole thread with `GET /v1/messages/{message_id}/thread`. See [Threaded replies](/messaging/replies). ## Edits and unsends When a contact edits or unsends an iMessage, you get `message.edited` with the new text or `message.unsent` with no text. Only messages up to a week old are tracked. When a message is unsent, delete its text on your side too: earlier events you stored still carry it. See [Edit and unsend](/messaging/edit-and-unsend). ## Typing `chat.typing_indicator.started` and `stopped` tell you a contact is typing in an iMessage chat, with an `expires_at` to clear the indicator by. See [Typing and read receipts](/messaging/typing-and-read-receipts). ## What you don't get * **Content the API has no part type for.** A message Blujay stores with no text and no file, which is how iMessage app content such as polls, Apple Pay and app cards usually arrives, has an empty `parts` list and no `text`. Stickers sent as images, voice memos and shared contact cards arrive as media. * **Effects and text styles on inbound messages** aren't reported. * **Delivery and read receipts for your messages** are their own events (`message.delivered`, `message.read`), not inbound messages. * **Calls** aren't part of the messaging API. ## Common mistakes > [!WARNING] > > * Don't overwrite newer state with an older event. Events can arrive out of order and more than once; dedupe by `event_id` and compare timestamps. > * Don't reply to every message forever. The reply bot in [Examples](/examples) acknowledges once per chat, then stays quiet. > * Don't treat a reaction as text. It has no `text` and no `parts`. > * Don't follow a full URL from a payload with your API key. Build the download URL from `download_url` on `https://api.blujay.io`. ## Next steps > **[Events](/webhooks/events)** > > Every event type with its full payload. > **[Examples](/examples)** > > A complete reply bot you can run. > **[Send messages](/messaging/send-messages)** > > Reply into the chat. > **[Sending rules](/messaging/sending-rules)** > > What a reply unlocks. # Threaded replies Source: https://docs.blujay.io/messaging/replies Reply to a specific iMessage in a thread, read reply details back, and page through a whole thread. A threaded reply answers one specific message, and the recipient sees it in that message's iMessage thread. You send one by adding `reply_to` to a follow-up message. Threaded replies are iMessage only: Blujay never sends a reply without its thread, so a reply that can't go out as iMessage is refused or fails instead of being sent as a plain message. ## Send a reply Add `reply_to` to `POST /v1/chats/{chat_id}/messages`. Set `message_id` to the message you're answering. ```bash curl theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-msg_def456-1" \ -d '{ "message": { "parts": [{ "type": "text", "text": "Yes, that time works." }], "reply_to": { "message_id": "msg_def456", "part_index": 0 } } }' ``` ```json Response (202) theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_ghi789", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:05:00.000Z", "parts": [{ "type": "text", "text": "Yes, that time works." }], "reply_to": { "message_id": "msg_def456", "part_index": 0, "thread_root_message_id": "msg_def456" } } }, "trace_id": "trc_3c5e7a9b1d2f", "timestamp": "2026-10-14T17:05:00.150Z" } ``` `part_index` is optional and defaults to `0`. It names which piece of the target message your reply hangs under, using Apple's numbering for that message. Blujay doesn't check it against the target's `parts`; it passes the number to iMessage as you sent it, and it reports Apple's number back on replies it receives (the same numbering as `part_index` in the `reaction.added` webhook). For a text-only message, `0` is the only part. For a message with media and a caption, Apple's numbering may not match the order of `parts` in Blujay, so if a reply lands under the wrong piece, use the other index. Blujay doesn't publish Apple's numbering rule. `reply_to` works only on follow-ups. `POST /v1/chats` rejects it with `400` [`VALIDATION_ERROR`](/errors/validation-error), because a chat's first message can't be a reply. ## Choose a valid target The target must be a sent iMessage in the same chat, on the same Blujay phone number. Blujay checks the target before it accepts the message, so nothing is sent when one of these errors comes back: | Error | Status | Cause | | - | - | - | | [`REPLY_TARGET_NOT_FOUND`](/errors/reply-target-not-found) | 404 | The message doesn't exist, or it belongs to another workspace or phone number. Blujay looks the target up only among the chat's own phone number's messages, so a message on another number looks the same as a missing one. Check `phone_number_id` on the target. | | [`REPLY_TARGET_NOT_IN_CHAT`](/errors/reply-target-not-in-chat) | 400 | The message is in a different chat. | | [`REPLY_TARGET_NOT_REPLYABLE`](/errors/reply-target-not-replyable) | 400 | The message is a reaction, was unsent, or was sent or received as SMS or RCS. | | [`REPLY_TARGET_NOT_SENT`](/errors/reply-target-not-sent) | 409 | The message hasn't been sent yet. Retry once it has. | ## Know when a reply can go out * **Direct chats.** Leave `preferred_service` out or set it to `iMessage`. Setting it to `SMS` or `RCS` returns [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation). * **Group chats.** The chat must be an established iMessage group. Other groups return [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation). See [Group chats](/messaging/group-chats#know-which-features-need-an-established-imessage-group). When Blujay chooses the service (Auto) and a direct reply can only go out as SMS or RCS, the message isn't sent. It moves to `failed` with `failure_code: "REPLY_REQUIRES_IMESSAGE"`. On webhook version `2026-09-02` and later, the `message.failed` event carries the same `failure_code`. To reach the person anyway, send the text again without `reply_to`. If you retry a reply with the same `Idempotency-Key`, keep the body identical. Reusing the key with a different `reply_to` returns `409` [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch). A reply Blujay rejected with a `4xx` isn't remembered under its key, so you can fix it and reuse the key. See [Idempotency](/idempotency). ## Read reply details Every message you read, from `GET /v1/messages/{message_id}`, `GET /v1/messages` or `GET /v1/chats/{chat_id}/messages`, has a `reply_to` field. It is `null` for a message that isn't a reply. For a reply, it holds three public IDs: | Field | Meaning | | - | - | | `message_id` | The message being replied to. | | `part_index` | Which part of that message the reply targets (0-based). | | `thread_root_message_id` | The message that started the thread. Use it to group a thread without an extra call. | For a reply you sent through Blujay, `message_id` is the message you named. For a reply that arrived over iMessage, Apple reports only the start of the thread, so `message_id` equals `thread_root_message_id`. Either ID is `null` when Blujay doesn't have that message, for example when it predates your history in Blujay. ## Read a whole thread `GET /v1/messages/{message_id}/thread` returns the message that started a thread and every reply in it, including replies that are still sending. Pass any message in the thread. ```bash curl theme={null} curl "https://api.blujay.io/v1/messages/msg_ghi789/thread?order=asc&limit=25" \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "data": [ { "message_id": "msg_def456", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "received", "parts": [{ "type": "text", "text": "Does 3pm on Friday work?" }], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "created_at": "2026-10-14T17:04:12.000Z", "updated_at": "2026-10-14T17:04:12.000Z" }, { "message_id": "msg_ghi789", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "delivered", "parts": [{ "type": "text", "text": "Yes, that time works." }], "degradations": [], "reply_to": { "message_id": "msg_def456", "part_index": 0, "thread_root_message_id": "msg_def456" }, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "trace_id": "trc_3c5e7a9b1d2f", "created_at": "2026-10-14T17:05:00.000Z", "updated_at": "2026-10-14T17:05:02.000Z" } ], "has_more": false, "limit": 25 }, "trace_id": "trc_6e8a0c2d4f1b", "timestamp": "2026-10-14T17:06:00.000Z" } ``` * `order` is `asc` (oldest first, the default) or `desc`. * `limit` defaults to 25 and goes up to 100. When `has_more` is `true`, pass `next_cursor` as `cursor` with the same `order`. * One thread read covers at most 1,000 messages of the thread, counted from its start with `order=asc` or from its end with `order=desc`. Real iMessage threads are far smaller, so you won't normally hit this. * Reactions are never part of a thread. A message that isn't in a thread, or a reaction, returns only itself. * Add `include=edit_history` to get earlier versions of edited messages. See [Edit and unsend](/messaging/edit-and-unsend). ## Get reply details in webhooks Subscriptions on webhook version `2026-10-03`, the default for new subscriptions, include `reply_to` on every message event, in the same shape as message reads. Earlier versions never include it. See [Webhook versions](/webhooks/versions). A `message.received` body is a snapshot taken when the message arrived. If iMessage reports the thread details a moment later, the stored event doesn't change, so read the message with `GET /v1/messages/{message_id}` when you need the latest `reply_to`. > [!WARNING] > > **Common mistakes** > > * A reply is a follow-up. `reply_to` on `POST /v1/chats` returns `400` [`VALIDATION_ERROR`](/errors/validation-error). > * `404` [`REPLY_TARGET_NOT_FOUND`](/errors/reply-target-not-found) for an ID you know exists usually means the target was sent or received on a different phone number than the chat. > * The pre-reply rules still apply: a reply with a link or media before the contact has replied returns `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked). See [Sending rules](/messaging/sending-rules). > * A reply that can't go out over iMessage fails. It's never sent as a plain SMS. ## Limitations * iMessage only. `preferred_service: "SMS"` or `"RCS"` returns `400` [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation); an Auto reply that can only go SMS or RCS fails with `REPLY_REQUIRES_IMESSAGE`. * The target must be an iMessage in the same chat on the same phone number, sent, not a reaction and not unsent (`404` [`REPLY_TARGET_NOT_FOUND`](/errors/reply-target-not-found), `400` [`REPLY_TARGET_NOT_IN_CHAT`](/errors/reply-target-not-in-chat), `400` [`REPLY_TARGET_NOT_REPLYABLE`](/errors/reply-target-not-replyable), `409` [`REPLY_TARGET_NOT_SENT`](/errors/reply-target-not-sent)). * Groups need an established iMessage group (`400` [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation)). See [Group chats](/messaging/group-chats). * Not on a first message (`400` [`VALIDATION_ERROR`](/errors/validation-error)), and subject to the pre-reply rules. * `part_index` isn't validated. A wrong index threads under the wrong piece of the message. * For a reply a contact sends, `message_id` is the thread root, not the exact message they replied to. * One thread read returns at most 1,000 messages. ## Next steps > **[Send messages](/messaging/send-messages)** > > Send follow-ups and track their progress. > **[Track a message](/messaging/lifecycle)** > > Understand `failed` and `failure_code`, including `REPLY_REQUIRES_IMESSAGE`. > **[Group chats](/messaging/group-chats)** > > See which group features need an established iMessage group. > **[Webhook versions](/webhooks/versions)** > > Move to `2026-10-03` to get `reply_to` in message events. # Send messages Source: https://docs.blujay.io/messaging/send-messages Start a chat with a first message, send follow-ups, and read back chats and messages. Every conversation in Blujay is a chat between one of your Blujay phone numbers and one or more recipients. You start a chat and send its first message with `POST /v1/chats`, then send everything after that with `POST /v1/chats/{chat_id}/messages`. Both calls return `202 Accepted` as soon as Blujay has queued the message, and the message is sent in the background. ## Send the first message `POST /v1/chats` starts a chat from one of your numbers and sends its first message. One number in `to` starts a direct chat. Two or more start a group chat (see [Group chats](/messaging/group-chats)). ```bash curl theme={null} curl https://api.blujay.io/v1/chats \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-welcome" \ -d '{ "from": "+15555550100", "to": ["+15555550123"], "message": { "parts": [ { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." } ] } }' ``` ```javascript Node.js theme={null} const response = await fetch("https://api.blujay.io/v1/chats", { method: "POST", headers: { Authorization: "Bearer blu_your_api_key", "Content-Type": "application/json", "Idempotency-Key": "order-1042-welcome", }, body: JSON.stringify({ from: "+15555550100", to: ["+15555550123"], message: { parts: [ { type: "text", text: "Hi Ava, this is Sam from the Northwind support team. Reply here any time." }, ], }, }), }); const body = await response.json(); if (body.status === "error") { throw new Error(`${body.error.code}: ${body.error.message} (trace ${body.trace_id})`); } console.log(response.status, body.data.chat_id, body.data.message.id); ``` ```python Python theme={null} import requests response = requests.post( "https://api.blujay.io/v1/chats", headers={ "Authorization": "Bearer blu_your_api_key", "Idempotency-Key": "order-1042-welcome", }, json={ "from": "+15555550100", "to": ["+15555550123"], "message": { "parts": [ {"type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time."} ] }, }, timeout=30, ) body = response.json() if body["status"] == "error": raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (trace {body['trace_id']})") print(response.status_code, body["data"]["chat_id"], body["data"]["message"]["id"]) ``` ```json 202 Accepted theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_abc123", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:01:00.000Z", "parts": [ { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." } ] } }, "trace_id": "trc_8f2a1c0e4b7d", "timestamp": "2026-10-14T17:01:00.000Z" } ``` Save `data.chat_id` for follow-ups and `data.message.id` to track the message. * `from` is one of your Blujay phone numbers, as a string in E.164 (`"+15555550100"`) or a common US format. List your numbers with [`GET /v1/phone-numbers`](/phone-numbers) and pick one whose `send_status.allowed` is `true`. * For a direct chat, `from` is optional. When you leave it out, Blujay uses the number the recipient already knows you on. If there is none, it uses your only number, or the default number of the teammate the contact is assigned to. If none of those applies, the request returns `422` [`FROM_NUMBER_REQUIRED`](/errors/from-number-required). A group chat always needs `from`. * `to` takes US phone numbers, in E.164 (`+15555550123`) or a common US format such as `(555) 555-0123`. Email addresses aren't accepted as recipients. * If you already have a direct chat with that recipient from the same number, the message goes into that chat and you get its `chat_id` back. ### Rules for a first message Until the contact replies to your number, Blujay rejects a send with `400` when it contains: * a link, in a `link` part or in the text ([`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) on `POST /v1/chats`, [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) on follow-ups), * a `media` or `vcard` part ([`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)), * text over 300 characters ([`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded)). At most 6 texts go out in a row without a response ([`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached)). A first message also can't be a threaded reply or carry an effect or text styles: `reply_to` returns `400` [`VALIDATION_ERROR`](/errors/validation-error), and `effect` or `text_decorations` return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). The full rules, what counts as a response, and the group variants are in [Sending rules](/messaging/sending-rules). > [!TIP] > > Blujay treats anything that looks like a domain name as a link, including `example.com` without `https://` and the domain in an email address such as `help@example.com`. Keep those out of a first message too. These rules apply to every `POST /v1/chats` request, even when the recipient already has a chat with your number. Use `POST /v1/chats/{chat_id}/messages` for everything after the first message. ## What `202 Accepted` means `202` means Blujay validated the request, saved the message and queued it for sending. It doesn't mean the message was sent or delivered. | Field | At acceptance | | - | - | | `delivery_status` | Always `queued`. Read the live status from `GET /v1/messages/{message_id}`, where a queued message shows as `accepted`. | | `preferred_service` | The service you asked for, or `null` when you let Blujay choose. | | `service` | `null`. It's set once Blujay confirms whether the message went out as iMessage, RCS or SMS. | | `parts` | The parts as Blujay will send them. A media URL or a `vcard` part comes back as a `media` part with a new `attachment_id`. | > [!NOTE] > > **Send vs read field names.** The `202` body names the message `id` and its state `delivery_status`. Every read (`GET /v1/messages/{message_id}`, the list endpoints) and every webhook names them `message_id` and `status`. Reads also drop `from`, `to` and `direction`. Map `data.message.id` to `message_id` when you store it. Follow the message to `sent`, `delivered`, `read` or `failed` with [webhooks](/webhooks/overview) or by polling. See [Track a message](/messaging/lifecycle). If the request times out or you get a `5xx`, send it again with the **same** `Idempotency-Key`. If Blujay already accepted it, you get `200` with the original response instead of a second message. A request Blujay rejected with a `4xx` isn't remembered under its key, so you can fix the request and send it again with the same key. See [Idempotency](/idempotency). ## Send a follow-up Send every message after the first with `POST /v1/chats/{chat_id}/messages`. The body is a `message` object with the same shape as in `POST /v1/chats`. ```bash theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-link" \ -d '{ "message": { "parts": [{ "type": "link", "value": "https://example.com/orders/1042" }] } }' ``` The response is the same `202` shape as the first send. Once the contact has replied, follow-ups can use every part type, including links, plus [threaded replies](/messaging/replies) and [effects and text styles](/messaging/effects). Until then the [first-message rules](#rules-for-a-first-message) still apply. ## Message parts A message is a list of `parts`, sent in order. | Part | Fields | Notes | | - | - | - | | `text` | `text` | Up to 10,000 characters per part, and 300 characters of text in the whole message until the contact replies. Must contain more than whitespace. Add `text_decorations` for [text styles](/messaging/effects#style-text). | | `link` | `value` | An `https://` URL up to 2,048 characters. Over iMessage the recipient sees a link preview; over SMS and RCS, how the link looks depends on the recipient's phone. It must be the only part in the message, and it's rejected until the contact replies. | | `media` | `attachment_id` or `url` | A file you uploaded, or a public HTTPS URL. Rejected until the contact replies. See [Media](/messaging/media). | | `vcard` | `vcard_id` | A saved contact card. Rejected until the contact replies. See [Contact cards](/messaging/contact-cards). | A message has 1 to 100 parts, at most 40 of them `media` or `vcard`, and at most 40,000 characters of text in total. Text and media parts can be mixed, for example a caption followed by a photo: ```json theme={null} { "message": { "parts": [ { "type": "text", "text": "Here's the replacement part we're sending." }, { "type": "media", "url": "https://example.com/images/part-7731.jpg" } ] } } ``` Other optional `message` fields: | Field | What it does | | - | - | | `preferred_service` | Pick the service for a direct chat. See below. | | `reply_to` | Send an iMessage threaded reply. Follow-ups only. See [Threaded replies](/messaging/replies). | | `effect` | Add an iMessage screen or bubble effect. Follow-ups only. See [Effects](/messaging/effects). | | `idempotency_key` | An alternative to the `Idempotency-Key` header. If you send both, they must match, or you get `409` [`IDEMPOTENCY_KEY_MISMATCH`](/errors/idempotency-key-mismatch). | | `client_reference` | Your own reference for the message, up to 255 characters. It comes back as `client_reference` whenever you read the message. Webhook payloads don't carry it; map `message_id` to your reference when you need it there. | One optional field sits next to `message` at the top level of the request: | Field | What it does | | - | - | | `pre_reply_override` | `true` lets one more text through after the 6-in-a-row limit (the 7th). Nothing else goes out until the contact responds. See [Sending rules](/messaging/sending-rules). | Unknown fields are rejected with `400` [`VALIDATION_ERROR`](/errors/validation-error). ## Choose a service or use Auto In a direct chat, leave out `preferred_service` to use **Auto**: Blujay chooses iMessage, RCS or SMS for the recipient. Auto is the right choice for most messages. Set `preferred_service` only when you need a specific outcome. Values are case-sensitive. | Value | What happens | | - | - | | *(omitted)* | Auto. Blujay picks the service, and `service` reports which one carried the message. | | `iMessage` | iMessage only. If Blujay can't confirm the recipient uses iMessage, the message fails instead of going out another way. The message has no `failure_code`; the `message.failed` webhook carries `failure.code: recipient_unavailable`. | | `RCS` or `SMS` | Keeps the message off iMessage. It goes out as RCS or SMS, whichever the recipient's number supports. Check `service` for the one that carried it. | Group chats don't accept `preferred_service` and return `400` [`VALIDATION_ERROR`](/errors/validation-error). To check a number before you send, see [iMessage, RCS or SMS](/messaging/choosing-a-service). ## Handle send errors Most send errors come back before anything is queued, so nothing was sent. None of the `400` and `403` errors below is retryable; fix the request or wait for the contact. | Error | What to do | | - | - | | `400` [`VALIDATION_ERROR`](/errors/validation-error) | A field is missing, malformed or not allowed here (for example `reply_to` on a first message, or `preferred_service` on a group). Read `error.details`. | | `400` [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) | The first message contains a link. Send it as a follow-up once the contact replies. | | `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) | The contact hasn't replied yet and the message has a link, media or a contact card. Send text only. | | `400` [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded) | The contact hasn't replied yet and the text is over 300 characters. Shorten it. | | `400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached) | You've sent 6 texts without a response. Wait for the contact, or send one more with `pre_reply_override: true`. | | `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | The feature isn't available here: an effect or text styles on a first message, a group chat on the Free Plan, or a feature switched off for your workspace. | | `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | On the Free Plan, the recipient hasn't texted your number and been verified in the app. See [Plans](/plans). | | `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | On the AI Agent plan, this number only replies to people who text it first, and it can't start or send to group chats. See [Plans](/plans). | | `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | The recipient opted out. Don't retry. See [Opt-outs](/opt-outs). | | `403` [`SENDER_SUSPENDED`](/errors/sender-suspended) | Sending is paused for this number. Send from another number or contact support. | | `422` [`FROM_NUMBER_REQUIRED`](/errors/from-number-required) | You left out `from` and Blujay couldn't pick a number for this recipient. Set `from`. | | `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) or [`QUEUE_FULL`](/errors/queue-full) | The number hit a sending limit or its queue is full. Wait `Retry-After` seconds. Each number's live limits are in `limits` on [`GET /v1/phone-numbers`](/phone-numbers). | | `503` [`QUEUE_UNAVAILABLE`](/errors/queue-unavailable) | Blujay couldn't accept the message right now. Retry with the same `Idempotency-Key`. | | `409` [`SENDER_RETIRED`](/errors/sender-retired) | Follow-ups only: the chat's number was retired. Start a new chat from an active number. | | `409` [`IDEMPOTENCY_IN_PROGRESS`](/errors/idempotency-in-progress) | The first request with this key is still running. Retry shortly with the same key. | | `409` [`IDEMPOTENCY_REQUEST_MISMATCH`](/errors/idempotency-request-mismatch) | You reused a key with a different body. Use a new key for a new message. | | `422` [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed) | A media URL isn't safe to fetch. See [Media](/messaging/media). | | `404` [`NOT_FOUND`](/errors/not-found) | The chat, attachment or vCard doesn't exist in your workspace. | Every error carries a `trace_id`. Include it when you contact support. See [Errors](/errors) for the envelope. ## List chats and messages All list endpoints return a page of results, newest change first. Pass `limit` (1 to 100, default 25) and, for the next page, the previous page's `next_cursor` as `cursor`. Stop when `has_more` is `false`. ```bash theme={null} curl "https://api.blujay.io/v1/chats/chat_abc123/messages?limit=2" \ -H "Authorization: Bearer blu_your_api_key" ``` ```json theme={null} { "status": "success", "data": { "data": [ { "message_id": "msg_def456", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "received", "parts": [{ "type": "text", "text": "Thanks, got it!" }], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "created_at": "2026-10-14T17:04:12.000Z", "updated_at": "2026-10-14T17:04:12.000Z" }, { "message_id": "msg_abc123", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "read", "parts": [ { "type": "text", "text": "Hi Ava, this is Sam from the Northwind support team. Reply here any time." } ], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "trace_id": "trc_8f2a1c0e4b7d", "created_at": "2026-10-14T17:01:00.000Z", "updated_at": "2026-10-14T17:03:40.000Z" } ], "next_cursor": "cur1.example", "has_more": true, "limit": 2 }, "trace_id": "trc_3d9b7e1c2a4f", "timestamp": "2026-10-14T17:05:00.000Z" } ``` An inbound message has `status: received`. Messages are ordered by when they last changed (`updated_at`), not when they were sent. An edit, a read receipt or any status change moves a message back to the top, so a message can appear on a page you already fetched. Deduplicate on `message_id`, and sort by `created_at` if you're showing a conversation. The `trace_id` on a message is the trace of the request that sent it; inbound messages have none. | Endpoint | Returns | | - | - | | `GET /v1/chats` | Your chats, most recently active first. | | `GET /v1/chats/{chat_id}` | One chat, with its participants, the number it uses (`phone_number_id`) and its confirmed `service`. | | `GET /v1/chats/{chat_id}/messages` | The messages in one chat. | | `GET /v1/messages` | Messages across every chat. Filter with `status` (for example `failed`) or `updated_after` (an ISO 8601 time) to sync only what changed. There are no other filters. | | `GET /v1/messages/{message_id}` | One message with its current status. | > [!WARNING] > > **Common mistakes** > > * `202` means queued, not delivered. Track the message before you tell anyone it arrived. > * `from` is a string (`"+15555550100"`), not an array and not a `phone_number_id`. Endpoints that take one of your numbers by ID use `from_phone_number_id`. > * URL-encode `+` in query strings (`%2B15555550123`), or the server reads it as a space. > * `next_cursor` values are opaque. Pass them back exactly as returned; never send a placeholder such as `cursor=string`. > * The `202` field `id` is the `message_id` you read back later. `delivery_status: queued` is `status: accepted` on reads. ## Retry a failed message `POST /v1/messages/{message_id}/retry` sends a failed message again with the same `message_id`. ```bash theme={null} curl -X POST https://api.blujay.io/v1/messages/msg_abc123/retry \ -H "Authorization: Bearer blu_your_api_key" \ -H "Idempotency-Key: retry-msg_abc123-1" ``` A successful retry returns `202` with the same shape as a send, and the message's status goes back to `accepted`. A failed message's `retryable` field tells you whether retry is possible. `false` means the message may already have reached the recipient, so check the conversation before you send a replacement. `true` means it never left Blujay; retry can still be refused if the recipient has since opted out or the number can no longer send. Only direct-chat messages that failed before they left Blujay can be retried. Everything else returns `409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed), including group messages and messages that may already have reached the recipient. Blujay refuses those so the recipient never gets a duplicate. No field on the message tells you in advance whether a retry is allowed; the call is the check. When you get `RETRY_NOT_ALLOWED`, don't automatically send a replacement: check the message status and the conversation first, and send a new message only when you're sure the first one didn't arrive or a duplicate is acceptable. Retrying an inbound message returns `400` [`INVALID_MESSAGE`](/errors/invalid-message). ## Limitations * Recipients must be US phone numbers. Email addresses return `400` [`VALIDATION_ERROR`](/errors/validation-error). * Until the contact replies: no links, media or contact cards (`400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked)), 300 characters per text (`400` [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded)), 6 texts in a row (`400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached)). See [Sending rules](/messaging/sending-rules). * Free Plan numbers send only to verified contacts (`403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified)) and can't use group chats (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)). AI Agent numbers only reply to people who text first (`403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)). See [Plans](/plans). * A `link` part must be the only part in its message. * `preferred_service` works in direct chats only (`400` [`VALIDATION_ERROR`](/errors/validation-error) in groups). * Webhook payloads don't carry `client_reference`; reads do. * Retry works only for direct-chat messages that failed before leaving Blujay; check `retryable` on the message first (`409` [`RETRY_NOT_ALLOWED`](/errors/retry-not-allowed)). ## Next steps > **[Sending rules](/messaging/sending-rules)** > > The pre-reply rules, what counts as a response, and the exact error codes. > **[Track a message](/messaging/lifecycle)** > > Statuses, services and failure codes, and how to follow them. > **[Media](/messaging/media)** > > Upload files once or send them from a URL. > **[Idempotency](/idempotency)** > > Retry any send safely without sending twice. # Sending rules Source: https://docs.blujay.io/messaging/sending-rules The rules Blujay enforces on every API send, and the exact error each one returns. Blujay enforces a small set of rules on every message your integration sends. They exist because Apple and the carriers judge a phone number by how people respond to it, and a number that gets filtered stops delivering for everyone it messages. The rules are the same on every plan. The errors are `400` or `403`, never retryable, and nothing is sent when you get one. This page is the contract. [Sending best practices](/best-practices) is the advice that keeps you well inside it. ## Before the contact replies Until a contact has replied to your phone number, Blujay rejects three kinds of send: | Rule | Error | | - | - | | A link, in a `link` part or in text that contains a web address | `400` [`FIRST_MESSAGE_LINK_NOT_ALLOWED`](/errors/first-message-link-not-allowed) on `POST /v1/chats`, `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) on a follow-up | | A photo, video, file or contact card (`media` or `vcard` part) | `400` [`PRE_REPLY_CONTENT_BLOCKED`](/errors/pre-reply-content-blocked) | | Text over 300 characters | `400` [`PRE_REPLY_LENGTH_EXCEEDED`](/errors/pre-reply-length-exceeded) | Blujay treats anything that looks like a domain name as a link, including `example.com` without `https://` and the domain in an email address such as `help@example.com`. Keep those out too. The rules lift for that contact on that number as soon as they reply. They apply per number: a contact who has replied to your Support number has not replied to your Sales number. ## Texts in a row without a response Before and after a first reply, Blujay sends at most **6 texts in a row** to a contact without a response from them. The 7th returns `400` [`PRE_REPLY_LIMIT_REACHED`](/errors/pre-reply-limit-reached). The count covers every message the person gets from your workspace, from any of your numbers, the Blujay apps, workflows and the API together. A photo or file with no text counts as one text; a caption and its attachment count as one. The count starts again each time they respond, on any of your numbers. To send one more text after the limit, for something the person needs, set `pre_reply_override: true` at the top level of the request body. It allows a 7th text and nothing after it until the contact responds. It is not a way around the rule. The override works only when exactly 6 texts are unanswered; a contact with a longer unanswered history (for example 20 texts sent before you turned on the API) is refused with or without it, until they respond. Every one of these errors carries `details[0]` with `rule` (`link`, `attachment`, `length` or `limit`), `limit` (300 characters for `length`, 6 texts for `limit`, `null` for the others) and `unanswered_count` (for `limit`). ## What counts as a response * A text from the contact, in the direct chat or in a group chat your number is in. * A reaction (tapback) from the contact. * Calls never count. The limits protect the iMessage number, which never sees a call. A response from the contact lifts the content rules for that number and restarts the 6-text count. ## Group chats The same rules apply to a group chat, counted for the group: * Until anyone in the group replies, links, attachments and texts over 300 characters are rejected. * At most 6 texts go to the group in a row without a response from anyone in it. * The rules apply to every `POST /v1/chats` that starts or reuses a group, even when some members already have direct chats with you. ## Who you can message | Plan | Rule | Error | | - | - | - | | Free Plan | Only [verified contacts](/plans#free-plan): people who texted your Free Plan number first and were verified in Blujay. Up to 10. | `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | | Free Plan | No group chats. | `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | | AI Agent | Only people who texted that number first. No group chats. | `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | | Every plan | Nobody who has opted out. | `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | | Every plan | US phone numbers only. | `400` [`VALIDATION_ERROR`](/errors/validation-error) | See [Plans](/plans) for what each plan can do. ## Limits on each number These are `429` errors with a `Retry-After` header, and they are retryable. Nothing is sent when you get one. | Rule | `details[0].limit_type` | Error | | - | - | - | | New contacts a day. A Dedicated number can message up to 50 people it has never texted before per day. Replies and follow-ups never count. | `new_contact_daily` | `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | | 30 messages a minute to the same person from one number. A guard against runaway loops. | `recipient_loop` | `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | | 1,500 messages waiting to send from one number. | `phone_number_queue` | `429` [`QUEUE_FULL`](/errors/queue-full) | Read the live values from `limits` and `queue` on [`GET /v1/phone-numbers`](/phone-numbers) before a batch. Those fields are the source of truth; the numbers above are today's typical values, and sending limits can change at any time. ## Handle these errors * Don't retry a `400` or `403` from this page. Wait for the contact to reply, then send the link, file or longer text as a follow-up with a sentence that says what it is. * On a `429`, wait for `Retry-After`, then retry with the same `Idempotency-Key`, or send from another number. * To know before you send, read the contact's `opted_out` and `messaging_allowed`, and the number's `outbound_policy.inbound_first`, `send_status`, `limits` and `queue`. There is no field that reports a contact's pre-reply state today; the first text you send tells you. ## Next steps > **[Sending best practices](/best-practices)** > > Get replies and keep your numbers healthy. > **[Phone numbers and limits](/phone-numbers)** > > Read live limits before you send. > **[Plans](/plans)** > > What Free Plan, Dedicated and AI Agent numbers can do. > **[Errors](/errors)** > > Every error code and how to fix it. # Typing and read receipts Source: https://docs.blujay.io/messaging/typing-and-read-receipts Show a typing indicator, mark chats as read, and display your contacts' typing indicators from webhooks. Typing indicators and read receipts make an automated conversation feel attended. You start and stop your typing indicator with `POST` and `DELETE /v1/chats/{chat_id}/typing`, mark a chat as read with `POST /v1/chats/{chat_id}/read`, and hear when your contacts are typing through the `chat.typing_indicator.started` and `chat.typing_indicator.stopped` webhooks. ## Show a typing indicator Start the indicator before you send a reply, and stop it if you decide not to send one. ```bash curl theme={null} curl -X POST https://api.blujay.io/v1/chats/chat_abc123/typing \ -H "Authorization: Bearer blu_your_api_key" ``` ```bash curl theme={null} curl -X DELETE https://api.blujay.io/v1/chats/chat_abc123/typing \ -H "Authorization: Bearer blu_your_api_key" ``` Both return `204 No Content` with an `X-Trace-ID` header. Typing is best effort: a `204` means Blujay accepted the request, not that the other person saw a typing bubble. Blujay doesn't promise how long the bubble stays up. Start it right before you send, and call `DELETE` if you decide not to send. What happens depends on the chat: | Chat | Result | | - | - | | Direct chat confirmed as iMessage | The indicator shows on the other person's device. | | Established iMessage group chat | The indicator shows to the group, from the chat's Blujay phone number. | | SMS or RCS chat, a direct chat whose service isn't confirmed yet, or any other group | Accepted with `204`. Nothing is shown. | Check the chat's `supports_typing_indicators` field from `GET /v1/chats/{chat_id}` to know in advance whether an indicator can appear. You can call the typing endpoints either way. | Error | Status | Cause | | - | - | - | | [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) | 400 | Typing indicators are switched off for your workspace. They're on by default and there's no setting in the API; contact [support@blujay.io](mailto:support@blujay.io) to switch them back on. | | [`UNSUPPORTED_CHAT_OPERATION`](/errors/unsupported-chat-operation) | 400 | The chat isn't active. See [Glossary](/glossary). | | [`PHONE_NUMBER_UNAVAILABLE`](/errors/phone-number-unavailable) | 503 | Your Blujay phone number couldn't be reached. Retry, or skip the indicator. | ## Mark a chat as read `POST /v1/chats/{chat_id}/read` marks every incoming message in the chat as read in Blujay. It works on the whole chat; there is no per-message read, and the call takes no body. ```bash curl theme={null} curl -X POST https://api.blujay.io/v1/chats/chat_abc123/read \ -H "Authorization: Bearer blu_your_api_key" ``` It returns `204 No Content`. In direct iMessage and RCS chats, Blujay also sends a read receipt, so the other person can see that you read their messages. SMS chats and group chats are only marked read in Blujay; nobody is notified. Mark-read ignores `Idempotency-Key`; calling it twice is harmless. A `503` [`PHONE_NUMBER_UNAVAILABLE`](/errors/phone-number-unavailable) means the read receipt couldn't be sent. The chat is still marked read in Blujay, so you can retry or move on. ## Receive typing indicators Subscribe to `chat.typing_indicator.started` and `chat.typing_indicator.stopped` to hear when a contact is typing to one of your numbers in a direct iMessage chat. SMS and RCS chats never produce typing events, and your own typing calls never produce them. ```json chat.typing_indicator.started theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "chat.typing_indicator.started", "event_id": "evt_abc123", "created_at": "2026-10-14T17:40:00.100Z", "trace_id": "trc_2b4d6f8a0c1e", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "occurred_at": "2026-10-14T17:40:00.000Z", "expires_at": "2026-10-14T17:41:00.000Z" } } ``` `occurred_at` is when Blujay observed the typing, not the exact keystroke time. `expires_at` is always 60 seconds after `occurred_at`. ### Group typing snapshots Subscriptions on webhook version `2026-10-03` also receive typing for iMessage group chats, but only on Dedicated numbers (`access_type: "dedicated"` in [`GET /v1/phone-numbers`](/phone-numbers)). Earlier versions never receive group typing, and a Free Plan number never does. A group event uses the same two event types and adds `participants`: everyone typing in that group right now. ```json chat.typing_indicator.started (group) theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "chat.typing_indicator.started", "event_id": "evt_def456", "created_at": "2026-10-14T17:45:00.100Z", "trace_id": "trc_6f8a0c2e4b1d", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "chat_id": "chat_grp789", "phone_number_id": "phone_abc123", "occurred_at": "2026-10-14T17:45:00.000Z", "expires_at": "2026-10-14T17:46:00.000Z", "participants": [ { "type": "phone", "address": "+15555550123", "contact_id": "contact_abc123" }, { "type": "phone", "address": "+15555550125" } ] } } ``` * `started` always lists at least one participant. `stopped` has `"participants": []`. * There are no per-person events. When one person stops while another keeps typing, you get another `started` with the shorter list. Replace the chat's whole list of typers with each newer snapshot. * Each participant has `type` (`phone` or `email`) and `address`, plus `contact_id` when they match a contact in your workspace. Your own Blujay numbers never appear. ### Display typing correctly Typing events can be repeated, delayed or out of order, and a `stopped` event isn't guaranteed. Apply these rules for each `chat_id` and `phone_number_id` pair: ### 1. Deduplicate Ignore any `event_id` you have already processed. ### 2. Drop stale events Ignore an event whose `expires_at` has passed, or whose `occurred_at` is older than the last event you applied. ### 3. Break ties toward stop If a `started` and a `stopped` have the same `occurred_at`, apply the `stopped`. ### 4. Expire on your own Clear the indicator at `expires_at` even if no `stopped` arrives. A refreshed indicator arrives as another `started` event with a later `occurred_at` and `expires_at`. > [!WARNING] > > **Common mistakes** > > * A `204` from `POST /typing` doesn't mean a bubble appeared. Check `supports_typing_indicators` on the chat if it matters. > * `POST /read` marks the whole chat, not one message. > * A `stopped` event isn't guaranteed. Clear the indicator at `expires_at` on your own. > * Group typing events need webhook version `2026-10-03` and a Dedicated number. On older versions or a Free Plan number, you'll never see them. ## Limitations * Typing indicators you send appear only in direct iMessage chats and established iMessage groups. Everything else is accepted with `204` and shows nothing. * No duration is promised for a typing bubble, and Blujay doesn't confirm that it was shown. * Typing can be switched off for a workspace (`400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled)); only support can switch it back on. * Read receipts go out for direct iMessage and RCS chats only. SMS and group chats are marked read in Blujay without notifying anyone. * Mark-read covers the whole chat. There is no per-message read receipt. * Incoming typing events come from direct iMessage chats, and from iMessage groups only on Dedicated numbers with webhook version `2026-10-03`. SMS and RCS chats never produce them. * Your own typing calls never produce typing events. ## Next steps > **[Webhooks overview](/webhooks/overview)** > > Create a subscription for typing and message events. > **[Webhook versions](/webhooks/versions)** > > Move to `2026-10-03` to receive group typing snapshots. > **[Group chats](/messaging/group-chats)** > > Learn what makes a group an established iMessage group. > **[Send messages](/messaging/send-messages)** > > Send the reply after your typing indicator. # Opt-outs Source: https://docs.blujay.io/opt-outs Stop messages to a contact or an address, lift an opt-out, and handle RECIPIENT_SUPPRESSED on sends. When someone asks you to stop messaging them, record an opt-out in Blujay. Blujay then rejects API sends to that person and won't enroll them in workflows. Opt-outs are stored by phone number or email address, so they hold even if the contact is deleted and created again. On a Dedicated number, Blujay also opts a contact out automatically when they text exactly `STOP` and nothing else. Other wording, such as "please stop", does nothing by itself: read the reply and opt them out with the API. See [Plans](/plans) for which numbers are Dedicated. ## Opt out a contact `POST /v1/contacts/{contact_id}/suppress` opts out the contact's phone number, or their email if they have no phone number. `reason` is optional (up to 500 characters) and is stored with the opt-out. The body can be empty. ```bash curl theme={null} curl https://api.blujay.io/v1/contacts/contact_abc123/suppress \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "reason": "Asked not to be contacted" }' ``` ```json Response (200) theme={null} { "status": "success", "data": { "contact_id": "contact_abc123", "phone_number": "+15555550123", "email": "ava@example.com", "name": "Ava Chen", "tags": ["VIP"], "stage": "Active", "assigned_team_member": null, "opted_out": true, "opted_out_at": "2026-10-14T17:45:00.000Z", "opted_out_source": "api", "messaging_allowed": false, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:45:00.000Z" }, "trace_id": "trc_0c2e4a6b8d1f", "timestamp": "2026-10-14T17:45:00.060Z" } ``` `opted_out_at` and `opted_out_source` appear only while the contact is opted out. `opted_out_source` says how the opt-out was made: | `opted_out_source` | Meaning | | - | - | | `api` | Set through this API. | | `inbound_stop` | The person texted `STOP` to a Dedicated number. | | `manual` | A team member opted them out in Blujay. | | `internal` | Set by Blujay, for example a do-not-contact flag carried over from the app. | Opting out a contact that is already opted out succeeds again and returns the contact. ## Opt out an address To opt out a phone number or email that may not belong to a contact yet, use `POST /v1/contacts/suppressions` with the `address`. Use this when you import a do-not-contact list from another system. ```bash curl theme={null} curl https://api.blujay.io/v1/contacts/suppressions \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "address": "+15555550125", "reason": "Imported do-not-contact list" }' ``` ```json Response (200) theme={null} { "status": "success", "data": { "address_type": "phone", "address": "+15555550125", "opted_out": true, "opted_out_at": "2026-10-14T17:46:00.000Z", "opted_out_source": "api", "reason": "Imported do-not-contact list" }, "trace_id": "trc_6a8c0e2b4d1f", "timestamp": "2026-10-14T17:46:00.040Z" } ``` `address` must be a US phone number in any accepted format (see [Contacts](/contacts#create-a-contact)) or an email address; anything else returns `400` [`VALIDATION_ERROR`](/errors/validation-error). If the address belongs to a contact, that contact shows `opted_out: true`. Opting out an address that is already opted out succeeds again. ## Handle RECIPIENT\_SUPPRESSED A send to someone who has opted out returns `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) before anything is queued: * `POST /v1/chats` checks every recipient in `to`. * `POST /v1/chats/{chat_id}/messages` checks the person in a direct chat, and everyone currently in a group chat. One opted-out participant blocks sends to the whole group. Don't retry these sends. Remove the person from your audience, or for a group, [remove them from the group](/messaging/group-chats#add-or-remove-a-participant) or start a new group without them. Workflows respect opt-outs too. Enrolling an opted-out contact doesn't start a run; the response has `no_op_reason: "contact_opted_out"`. See [Workflows](/workflows). ## Check opt-out status Every contact you read includes `opted_out` and `messaging_allowed`. Check them with `GET /v1/contacts/{contact_id}`, or look a person up by phone number or email with `GET /v1/contacts/search`, before you send. See [Contacts](/contacts#fields-to-check-before-you-send). There is no webhook for opt-outs yet, so an integration that mirrors opt-outs into its own system has to poll. Read the contacts you care about and watch `opted_out`; `opted_out_source: "inbound_stop"` tells you the person texted STOP. ## Remove an opt-out `POST /v1/contacts/{contact_id}/unsuppress` removes the contact's opt-out and returns the contact with `opted_out: false`. Only do this when the person has agreed to hear from you again. ```bash curl theme={null} curl -X POST https://api.blujay.io/v1/contacts/contact_abc123/unsuppress \ -H "Authorization: Bearer blu_your_api_key" ``` The API removes opt-outs only through a contact. To lift an opt-out on an address that has no contact, create the contact first, then call `unsuppress`. ## Keep opt-outs when contacts are deleted Opt-outs belong to the phone number or email address, not to the contact record. When you delete an opted-out contact with `DELETE /v1/contacts/{contact_id}`, the opt-out stays. If you later create a contact with the same phone number, it starts out with `opted_out: true`, and sends to that number are still rejected. ## Limitations * No webhook for opt-outs. `contact.suppressed` and `contact.unsuppressed` exist in the schema but return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) on subscribe. Poll `opted_out`. * No endpoint lists opt-outs, and `GET /v1/contacts` can't filter by `opted_out`. Keep your own list from the responses you get, or read contacts one by one. * Automatic STOP handling applies to Dedicated numbers and to the exact text `STOP` only. Free Plan and AI Agent numbers, and any other wording, need you to opt the person out. * An opt-out on an address with no contact can only be lifted by creating the contact first. * An opt-out blocks the whole group: `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) on every send to a group that contains an opted-out participant. ## Next steps > **[Contacts](/contacts)** > > Create, find and update the people you message. > **[Sending rules](/messaging/sending-rules)** > > Who you can message on each plan. > **[Group chats](/messaging/group-chats)** > > Manage who is in a group before you send to it. > **[Errors](/errors)** > > See every error code and how to fix it. # Phone numbers and limits Source: https://docs.blujay.io/phone-numbers List your Blujay phone numbers, read their live sending limits and queue, and handle the limit errors a send can return. Every message goes out from one of your Blujay phone numbers. `GET /v1/phone-numbers` tells you which numbers you have, whether each one can send right now, how full its queue is, and how much of its sending limits it has used. This page also explains the limits themselves: how many new contacts a number can message a day, how big its queue is, and the guard against messaging one person too often. Request limits on the API itself (reads and writes per minute) are on [Rate limits](/rate-limits). The rules about what a message can contain before a contact replies are on [Sending rules](/messaging/sending-rules). ## List your numbers `GET /v1/phone-numbers` returns the active numbers in your workspace, oldest first. ```bash curl theme={null} curl https://api.blujay.io/v1/phone-numbers \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "phone_numbers": [ { "phone_number_id": "phone_abc123", "display_number": "+15555550100", "label": "Support", "status": "active", "line_type": "outbound", "access_type": "dedicated", "send_status": { "allowed": true }, "health_status": { "status": "healthy", "score": 96, "as_of": "2026-10-14T17:00:00.000Z", "freshness": "fresh" }, "limits": { "new_contacts": { "used_today": 12, "limit_per_day": 50, "remaining_today": 38, "reset_at": "2026-10-15T07:00:00.000Z" }, "daily": { "used_today": 140, "guideline": 250, "remaining_to_guideline": 110, "enforced": false } }, "queue": { "pending": 3, "capacity": 1500, "remaining": 1497 }, "capabilities": { "messaging": { "outbound": true, "inbound": true, "media_attachments": true, "link_parts": true }, "voice": { "outbound_ready": true, "inbound_forwarding_ready": true } }, "outbound_policy": { "inbound_first": false, "first_message_links_allowed": false, "follow_up_links_allowed": true, "daily_total_enforced": false } } ] }, "trace_id": "trc_5b7d9f1a3c2e", "timestamp": "2026-10-14T17:00:01.000Z" } ``` The `limits` and `queue` values above are illustrative. They differ between numbers and change over time, so read them from your own responses. To read one number, use `GET /v1/phone-numbers/{phone_number_id}`. It also returns numbers that are no longer active, with `status: "unavailable"`, so you can still look up a number that older chats used. An unavailable number doesn't report current health, limits, queue or label. ## Understand the fields | Field | What it tells you | | - | - | | `phone_number_id` | The ID to use as `from_phone_number_id` and in webhook filters. `POST /v1/chats` is the exception: it takes the `display_number` string as `from`. | | `display_number` | The number in E.164. | | `status` | `active`, or `unavailable` when the number can't be used. | | `line_type` | Always `outbound` today. | | `access_type` | `dedicated` for a number that is yours alone (Dedicated and AI Agent plans), or `shared` for a Free Plan number. See [Plans](/plans). | | `send_status` | Whether the number can send right now. When `allowed` is `false`, `reason_code` and `reason` say why: `sender_retired` (the number was replaced; send from an active number), `number_inactive` or `number_setup_incomplete`. | | `health_status` | Your own numbers only. `status` is `healthy`, `degraded`, `critical` or `unknown`, with an optional `score` from 0 to 100, the time it was scored (`as_of`) and a `freshness` hint. | | `limits` | Your own numbers only. Live sending limits and usage. See [Sending limits](#sending-limits). | | `queue` | Your own numbers only. Messages waiting to send from this number: `pending`, the queue's `capacity`, and how many more it can take (`remaining`). | | `capabilities.messaging` | Whether the number can send, receive, send media and send link parts. | | `capabilities.voice` | Your own numbers only. Whether the number is ready for calls: `outbound_ready` for outgoing calls and `inbound_forwarding_ready` for incoming calls. The API has no calling endpoints. | | `outbound_policy` | Who the number can message, and link rules. `inbound_first` is `true` when the number can only message people who texted it first (Free Plan and AI Agent numbers); skip those when you start conversations. `first_message_links_allowed` is always `false`: a chat's first message can't contain a link. Follow-ups can, once the contact has replied. `daily_total_enforced` is always `false`, the same fact as `limits.daily.enforced`. | Free Plan numbers leave out `health_status`, `limits`, `queue` and `capabilities.voice`. A missing field there doesn't mean the number can't send; use `send_status`. > [!WARNING] > > **Common mistake:** `from` in `POST /v1/chats` is the `display_number` string (`"+15555550100"`), not the `phone_number_id`. Every other endpoint that names a number takes `from_phone_number_id` (`"phone_abc123"`). ## Sending limits Sending limits protect each number's reputation, so its messages keep arriving. They apply to each phone number, not to your API key. The values below are today's typical values; the live `limits` and `queue` fields on your number are the source of truth, and sending limits can change at any time. There is no hourly limit on texts. | Field | Meaning | | - | - | | `limits.new_contacts` | People this number has messaged for the first time today: `used_today`, `limit_per_day`, `remaining_today`, and `reset_at`, when the count resets. | | `limits.daily` | Messages sent today against a recommended daily total: `used_today`, `guideline` and `remaining_to_guideline`. `enforced` is `false`, so the guideline is advice and Blujay doesn't reject sends because of it. | | `queue` | `pending` messages waiting to send, the queue's `capacity`, and how many more it can take (`remaining`). | ### New contacts per day A **new contact** is someone this number has never texted before. Each Dedicated number can message up to 50 new contacts a day once it is established. A new number, or one that was recently reassigned, may start lower and reaches the full limit over about its first month. A number's age is counted from the day it was created in Blujay; moving it to another workspace doesn't reset it. * People who texted you first never count, and neither do follow-ups in a chat you already have. * In a new group chat, each participant the number hasn't texted before counts. * Over the limit, a send returns `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) with `limit_type: new_contact_daily`. Nothing is queued. ### Queue Each number's send queue has room for 1,500 pending messages. When it is full, a send returns `429` [`QUEUE_FULL`](/errors/queue-full) with `limit_type: phone_number_queue`. Wait for `Retry-After` while queued messages go out, then retry with the same `Idempotency-Key`. ### Texts to the same person Each number can send at most 30 messages a minute to the same person (`limit_type: recipient_loop`). It's there to stop a runaway loop in your code from flooding someone, and failed attempts count toward it too. Normal conversations never come close. ### Free Plan numbers A Free Plan number can message only [verified contacts](/plans): people who texted it first and were verified in the app, up to 10 of them. The new-contact limit never applies to it, and `GET /v1/phone-numbers` doesn't report `limits` or `queue` for it. Sending to anyone else returns `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified). The [sending rules](/messaging/sending-rules) and the same-person guard still apply. ### When limits reset The new-contact count resets at midnight in your workspace's time zone. The API doesn't expose that time zone, so don't compute the reset yourself: read `limits.new_contacts.reset_at` on the number, or `error.details[0].reset_at` on a `429`, and wait until then. The same-person guard and the queue clear on their own within minutes; their `reset_at` says when. ### Texts in a row without a response A separate rule limits how many texts go to a contact without a response, and what a message can contain before they reply. Those return `400` errors, not `429`, and aren't retryable. See [Sending rules](/messaging/sending-rules). ## Number health Your own numbers report `health_status`: a `score` from 0 to 100 and a `status` band. | `status` | `score` | What to do | | - | - | - | | `healthy` | 70 to 100 | Send normally. | | `degraded` | 50 to 69 | Start fewer new conversations from this number, and focus on people who reply. | | `critical` | 0 to 49 | Stop starting new conversations from this number. Keep answering people who message you, and contact [support@blujay.io](mailto:support@blujay.io). | | `unknown` | none | Blujay doesn't have a recent score for the number yet. | The score reflects how your conversations go: how often people reply, how long conversations last, how quickly you answer, how many conversations people start with you, how close you run to the recommended daily volume, and whether your messages look templated. See [Sending best practices](/best-practices). Read `health_status` as part of your preflight check and before a large batch. ## Check a number before you send Before you start a batch, read the number and stop when it has no room. Treat any of these as a stop signal: * `send_status.allowed` is `false`. * `queue.remaining` is `0`. * `limits.new_contacts.remaining_today` is `0` and you're about to message someone for the first time. ```javascript Node.js theme={null} const response = await fetch("https://api.blujay.io/v1/phone-numbers/phone_abc123", { headers: { Authorization: "Bearer blu_your_api_key" }, }); const body = await response.json(); if (body.status === "error") { throw new Error(`${body.error.code}: ${body.error.message} (trace ${body.trace_id})`); } const number = body.data; function canSend(number, { newContact }) { if (!number.send_status.allowed) return false; if (!number.limits) return true; // Free Plan numbers don't report limits. if (number.queue.remaining === 0) return false; if (newContact && number.limits.new_contacts.remaining_today === 0) return false; return true; } console.log(canSend(number, { newContact: true })); ``` ```python Python theme={null} import requests response = requests.get( "https://api.blujay.io/v1/phone-numbers/phone_abc123", headers={"Authorization": "Bearer blu_your_api_key"}, timeout=30, ) body = response.json() if body["status"] == "error": raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (trace {body['trace_id']})") number = body["data"] def can_send(number, new_contact): if not number["send_status"]["allowed"]: return False limits = number.get("limits") if limits is None: # Free Plan numbers don't report limits. return True if number["queue"]["remaining"] == 0: return False if new_contact and limits["new_contacts"]["remaining_today"] == 0: return False return True print(can_send(number, new_contact=True)) ``` The check is a guide, not a reservation. Other sends can use up room between your check and your send, so still handle the errors below. ## Handle limit errors on sends When a send would go over a number's limit, `POST /v1/chats` and `POST /v1/chats/{chat_id}/messages` return `429` before anything is queued. Wait for the `Retry-After` header (also `error.retry_after`), then retry with the same `Idempotency-Key`, or send from another number. | Error | `details[0].limit_type` | Meaning | | - | - | - | | [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | `new_contact_daily` | The number has reached today's limit for messaging new contacts. | | [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | `recipient_loop` | Too many messages to the same person in a short time. This guards against runaway loops. | | [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) | `phone_number_sendability` | The number can't send right now. Check its `send_status`. | | [`QUEUE_FULL`](/errors/queue-full) | `phone_number_queue` | The number's queue is full. Wait for queued messages to send. | Each entry in `details` also has `scope: "phone_number"`, the `phone_number_id`, `limit`, `used`, `remaining` and `reset_at`: ```json theme={null} { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "New-contact limit reached for this phone number", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/rate-limit-exceeded", "details": [ { "limit_type": "new_contact_daily", "scope": "phone_number", "phone_number_id": "phone_abc123", "limit": 50, "used": 50, "remaining": 0, "reset_at": "2026-10-15T07:00:00.000Z" } ], "retry_after": 43140 }, "trace_id": "trc_6b2d9e0f1a3c", "timestamp": "2026-10-14T17:01:00.000Z" } ``` `RATE_LIMIT_EXCEEDED` can also mean you're making API requests too quickly. In that case `details[0].limit_type` is `public_api_read`, `public_api_write` or `public_api_media`, and `scope` is `api_key`. See [Rate limits](/rate-limits). ## Common questions > **Is there an hourly limit?** > > No. Numbers have a daily limit on new contacts, a limit on texts in a row without a response, and a per-minute guard on texts to the same person. > **How do I send more?** > > Spread new conversations over the day and across your numbers. A new number's new-contact limit grows over about its first month, and people who text you first don't use it up. For more volume, add numbers. > **Do texts I receive count?** > > No. Only texts you send count. A reply from the contact restarts the count of texts in a row. > **Does a rejected send use up a limit?** > > A `429` or `400` queues nothing and doesn't use up the new-contact limit. Failed attempts do count toward the same-person guard. > [!TIP] > > For a large send, spread messages over the day and across your numbers, and stop when `remaining_today` or `queue.remaining` reaches zero. Steady batches deliver better than bursts. ## Limitations * A Free Plan number reports no `health_status`, `limits`, `queue` or `capabilities.voice`, and can message only verified contacts ([`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified)). * An AI Agent number only replies to people who text it first ([`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first)), and it can't create group chats or send to them. The phone number object doesn't say which plan a number is on. See [Plans](/plans). * A number that was replaced reports `send_status.reason_code: sender_retired`; a send from it returns [`SENDER_RETIRED`](/errors/sender-retired). Switch to the new number's `phone_number_id`. * There is no webhook when a number's health changes or it is suspended ([`SENDER_SUSPENDED`](/errors/sender-suspended)). [`phone_number.status_updated`](/webhooks/events#phone-numbers) fires only when a number is replaced. * The workspace time zone behind `reset_at` isn't exposed. ## Next steps > **[Send messages](/messaging/send-messages)** > > Start a chat from one of your numbers. > **[Sending rules](/messaging/sending-rules)** > > What a message can contain before and after a reply. > **[Rate limits](/rate-limits)** > > API request limits and how to back off. > **[iMessage, RCS or SMS](/messaging/choosing-a-service)** > > Check whether a number reaches someone over iMessage, RCS or SMS. # Plans Source: https://docs.blujay.io/plans What the API can do on a Free Plan, Dedicated or AI Agent phone number, and which error each limit returns. Every Blujay phone number is on one of three plans, and a workspace can hold numbers on different plans. A new workspace starts with a Free Plan number. The plan decides who the number can message and which features it can use. The API is the same on every plan; the difference is which requests succeed. | | Free Plan | Dedicated | AI Agent | | - | - | - | - | | Your own phone number | No, a shared number | Yes | Yes | | `access_type` on `GET /v1/phone-numbers` | `shared` | `dedicated` | `dedicated` | | Who you can message | Up to 10 [verified contacts](#free-plan) | Anyone who hasn't opted out | People who text the number first | | Start a conversation | No | Yes | No | | Group chats | No | Yes | No | | Media, contact cards, effects, replies, edit and unsend | After the contact replies | After the contact replies | After the contact replies | | Typing indicators and read receipts | Yes | Yes | Yes | | `limits`, `queue` and `health_status` reported | No | Yes | Yes | | Error when a send isn't allowed | `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified) | `403` [`RECIPIENT_SUPPRESSED`](/errors/recipient-suppressed) | `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first) | The [sending rules](/messaging/sending-rules) apply on every plan. ## Free Plan A new workspace starts on the Free Plan with a phone number that other Free Plan workspaces share. * A person must text your Free Plan number first, and you verify them in Blujay under **Contacts**. Then you can message them through the API. Sending to anyone else returns `403` [`CONTACT_NOT_VERIFIED`](/errors/contact-not-verified). * You can have up to 10 verified contacts. * Group chats and group operations return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). * `GET /v1/phone-numbers` shows the number with `access_type: "shared"` and leaves out `limits`, `queue` and `health_status`. Use `send_status` to see whether it can send. * Typing indicators, read receipts and the sending rules work the same as on other plans. The Free Plan is for trying the API with your own phone. See the [Quickstart](/quickstart#free-plan-only-verify-your-phone). ## Dedicated A Dedicated number is yours alone, with full functionality: start conversations with anyone who hasn't opted out, group chats, and every iMessage feature the API offers. * `GET /v1/phone-numbers` reports its live `limits`, `queue` and `health_status`. * It can message up to 50 new contacts a day once established. A new or recently reassigned number may start lower and reaches the full limit over about its first month. See [Phone numbers and limits](/phone-numbers). * When a contact texts exactly `STOP`, Blujay opts them out automatically. See [Opt-outs](/opt-outs). ## AI Agent An AI Agent number is your own number set up to reply, not to reach out. It is built for assistants that answer people who text in. * A person must text the number first. A send to anyone else, and any group chat request, returns `403` [`CONTACT_MUST_TEXT_FIRST`](/errors/contact-must-text-first). * Once someone has texted the number, you can message them like any other contact, with media, replies, effects, edit and unsend. * If a contact adds the number to a group chat, messages from that group still arrive, but the number can't send into it: group sends return the same `403`. * `GET /v1/phone-numbers` reports `limits`, `queue` and `health_status` as for a Dedicated number. ## Tell plans apart in the API * `access_type` is `shared` on the Free Plan and `dedicated` on Dedicated and AI Agent numbers. * `outbound_policy.inbound_first` is `true` on Free Plan and AI Agent numbers, the ones that can only message people who texted first. Skip them when you start conversations. It's `false` on Dedicated numbers. ## Next steps > **[Sending rules](/messaging/sending-rules)** > > The rules every plan shares. > **[Phone numbers and limits](/phone-numbers)** > > Read each number's live status and limits. # Quickstart Source: https://docs.blujay.io/quickstart Get an API key, send your first message and see the reply come back, in about five minutes. > [!NOTE] > > **Before you start** > > * **A Blujay workspace.** [Sign up free](https://app.blujay.io/?auth=register) if you don't have one. A new workspace starts on the Free Plan with a shared phone number. > * **Workspace owner or admin access.** Only workspace owners and admins see the **API** tab. > * **Your own US mobile number.** Blujay sends to US numbers only. Use your own phone as the recipient while you test: every API call sends real messages. > * **The Blujay app, if you're on the Free Plan.** A Free Plan number can message up to 10 verified contacts: people who texted the number first and were verified in the app. Verification happens in the app, not through the API (step 4). A Dedicated number skips that step. See [Plans](/plans). ### 1. Get your API key In [Blujay](https://app.blujay.io), open **API** in the sidebar. Under **API Token**, reveal and copy your key. It starts with `blu_`. Keep it on your server, in an environment variable or secret manager. Never put it in a browser, a mobile app or source control. ```bash theme={null} export BLUJAY_API_KEY="blu_your_api_key" ``` ### 2. Check that the key works ```bash theme={null} curl https://api.blujay.io/v1/me \ -H "Authorization: Bearer $BLUJAY_API_KEY" ``` ```json theme={null} { "status": "success", "data": { "workspace_id": "workspace_abc123", "workspace_label": "Acme Support", "credential": { "type": "api_key", "id": "3f6c2a7e-1b9d-4e5a-9c1f-2d7b8a6e4c10", "status": "active" }, "api_key": { "id": "3f6c2a7e-1b9d-4e5a-9c1f-2d7b8a6e4c10", "status": "active" } }, "trace_id": "trc_9d1f0a7c2b4e", "timestamp": "2026-10-14T17:00:00.000Z" } ``` A `401` means the key is missing or wrong. See [`UNAUTHORIZED`](/errors/unauthorized). ### 3. Pick a phone number to send from ```bash theme={null} curl https://api.blujay.io/v1/phone-numbers \ -H "Authorization: Bearer $BLUJAY_API_KEY" ``` Choose a number whose `send_status.allowed` is `true`, and note its `display_number`. `POST /v1/chats` takes that string as `from`. A Free Plan number shows `access_type: "shared"`. A Dedicated number also reports its live sending limits and queue. See [Phone numbers and limits](/phone-numbers). ### 4. Free Plan only: verify your phone On the Free Plan, Blujay sends only to contacts who have texted your Free Plan number first and were verified in the app. Skip this step if your number is Dedicated. 1. In [Blujay](https://app.blujay.io), go to **Contacts** and choose **Add verified contact**. 2. Enter your own mobile number and name. 3. From your phone, text the message Blujay shows to your Free Plan number. Scan the QR code to fill it in for you. 4. Wait until Blujay shows you as verified. Sending to someone who isn't verified returns [`403 CONTACT_NOT_VERIFIED`](/errors/contact-not-verified). ### 5. Send your first message `POST /v1/chats` starts a chat and sends the first message. Replace `from` with your Blujay number and `to` with your own mobile number. ```bash curl theme={null} curl https://api.blujay.io/v1/chats \ -H "Authorization: Bearer $BLUJAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-0001" \ -d '{ "from": "+15555550100", "to": ["+15555550123"], "message": { "parts": [{ "type": "text", "text": "Hi! This is my first message from the Blujay API." }] } }' ``` ```javascript Node.js theme={null} const response = await fetch("https://api.blujay.io/v1/chats", { method: "POST", headers: { Authorization: `Bearer ${process.env.BLUJAY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "quickstart-0001", }, body: JSON.stringify({ from: "+15555550100", to: ["+15555550123"], message: { parts: [{ type: "text", text: "Hi! This is my first message from the Blujay API." }], }, }), }); const body = await response.json(); if (body.status === "error") { throw new Error(`${body.error.code}: ${body.error.message} (trace ${body.trace_id})`); } console.log(response.status, body.data.chat_id, body.data.message.id); ``` ```python Python theme={null} import os import requests response = requests.post( "https://api.blujay.io/v1/chats", headers={ "Authorization": f"Bearer {os.environ['BLUJAY_API_KEY']}", "Idempotency-Key": "quickstart-0001", }, json={ "from": "+15555550100", "to": ["+15555550123"], "message": { "parts": [{"type": "text", "text": "Hi! This is my first message from the Blujay API."}] }, }, timeout=30, ) body = response.json() if body["status"] == "error": raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} (trace {body['trace_id']})") print(response.status_code, body["data"]["chat_id"], body["data"]["message"]["id"]) ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { payload := map[string]any{ "from": "+15555550100", "to": []string{"+15555550123"}, "message": map[string]any{ "parts": []map[string]string{ {"type": "text", "text": "Hi! This is my first message from the Blujay API."}, }, }, } body, _ := json.Marshal(payload) req, _ := http.NewRequest("POST", "https://api.blujay.io/v1/chats", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BLUJAY_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "quickstart-0001") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer resp.Body.Close() var result struct { Status string `json:"status"` Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` TraceID string `json:"trace_id"` Data struct { ChatID string `json:"chat_id"` Message struct { ID string `json:"id"` } `json:"message"` } `json:"data"` } json.NewDecoder(resp.Body).Decode(&result) if result.Status == "error" { panic(fmt.Sprintf("%s: %s (trace %s)", result.Error.Code, result.Error.Message, result.TraceID)) } fmt.Println(resp.StatusCode, result.Data.ChatID, result.Data.Message.ID) } ``` Blujay answers `202 Accepted`: the message is queued (`delivery_status: "queued"`), not yet delivered. ```json theme={null} { "status": "success", "data": { "chat_id": "chat_abc123", "message": { "id": "msg_abc123", "direction": "outbound", "delivery_status": "queued", "preferred_service": null, "service": null, "from": "+15555550100", "to": ["+15555550123"], "created_at": "2026-10-14T17:01:00.000Z", "parts": [{ "type": "text", "text": "Hi! This is my first message from the Blujay API." }] } }, "trace_id": "trc_4c8e2a91f0d7", "timestamp": "2026-10-14T17:01:00.000Z" } ``` `service` is `null` until Blujay confirms whether the message went out as iMessage, RCS or SMS. If your request times out, send it again with the **same** `Idempotency-Key`: you get the original result back instead of a second message. > [!WARNING] > > **Common mistakes** > > * `from` is one string (the `display_number`); `to` is an array, even for one recipient. > * `202` means queued. Nothing has been delivered yet. > * A `400` such as [`VALIDATION_ERROR`](/errors/validation-error) has no `data`. Check `status === "error"` before you read `data`, as the samples above do. ### 6. Watch it get delivered ```bash theme={null} curl https://api.blujay.io/v1/messages/msg_abc123 \ -H "Authorization: Bearer $BLUJAY_API_KEY" ``` ```json theme={null} { "status": "success", "data": { "message_id": "msg_abc123", "chat_id": "chat_abc123", "contact_id": "contact_abc123", "phone_number_id": "phone_abc123", "preferred_service": null, "service": "iMessage", "status": "delivered", "parts": [{ "type": "text", "text": "Hi! This is my first message from the Blujay API." }], "degradations": [], "reply_to": null, "edited_at": null, "edit_count": 0, "unsent_at": null, "unsent_scope": null, "trace_id": "trc_4c8e2a91f0d7", "created_at": "2026-10-14T17:01:00.000Z", "updated_at": "2026-10-14T17:01:03.000Z" }, "trace_id": "trc_51b9e07a3c6d", "timestamp": "2026-10-14T17:01:30.000Z" } ``` The `202` in step 5 names the message `message.id` and reports `delivery_status: "queued"`; every read, including this one and the webhooks, calls it `message_id` and uses `status`, which starts at `accepted`. `status` moves to `sent`, and for iMessage and RCS on to `delivered` and `read`. `service` shows the service that carried it. Polling is fine for a first test; in production, [subscribe to webhooks](/webhooks/overview) instead. See [Track a message](/messaging/lifecycle). ### 7. Reply from your phone Reply to the message from your phone, then list the chat's messages: ```bash theme={null} curl "https://api.blujay.io/v1/chats/chat_abc123/messages?limit=10" \ -H "Authorization: Bearer $BLUJAY_API_KEY" ``` Your reply is there as `msg_def456` with `status: "received"`. ### 8. Send a follow-up Answer in the same chat with `POST /v1/chats/{chat_id}/messages`, using the `chat_id` from your first send: ```bash theme={null} curl https://api.blujay.io/v1/chats/chat_abc123/messages \ -H "Authorization: Bearer $BLUJAY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-0002" \ -d '{ "message": { "parts": [{ "type": "text", "text": "Got it, thanks for replying!" }] } }' ``` Blujay answers `202 Accepted` again. Now that the person has replied, follow-ups can include links, photos, files and contact cards, and run longer than 300 characters. Until a contact replies, Blujay rejects those with a `400`. See [Sending rules](/messaging/sending-rules) and [Send messages](/messaging/send-messages). ### 9. Get replies sent to you automatically Instead of checking for new messages, have Blujay send each reply and delivery update to your server the moment it happens. Subscribe an HTTPS address on your server: ```bash theme={null} curl https://api.blujay.io/v1/webhook-subscriptions \ -H "Authorization: Bearer $BLUJAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.example.com/webhooks/blujay", "event_types": ["message.received", "message.delivered", "message.failed"] }' ``` ```json 201 Created theme={null} { "status": "success", "data": { "subscription_id": "whsub_abc123", "url": "https://yourapp.example.com/webhooks/blujay", "event_types": ["message.received", "message.delivered", "message.failed"], "webhook_version": "2026-10-03", "status": "active", "phone_number_filter": { "mode": "all" }, "signing_secret_hint": "Y3OA==", "signing_secret": "whsec_ZXhhbXBsZS1zZWNyZXQtZG8tbm90LXVzZS0xMjM0NTY3OA==", "created_at": "2026-10-14T17:05:00.000Z", "updated_at": "2026-10-14T17:05:00.000Z" }, "trace_id": "trc_7e1c4b2a90f3", "timestamp": "2026-10-14T17:05:00.000Z" } ``` > [!WARNING] > > The response includes `signing_secret`, shown **only this once**. Save it now: you need it to [check that each webhook really came from Blujay](/webhooks/verify-signatures). Reply from your phone again, and your server receives a `message.received` event within seconds. That is the whole loop: you sent a message, and the reply arrived back as `message.received`. Testing on your laptop? A tunneling tool such as [ngrok](https://ngrok.com) gives your local server a temporary public HTTPS address. See [Webhooks](/webhooks/overview) for the full setup and [Receive messages](/messaging/receive-messages) for what inbound messages look like. ### 10. Build something * Start from a working program: the [Examples](/examples) page has a complete reply bot and a safe batch sender you can copy. * Building with an AI assistant? [Build with AI](/build-with-ai) has the machine-readable docs and a prompt that reviews your integration for duplicate sends, missed webhooks and leaked keys. ## Next steps > **[Sending rules](/messaging/sending-rules)** > > What a message can contain before and after the contact replies. > **[Send messages](/messaging/send-messages)** > > Follow-ups, links, media and choosing a service. > **[Idempotency](/idempotency)** > > Retry safely without sending twice. > **[Errors](/errors)** > > What each error code means and how to handle it. # Rate limits Source: https://docs.blujay.io/rate-limits API request limits, capability-check quotas, and how to back off. This page covers limits on API requests. They protect the API and are the same for everyone. Limits on sending, which protect each phone number's reputation, are on [Phone numbers and limits](/phone-numbers#sending-limits). The rules about what a message can contain before a contact replies are on [Sending rules](/messaging/sending-rules). ## Request limits Limits count requests per API key, in fixed one-minute windows. | Requests | Limit | | - | - | | Reads: `GET` requests, and capability checks | 600 per minute | | Writes: `POST`, `PATCH`, `DELETE` | 120 per minute | | Attachments: any `/v1/attachments` request | 60 per minute | | Failed authentication, per IP address | 60 per minute | Responses include `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers; `RateLimit-Reset` is the number of seconds until the window resets, on every response including a `429`. Over a limit, you get `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) with a `Retry-After` header: ```json theme={null} { "status": "error", "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded", "type": "rate_limit", "http_status": 429, "retryable": true, "doc_url": "https://docs.blujay.io/errors/rate-limit-exceeded", "details": [{ "limit_type": "public_api_write", "scope": "api_key", "limit": 120, "used": 121, "remaining": 0, "reset_at": "2026-10-14T17:31:00.000Z" }], "retry_after": 12, "reset_at": "2026-10-14T17:31:00.000Z" }, "trace_id": "trc_6b2d9e0f1a3c", "timestamp": "2026-10-14T17:30:48.000Z" } ``` `retry_after` and `reset_at` on the error match the `Retry-After` header. `details[0].limit_type` tells you which request limit you hit: `public_api_read`, `public_api_write` or `public_api_media`. The same error code with `scope: "phone_number"` and a `limit_type` of `new_contact_daily`, `recipient_loop` or `phone_number_sendability` is a sending limit on the number, not a request limit. See [Handle limit errors on sends](/phone-numbers#handle-limit-errors-on-sends). ## Capability checks [`POST /v1/capability/evaluate_service`](/messaging/choosing-a-service) runs a fresh check on the phone number you name. Each number can run 30 fresh checks per clock hour and 100 per UTC day. An answer Blujay already has, from any check or send in the last 5 minutes (30 seconds for a check that didn't confirm anything), comes back with `source: "cached"`, and cached answers are free and unlimited. Over the quota, the check returns `429` [`RATE_LIMIT_EXCEEDED`](/errors/rate-limit-exceeded) with `Retry-After`. Sending with Auto (no `preferred_service`) never needs a check first. ## Backing off 1. On `429` or a retryable `503`, wait for `Retry-After` seconds. 2. Retry with the same `Idempotency-Key`. 3. If it fails again, back off exponentially with jitter, up to a few minutes. 4. Don't retry non-retryable errors (`retryable: false`). Fix the request instead. If `Retry-After` is longer than you want to wait (for example a sending limit that resets at midnight), schedule the send for later. An idempotency key is honored for 24 hours. See [Idempotency](/idempotency). ## Common questions > **Are request limits per key or per workspace?** > > Per API key. A workspace has one key today, so that is the same thing. > **Do cached capability checks count against the quota?** > > No. Only fresh checks count toward the 30 per hour and 100 per day. A cached answer has `source: "cached"`. > **Why did a send return 429 when I'm well under 120 writes a minute?** > > The number hit a sending limit, not the API. Check `error.details[0].limit_type` and `scope`. See [Phone numbers and limits](/phone-numbers#handle-limit-errors-on-sends). > **Why did my second message to someone return 400, not 429?** > > That is a sending rule, not a rate limit: links, attachments, contact cards and texts over 300 characters are rejected until the contact replies, and at most 6 texts go out in a row without a response. These are never retryable. See [Sending rules](/messaging/sending-rules). ## Next steps > **[Phone numbers and limits](/phone-numbers)** > > Sending limits and the live fields that report them. > **[Sending rules](/messaging/sending-rules)** > > What a message can contain before and after a reply. > **[Idempotency](/idempotency)** > > Retry without sending twice. # Support Source: https://docs.blujay.io/support How to get help with the Blujay API. Most questions are answered in [Debugging](/debugging), which walks through finding a failed request or message, and the [FAQ](/faq). Try those first. ## Contact support Email [support@blujay.io](mailto:support@blujay.io). To help support find the problem quickly, include: * the `trace_id` from the response (also in the `X-Trace-ID` header); * your `X-Request-ID`, if you send one; * the endpoint, the time of the request, and the error `code`; * for webhooks, the `subscription_id` and `event_id`. > [!WARNING] > > Never send your API key or webhook signing secret. Blujay support never asks for them. ## Request a feature Want something the API doesn't do yet? Email [support@blujay.io](mailto:support@blujay.io) with what you're building and what you need. Requests from developers shape what Blujay adds next. ## Security To report a security issue, email [security@blujay.io](mailto:security@blujay.io). ## Using Blujay For help with the Blujay apps, such as setting up your team or phone numbers, see the [Blujay Help Center](https://support.blujay.io). ## Next steps > **[Debugging](/debugging)** > > Trace a request and follow one message. > **[Errors](/errors)** > > Every error code, what it means and how to fix it. # Delivery and retries Source: https://docs.blujay.io/webhooks/delivery How Blujay retries failed deliveries, how to inspect attempts, and how to replay an event. ## At a glance | | How it works | | - | - | | **Time to answer** | Your endpoint has 10 seconds to return a `2xx`. | | **Retries** | Up to 10 attempts over about 25 minutes, on timeouts, network and DNS errors, `408`, `429` and `5xx`. | | **Duplicates** | Delivery is at least once, so the same event can arrive more than once. Ignore repeats by `event_id`. | | **Order** | Events can arrive out of order. Use `created_at` and the timestamps in `data` to decide what's newest. | | **After that** | List every attempt and [replay](#replay-an-event) any event yourself with the API. | ## Success and retries A delivery succeeds when your endpoint returns any `2xx` status within 10 seconds. Otherwise Blujay retries it automatically, up to **10 attempts in total over about 25 minutes**: | Attempt | Wait before it | | - | - | | 1 | Sent right away | | 2 | 10 seconds | | 3 | 20 seconds | | 4 | 40 seconds | | 5 | 80 seconds | | 6 | 160 seconds | | 7–10 | 5 minutes each | Each wait varies by up to 20% so retries don't arrive in bursts, and can be longer when Blujay is under heavy load. ### What your response means to Blujay | Your endpoint returns | What Blujay does | Attempt `error_code` | | - | - | - | | `2xx` | Done. The body is ignored. | none | | `301`, `302`, `303`, `307` or `308` with a `Location` header | Follows the redirect with the same `POST`, up to 3 times per attempt. See [Redirects](#redirects). | none, unless the redirect fails | | Any other `3xx`, or a redirect without `Location`, or a `1xx` | Stops. Not retried. | `HTTP_UNEXPECTED_RESPONSE` | | `408` | Retries on the schedule above. | `HTTP_RETRYABLE_RESPONSE` | | `429` | Retries on the schedule above. `Retry-After` is not read. | `HTTP_RETRYABLE_RESPONSE` | | Any other `4xx`, such as `400`, `401` or `404` | Stops. Fix your endpoint, then [replay](#replay-an-event) the event. | `HTTP_4XX_RESPONSE` | | `5xx` | Retries on the schedule above. | `HTTP_RETRYABLE_RESPONSE` | | Nothing within 10 seconds | Retries on the schedule above. | `WEBHOOK_DELIVERY_TIMEOUT` | | A DNS failure | Retries on the schedule above. | `WEBHOOK_URL_UNRESOLVABLE` | | A connection or TLS error | Retries on the schedule above. | `WEBHOOK_DELIVERY_NETWORK_ERROR` | Every retry carries the same `event_id` and body, with a fresh signature. If your endpoint is down for longer than the retry window, replay the events you missed once it is back. > [!TIP] > > To force a redelivery on purpose, for example when your database is unavailable, return `503`. The [Examples](/examples) page shows this pattern. ## Redirects If your endpoint answers with a redirect (`301`, `302`, `303`, `307` or `308` and a `Location` header), Blujay follows it, up to 3 redirects per attempt: * Blujay sends the same `POST`, with the full body and signature headers, to each new address. It never turns the request into an empty `GET`. * Each step gets its own 10 seconds. * Every address must be public HTTPS. A redirect to `http://` or to a private address fails the attempt with `WEBHOOK_URL_INVALID` or `WEBHOOK_URL_UNSAFE`. See [Where Blujay won't deliver](/webhooks/overview#where-blujay-wont-deliver). * Too many redirects (`WEBHOOK_REDIRECT_LIMIT_EXCEEDED`) or a broken `Location` (`WEBHOOK_REDIRECT_INVALID`) isn't retried, because retrying wouldn't change the result. Fix the URL, then replay the event. These codes are attempt `error_code` values from the deliveries list below, not API error codes; they have no error page. Redirects usually mean the subscription URL is slightly off: `example.com` instead of `www.example.com`, or a missing or extra trailing `/`. To find the address your server really answers on, run this. It prints the final URL after any redirects: ```bash theme={null} curl -s -o /dev/null -L -w '%{url_effective}\n' -X POST \ -H 'content-type: application/json' -d '{}' \ https://yourapp.example.com/webhooks/blujay ``` Then update the subscription's `url` with `PATCH /v1/webhook-subscriptions/{subscription_id}` so deliveries skip the redirect. ## Inspect delivery attempts ```bash theme={null} curl "https://api.blujay.io/v1/webhook-subscriptions/whsub_abc123/deliveries?limit=25" \ -H "Authorization: Bearer blu_your_api_key" ``` ```json theme={null} { "status": "success", "data": { "data": [ { "event_id": "evt_abc123", "event_type": "message.received", "attempt_number": 1, "delivery_mode": "automatic", "status": "failed", "http_status": 503, "error_code": "HTTP_RETRYABLE_RESPONSE", "error_message": "Remote endpoint returned retryable status 503", "scheduled_at": "2026-10-14T17:05:00.000Z", "started_at": "2026-10-14T17:05:00.010Z", "completed_at": "2026-10-14T17:05:00.420Z", "next_retry_at": "2026-10-14T17:05:10.000Z", "created_at": "2026-10-14T17:05:00.000Z", "updated_at": "2026-10-14T17:05:00.420Z" } ], "has_more": false, "limit": 25 }, "trace_id": "trc_2c7a9e1b40d5", "timestamp": "2026-10-14T17:05:01.000Z" } ``` Each row is one **attempt**, newest first. A failed attempt with `next_retry_at` will be retried; it isn't final. Add `event_id=evt_abc123` to see every attempt for one event; `cursor` and `limit` page the list. You can see the same log in [Blujay](https://app.blujay.io) under **API** → **Webhooks**. ### Find the event for a message There is no endpoint that lists events, and no lookup from a `message_id` to its events. The `event_id` arrives in the `webhook-id` header and the `event_id` field of each delivery, so store it with the event. If you never received the event, page the deliveries list for the time window, which shows each `event_id` and `event_type` but not the message, and replay the events in that window; each replay carries the full body. | `error_code` | Meaning | | - | - | | `HTTP_RETRYABLE_RESPONSE` | Your endpoint returned `408`, `429` or `5xx`. | | `HTTP_4XX_RESPONSE` | Your endpoint returned another `4xx`. Not retried. | | `HTTP_UNEXPECTED_RESPONSE` | Your endpoint returned a `1xx` or `3xx` that wasn't a followable redirect. Not retried. | | `WEBHOOK_DELIVERY_TIMEOUT` | No response within 10 seconds. | | `WEBHOOK_DELIVERY_NETWORK_ERROR` | The connection failed, including a TLS certificate Blujay couldn't verify. | | `WEBHOOK_REDIRECT_LIMIT_EXCEEDED`, `WEBHOOK_REDIRECT_INVALID` | Too many redirects, or a redirect to a URL Blujay won't call. Not retried. | | `WEBHOOK_URL_INVALID`, `WEBHOOK_URL_UNSAFE`, `WEBHOOK_URL_UNRESOLVABLE` | The URL isn't a valid public HTTPS address, resolves to a private address, or doesn't resolve. Only the last one is retried. | | `WEBHOOK_ATTEMPT_LIMIT_EXHAUSTED` | All 10 attempts failed. Replay the event when your endpoint is fixed. | | `WEBHOOK_DELIVERY_INTERRUPTED` | Blujay stopped this attempt before it finished. The event is retried, or you can replay it. | | `WEBHOOK_SIGNING_SECRET_UNAVAILABLE` | Blujay couldn't sign the request, so it wasn't sent. It is retried. | | `WEBHOOK_PAYLOAD_INVALID` | The stored event couldn't be prepared for delivery. Contact [support](/support) with the `event_id`. | | `WEBHOOK_SUBSCRIPTION_INACTIVE`, `WEBHOOK_PHONE_NUMBER_EXCLUDED` | The subscription was disabled, or no longer includes the event's type or phone number, when Blujay tried to deliver. | | `WEBHOOK_EVENT_NO_LONGER_DELIVERABLE` | The event can no longer be delivered, for example because the number it concerns was replaced. | ## Replay an event After you fix your endpoint, send an event again: ```bash theme={null} curl -X POST https://api.blujay.io/v1/webhook-subscriptions/whsub_abc123/events/evt_abc123/replay \ -H "Authorization: Bearer blu_your_api_key" \ -H "Idempotency-Key: replay-evt_abc123-1" ``` Blujay answers `202 Accepted` and makes one more delivery attempt, recorded with `delivery_mode: "replay"`. Check the deliveries list for its result. * The replay has the original `event_id` and body, in the version the event was created with. * If automatic retries for the event are still running, the replay waits for them to finish first. * A replay that fails isn't retried automatically. Replay it again when you're ready. * The subscription must be active and still include the event's type and phone number, or you get `409` [`WEBHOOK_SUBSCRIPTION_INACTIVE`](/errors/webhook-subscription-inactive) or [`WEBHOOK_PHONE_NUMBER_EXCLUDED`](/errors/webhook-phone-number-excluded). * Reuse the same `Idempotency-Key` only to retry the same replay request. A different key sends the event again. * Events stay replayable for at least the full retry window. Blujay doesn't publish a longer retention guarantee, so replay what you missed as soon as your endpoint is back. * Only events Blujay recorded can be replayed. While a subscription is disabled, no events are recorded for it. See [Webhooks](/webhooks/overview#manage-subscriptions). ## Limitations * The retry schedule is fixed. You can't change the number of attempts or the waits, and `Retry-After` on a `429` is ignored. * Replay is one event at a time, to the subscription that recorded it. There is no bulk replay and no replay to a different subscription. * Events from a disabled subscription are never recorded, so they can't be replayed. Pausing a subscription loses events. * There is no events endpoint and no message-to-event lookup. Store `event_id` with every event you receive. * Attempt `error_code` values are not API error codes. Only the API errors above link to an error page. ## Common mistakes > [!WARNING] > > * Don't return a redirect. Point the subscription at the final URL; a redirect costs a round trip on every attempt and fails if it leaves public HTTPS. > * Don't replay before you fix the endpoint. A replay that fails is not retried. > * Don't pause a subscription to buffer events. Return `503` instead, so Blujay retries and keeps the events. ## Next steps > **[Verify signatures](/webhooks/verify-signatures)** > > Every attempt is signed again. > **[Events](/webhooks/events)** > > Every event type and its payload. # Events Source: https://docs.blujay.io/webhooks/events Every webhook event type, when it is sent and what its payload contains. Choose events with `event_types` when you create or update a subscription. Payloads below are for the default version, `2026-10-03`. Every event uses the [common envelope](/webhooks/overview#the-event-payload); only `data` is shown. | Event | Sent when | | - | - | | [`message.accepted`](#message-lifecycle) | A message you sent through the API is accepted for sending. | | [`message.sent`](#message-lifecycle) | An outbound message has been sent. | | [`message.delivered`](#message-lifecycle) | An outbound message reached the recipient's device. | | [`message.read`](#message-lifecycle) | The recipient read an outbound message. | | [`message.failed`](#message-lifecycle) | An outbound message failed. | | [`message.received`](#message-received) | Someone sent a message to one of your numbers. | | [`message.edited`](#message-edited-and-message-unsent) | An iMessage was edited, by you or by the contact. | | [`message.unsent`](#message-edited-and-message-unsent) | An iMessage was unsent, by you or by the contact. | | [`reaction.added`](#reactions) | Someone added a reaction to a message. | | [`reaction.removed`](#reactions) | Someone removed a reaction. | | [`chat.typing_indicator.started`](#typing) | A contact started typing in an iMessage chat. | | [`chat.typing_indicator.stopped`](#typing) | A contact stopped typing. | | [`participant.added`](#group-changes) | Someone was added to a group chat. | | [`participant.removed`](#group-changes) | Someone left or was removed from a group chat. | | [`chat.group_name_updated`](#group-changes) | A group chat was renamed. | | [`contact.tag_added`](#contacts) | A tag was added to a contact. | | [`contact.assigned`](#contacts) | A contact's assigned team member changed. | | [`phone_number.status_updated`](#phone-numbers) | One of your numbers was replaced by a new number. | ### Reserved event types > [!NOTE] > > Nine more event types appear in the API schema but can't be subscribed to today: `chat.created`, `chat.group_icon_updated`, `chat.group_name_update_failed`, `chat.group_icon_update_failed`, `contact.created`, `contact.updated`, `contact.deleted`, `contact.suppressed` and `contact.unsuppressed`. Putting one in `event_types` returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). They may never be sent. In particular, there is no event for an opt-out and no event when a group rename fails: poll `opted_out` on the contact ([Opt-outs](/opt-outs)) and read the group after a rename ([Group chats](/messaging/group-chats)). An event type that isn't in the schema at all returns `400` [`UNSUPPORTED_WEBHOOK_EVENT`](/errors/unsupported-webhook-event). ## Message lifecycle `message.accepted`, `message.sent`, `message.delivered`, `message.read` and `message.failed` follow an outbound message. They cover messages sent from the Blujay apps, workflows, scheduled texts, integrations and the API. `message.accepted` is sent only for messages sent through the API. ```json theme={null} { "message_id": "msg_abc123", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "status": "delivered", "direction": "outbound", "requested_service": null, "service": "imessage", "sent_at": "2026-10-14T17:01:02.000Z", "delivered_at": "2026-10-14T17:01:03.000Z", "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [{ "type": "text", "text": "Hi Ava, your order 1042 has shipped." }], "reply_to": null } ``` * `requested_service` is the service you asked for (`null` for Auto). `service` is the service that actually carried the message, once confirmed. Webhooks spell services in lowercase (`imessage`, `rcs`, `sms`); message reads use `iMessage`, `RCS` and `SMS`. * Not every message gets every event. SMS usually stops at `sent`, because carriers don't report delivery or read status reliably. iMessage reports `delivered`, and `read` when the recipient has read receipts on, and it often skips `sent` altogether when delivery is confirmed within the same moment. Treat each event as the message's newest state rather than expecting the full sequence. * `degradations` lists anything that was not delivered because the message went out as SMS or RCS, for example `{"code": "effect_not_delivered", "reason": "…"}`. * **Lifecycle events carry no `contact_id`.** They identify the chat. To know the person, keep the `chat_id` from your `POST /v1/chats` response next to your own record, or read `GET /v1/chats/{chat_id}`: a direct chat's `participants` entry carries the `contact_id`. Only `message.received` names the sender directly. ### Failures A `message.failed` event has `failed_at` and a `failure` object: ```json theme={null} { "status": "failed", "failed_at": "2026-10-14T17:01:05.000Z", "failure": { "code": "recipient_unavailable", "reason": "The recipient was unavailable on the requested messaging service." } } ``` | `failure.code` | Meaning | | - | - | | `recipient_unavailable` | The recipient can't be reached on the service you asked for with `preferred_service`. | | `unsupported_content` | The message has content the chosen service can't send, such as a file type SMS can't carry (for example a PDF), media over the SMS/MMS size limit, or an effect or text style. `failure_code` says which, when it is a media problem. | | `reply_requires_imessage` | A threaded reply could only have gone out as SMS or RCS, so it wasn't sent. | | `service_mismatch` | The message couldn't be sent with the service you required. | | `sender_unavailable` | Your phone number couldn't send at the time. | | `sender_retired` | Your phone number was replaced before the message went out. | | `network_rejected` | The messaging network rejected the message. | | `dispatch_unavailable` | Blujay couldn't start sending the message. | | `delivery_failed` | The message couldn't be delivered, for any other reason. | Media problems also set `failure_code` to [`ATTACHMENT_FETCH_FAILED`](/errors/attachment-fetch-failed), [`ATTACHMENT_TOO_LARGE`](/errors/attachment-too-large) or [`UNSUPPORTED_ATTACHMENT_TYPE`](/errors/unsupported-attachment-type). The same `failure` object, `failed_at` and `failure_code` are on the message itself (`GET /v1/messages/{message_id}` and the lists), together with `retryable`. See [Track a message](/messaging/lifecycle). ## message.received Sent when someone messages one of your numbers. A direct message has `contact_id`: ```json theme={null} { "message_id": "msg_def456", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "contact_id": "contact_abc123", "text": "Here is the photo.", "status": "received", "direction": "inbound", "requested_service": null, "service": "imessage", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [ { "type": "text", "text": "Here is the photo." }, { "type": "media", "attachment_id": "att_abc123", "filename": "photo.jpg", "content_type": "image/jpeg", "size_bytes": 428731, "download_url": "/v1/attachments/att_abc123/content" } ], "reply_to": null } ``` `text` is the first text part, so you can read a plain reply without walking `parts`. It is absent when the message has no text. A message in a group chat has a `sender` instead of `contact_id`. `sender.contact_id` is present only when the sender is a contact in your workspace; a participant you never saved has none: ```json theme={null} { "chat_id": "chat_grp789", "sender": { "type": "phone", "address": "+15555550125" } } ``` A media part can arrive with only `type` and `attachment_id` while the file is still being processed. The event is a snapshot and never changes, so read the message or the attachment again later for its details. See [Media](/messaging/media#media-in-received-messages). ### What you don't get inbound `message.received` is built from the text and files Blujay stored for the message. Anything else is either reported another way or not at all: * **Tapbacks are reactions, not messages.** A reaction arrives only as [`reaction.added` or `reaction.removed`](#reactions), with the message it applies to. It never arrives as `message.received`, and it isn't listed by `GET /v1/messages`. * **Content with no part type.** A message Blujay stores with no text and no file, such as an iMessage app payload (Apple Pay, a poll, a shared location), arrives as `message.received` with `"parts": []` and no `text`. You can't tell from the event what kind of content it was. * **Stickers, voice memos and contact cards** arrive as `media` parts: a sticker sent as an image is image media, a voice memo is audio media, and a shared contact card is a `text/vcard` media part. * **Effects and text styles** on inbound messages aren't reported. * **The service** can be `null` on an inbound message when Blujay hasn't confirmed it. For the full inbound walkthrough, see [Receive messages](/messaging/receive-messages). ## message.edited and message.unsent Sent when an iMessage changes after it was sent, whether you changed it through Blujay or the contact changed theirs. A contact's edits and unsends are tracked for messages up to a week old. Both events carry the same message fields as a lifecycle event, plus the edit or unsend details. * `message.edited` carries the new `text` and `parts`, plus `edited_at` and `edit_count`. Read earlier versions with `GET /v1/messages/{message_id}?include=edit_history`. * `message.unsent` carries `unsent_at` and `unsent_scope` but never the text. With `unsent_scope: "all"` the whole message was removed. With `"text"` only its caption was removed and its attachments remain. ```json message.edited theme={null} { "message_id": "msg_abc123", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "status": "delivered", "direction": "outbound", "requested_service": null, "service": "imessage", "sent_at": "2026-10-14T17:01:02.000Z", "delivered_at": "2026-10-14T17:01:03.000Z", "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [{ "type": "text", "text": "Hi Ava, your order 1042 ships tomorrow." }], "reply_to": null, "text": "Hi Ava, your order 1042 ships tomorrow.", "edited_at": "2026-10-14T17:04:59.000Z", "edit_count": 1 } ``` A contact's edited or unsent message carries `contact_id` in a direct chat or `sender` in a group, as on `message.received`. See [Edit and unsend](/messaging/edit-and-unsend). > [!NOTE] > > When a message is unsent, delete its text on your side too. Earlier events, such as `message.received`, keep the text they carried, including if they are delivered again. ## Reactions `reaction.added` and `reaction.removed` report the standard reactions: `love`, `like`, `dislike`, `laugh`, `emphasize` and `question`. They can arrive about a minute after the reaction. ```json theme={null} { "message_id": "msg_abc123", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "reaction": { "type": "like", "part_index": 0 }, "reactor": { "type": "phone", "address": "+15555550123" }, "occurred_at": "2026-10-14T17:06:00.000Z", "observed_at": "2026-10-14T17:06:00.100Z" } ``` `message_id` is the message that was reacted to. `occurred_at` is when the person reacted; `observed_at` is when Blujay saw it. When the reaction comes from one of your own numbers, `reactor` is `{"type": "phone_number", "phone_number_id": "phone_abc123"}`. Each event is one add or one removal; it doesn't describe the full set of reactions on the message. A reaction from the contact counts as a response under the [sending rules](/messaging/sending-rules#what-counts-as-a-response). ## Typing `chat.typing_indicator.started` and `chat.typing_indicator.stopped` report a contact typing in an iMessage chat: ```json theme={null} { "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "occurred_at": "2026-10-14T17:07:00.000Z", "expires_at": "2026-10-14T17:08:00.000Z" } ``` In a group chat, the event adds `participants`: everyone typing in the group at that moment. `started` lists at least one person; `stopped` has an empty list. Replace your whole list of typers with each newer event. Group typing is sent only on webhook version `2026-10-03`, and only for your Dedicated numbers (`access_type: "dedicated"`); earlier versions and other numbers never receive it. See [Typing and read receipts](/messaging/typing-and-read-receipts). To show typing correctly: * ignore events after their `expires_at`, and clear the indicator at `expires_at` even if no `stopped` arrives; * per chat, ignore an event older than the last one you applied, and let `stopped` win when timestamps are equal. ## Group changes `participant.added`, `participant.removed` and `chat.group_name_updated` report changes to iMessage group chats your numbers are in. ```json theme={null} { "chat_id": "chat_grp789", "phone_number_id": "phone_abc123", "occurred_at": "2026-10-14T17:08:00.000Z", "participant": { "type": "phone", "handle": "+15555550125", "address": "+15555550125", "status": "active", "joined_at": "2026-10-14T17:08:00.050Z", "left_at": null } } ``` `chat.group_name_updated` carries `display_name` instead of `participant`. For the current state of a group, read `GET /v1/chats/{chat_id}` and `GET /v1/chats/{chat_id}/participants`. There is no event when an add or rename fails; read the group to confirm. If two of your numbers are in the same group, each number reports the change: you get one event per number, each with its own `phone_number_id` and `chat_id`. To receive one, limit the subscription to one number with `phone_number_ids`. ## Contacts `contact.tag_added` carries the full contact and the `tag`. `contact.assigned` carries the contact, its new `assigned_team_member` (`null` when unassigned) and `assignment_changed: true`. Contact events always cover the whole workspace. ```json theme={null} { "contact": { "contact_id": "contact_abc123", "phone_number": "+15555550123", "email": "ava@example.com", "name": "Ava Chen", "tags": ["VIP"], "stage": "Active", "assigned_team_member": null, "opted_out": false, "messaging_allowed": true, "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:09:00.000Z" }, "tag": "VIP" } ``` ## Phone numbers `phone_number.status_updated` is sent when one of your numbers is replaced by a new one. You get one event for the old number and one for the new number: ```json theme={null} { "phone_number_id": "phone_abc123", "status": "unavailable", "send_status": { "allowed": false, "reason_code": "sender_retired" } } ``` ```json theme={null} { "phone_number_id": "phone_ghi789", "status": "active", "send_status": { "allowed": true } } ``` Switch your sends to the new number's `phone_number_id`. No event is sent when a number is suspended or its health changes; read `GET /v1/phone-numbers` for that. ## Timestamps | Field | On | Meaning | | - | - | - | | `created_at` (envelope) | Every event | When Blujay recorded the event. Use it with the fields below to order events. | | `sent_at`, `delivered_at`, `read_at`, `failed_at` | Message events | When the message reached each state. `null` until it does. | | `occurred_at` | Reactions, typing, group changes | When it happened on the device, as reported to Blujay. | | `observed_at` | Reactions | When Blujay saw the reaction. Can be about a minute after `occurred_at`. | | `expires_at` | Typing | When to clear the indicator if no `stopped` arrives. 60 seconds after `occurred_at`. | | `edited_at`, `unsent_at` | Edits and unsends | When the change happened. Keep the newest per `message_id`. | ## Limitations * No event for opt-outs, contact creation, updates or deletion, chat creation, or a failed group rename or icon change. Those types return `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled) on subscribe. * Delivery events don't name the contact. Keep the `chat_id` to `contact_id` mapping yourself or read the chat. * A `message.received` snapshot never updates. Media details that weren't ready are only available by reading the attachment again. * Inbound effects, text styles, and the kind of an iMessage app payload aren't reported. * Group typing needs version `2026-10-03` and a Dedicated number. ## Next steps > **[Versions](/webhooks/versions)** > > What each payload version adds. > **[Track a message](/messaging/lifecycle)** > > Statuses and how to track a message. > **[Receive messages](/messaging/receive-messages)** > > Handle replies, media, groups and reactions. > **[Delivery and retries](/webhooks/delivery)** > > What happens when your endpoint is down. # Webhooks Source: https://docs.blujay.io/webhooks/overview Get replies, delivery updates and other events pushed to your server as they happen. Webhooks tell your server when something happens: a contact replies, a message is delivered or read, a message fails, someone reacts, or a contact is tagged. Blujay sends a signed `POST` with a JSON body to your HTTPS endpoint. ## Set up an endpoint ### 1. Build a receiver Your endpoint must: 1. accept `POST` requests over public HTTPS; 2. [verify the signature](/webhooks/verify-signatures) using the raw request body; 3. store the event (deduplicating by `event_id`) and return any `2xx` within 10 seconds; 4. do slow work afterwards, from your own queue. ### 2. Create a subscription ```bash theme={null} curl https://api.blujay.io/v1/webhook-subscriptions \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.example.com/webhooks/blujay", "event_types": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"] }' ``` ```json Response (201) theme={null} { "status": "success", "data": { "subscription_id": "whsub_abc123", "url": "https://yourapp.example.com/webhooks/blujay", "event_types": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"], "webhook_version": "2026-10-03", "status": "active", "phone_number_filter": { "mode": "all" }, "signing_secret_hint": "Y3OA==", "signing_secret": "whsec_ZXhhbXBsZS1zZWNyZXQtZG8tbm90LXVzZS0xMjM0NTY3OA==", "created_at": "2026-10-14T17:00:00.000Z", "updated_at": "2026-10-14T17:00:00.000Z" }, "trace_id": "trc_7e1c4b2a9f03", "timestamp": "2026-10-14T17:00:00.000Z" } ``` > [!WARNING] > > `signing_secret` is shown **only once**, in this response. Save it in your secret manager now. Later reads return only `signing_secret_hint`. A subscription you create with the API starts `active` unless you send `"status": "disabled"`; a subscription you create in [Blujay](https://app.blujay.io) under **API** → **Webhooks** starts switched off until you turn on delivery there. Blujay records an event only for subscriptions that are active when it happens, so nothing from a disabled period is stored, and nothing from it can be replayed later. ### 3. Send a test message There is no test event. Send a real message to your own phone with `POST /v1/chats` and reply to it. With the event list above you receive `message.sent`, then `message.delivered` on iMessage, then `message.received` when you reply. ## The event payload Every event has the same envelope. Event-specific fields are in `data`: ```json theme={null} { "api_version": "v1", "webhook_version": "2026-10-03", "event_type": "message.received", "event_id": "evt_abc123", "created_at": "2026-10-14T17:05:00.120Z", "trace_id": "trc_0f4e8a2b6c1d", "workspace_id": "workspace_abc123", "subscription_id": "whsub_abc123", "data": { "message_id": "msg_def456", "chat_id": "chat_abc123", "phone_number_id": "phone_abc123", "contact_id": "contact_abc123", "text": "Sounds good, see you then!", "status": "received", "direction": "inbound", "requested_service": null, "service": "imessage", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null, "failure": null, "degradations": [], "parts": [{ "type": "text", "text": "Sounds good, see you then!" }], "reply_to": null } } ``` | Field | Meaning | | - | - | | `event_id` | Unique ID for this event. Same value as the `webhook-id` header. Use it to deduplicate. | | `event_type` | What happened. See [Events](/webhooks/events). | | `webhook_version` | The payload version this subscription is pinned to. See [Versions](/webhooks/versions). | | `created_at` | When Blujay recorded the event. Message events also carry `sent_at`, `delivered_at`, `read_at` and `failed_at`; reaction, typing and group events carry `occurred_at`. See [Timestamps](/webhooks/events#timestamps). | | `trace_id` | Quote it to [support](/support) with the `event_id`. | | `data` | The event's details. | ## Handle events correctly * **Deduplicate by `event_id`.** Delivery is at least once, so you may receive the same event more than once. * **Expect any order.** Events can arrive out of order. Compare timestamps (`sent_at`, `delivered_at`, `occurred_at`) and never overwrite newer state with an older event. * **Reconcile when in doubt.** The event body is a snapshot from when it happened. For the current state, read the resource, for example `GET /v1/messages/{message_id}`. * **Fetch media with your key.** Media `download_url` values are relative API paths, such as `/v1/attachments/att_abc123/content`. Call them on `https://api.blujay.io` with your API key. Never send your key to a URL from any other host. For a walkthrough of what arrives when a contact texts you, see [Receive messages](/messaging/receive-messages). ## Select phone numbers By default a subscription covers all your phone numbers, including ones you add later. To limit it to some numbers, set `phone_number_filter`: ```json theme={null} { "phone_number_filter": { "mode": "selected", "phone_number_ids": ["phone_abc123", "phone_def456"] } } ``` Use 1 to 100 IDs from `GET /v1/phone-numbers`. A selection stays fixed when you add numbers. Contact events (`contact.tag_added`, `contact.assigned`) aren't tied to a number and always cover the whole workspace. ## Manage subscriptions | Task | Request | | - | - | | List subscriptions | `GET /v1/webhook-subscriptions` | | Get one subscription | `GET /v1/webhook-subscriptions/{subscription_id}` | | Change URL, events, numbers or version | `PATCH /v1/webhook-subscriptions/{subscription_id}` | | Pause delivery | `PATCH` with `{"status": "disabled"}`; resume with `{"status": "active"}` | | Delete | `DELETE /v1/webhook-subscriptions/{subscription_id}` | | See delivery attempts | `GET /v1/webhook-subscriptions/{subscription_id}/deliveries` | While a subscription is disabled, Blujay doesn't record events for it. When you resume it, you receive only new events; the ones you missed are not delivered and can't be replayed. A lost signing secret can't be recovered: create a new subscription, switch your receiver to its secret, then delete the old one. Creating a subscription doesn't use an `Idempotency-Key`. If a create request times out, list your subscriptions before trying again, so you don't end up with two. ## Where Blujay won't deliver Blujay checks the URL when you create or update a subscription, and resolves the hostname again before every delivery attempt. * **Only `https://`.** An `http://` URL, or one with a username, password or `#fragment`, returns `400` [`WEBHOOK_URL_INVALID`](/errors/webhook-url-invalid). A URL over 2048 characters fails validation with `400` [`VALIDATION_ERROR`](/errors/validation-error). * **Only public addresses.** `localhost`, Docker and cloud metadata hostnames, and any address in a loopback, link-local, private (`10/8`, `172.16/12`, `192.168/16`), carrier-grade NAT, multicast, documentation or reserved range, in IPv4 or IPv6, return `400` [`WEBHOOK_URL_UNSAFE`](/errors/webhook-url-unsafe). A hostname that later starts resolving to such an address fails the attempt with the same code. For local development, use a tunnel that gives you a public HTTPS URL. * **The hostname must resolve.** Otherwise creating the subscription returns `400` [`WEBHOOK_URL_UNRESOLVABLE`](/errors/webhook-url-unresolvable). A DNS failure during delivery is retried. * **Redirects.** Blujay follows up to 3 redirects per attempt, sending the same `POST` with the body and signature headers to each address. Every address must pass the same checks. See [Delivery and retries](/webhooks/delivery#redirects). * **A valid certificate.** Blujay verifies your TLS certificate the way a browser does. A self-signed, expired or mismatched certificate fails the attempt with `WEBHOOK_DELIVERY_NETWORK_ERROR`, and the attempt is retried until you fix it. Your endpoint must respond within 10 seconds with any `2xx` status. The response body is ignored. ## What you don't get * **No test event.** Send a real message to yourself instead. * **No source IP range.** Blujay doesn't publish the addresses it sends from, so you can't allowlist by IP. Trust the [signature](/webhooks/verify-signatures) instead. * **No events for opt-outs, contact creation, updates or deletion, or chat creation.** Those event types exist in the API schema but are reserved: subscribing to them returns `400` [`FEATURE_NOT_ENABLED`](/errors/feature-not-enabled). See [Reserved event types](/webhooks/events#reserved-event-types). To mirror opt-outs, poll `opted_out` on the contact ([Opt-outs](/opt-outs)). * **No `contact_id` on delivery events.** `message.sent`, `message.delivered`, `message.read` and `message.failed` identify the chat, not the person. See [Message lifecycle](/webhooks/events#message-lifecycle). * **No events from a disabled period**, and no replay of them. * **No secret rotation in place.** To change the secret, create a new subscription and delete the old one. ## Common mistakes > [!WARNING] > > * Answer within 10 seconds. Store the event and return `2xx`; do the real work from your own queue. > * Verify the signature with the raw request body, byte for byte. A re-serialized body never matches. > * Deduplicate by `event_id`. The same event can arrive more than once. > * Never overwrite newer state with an older event. Compare `sent_at`, `delivered_at` and `occurred_at` before you update a record. > * List subscriptions before you retry a create that timed out. Create is not idempotent. ## Next steps > **[Verify signatures](/webhooks/verify-signatures)** > > Confirm every request really came from Blujay. > **[Events](/webhooks/events)** > > Every event type and its payload. > **[Delivery and retries](/webhooks/delivery)** > > What happens when your endpoint is down. > **[Receive messages](/messaging/receive-messages)** > > What arrives when a contact texts you. # Verify signatures Source: https://docs.blujay.io/webhooks/verify-signatures Confirm that every webhook request came from Blujay and wasn't changed or replayed. Blujay signs every webhook with your subscription's signing secret, following the [Standard Webhooks](https://www.standardwebhooks.com) specification. Verify the signature before you trust a request. ## The headers | Header | Example | Meaning | | - | - | - | | `webhook-id` | `evt_abc123` | The event ID. Same as `event_id` in the body. | | `webhook-timestamp` | `1792000000` | Unix time in seconds when this attempt was signed. | | `webhook-signature` | `v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=` | One or more space-separated signatures. | ## How the signature is made 1. Take the secret after the `whsec_` prefix and base64-decode it. Those bytes are the HMAC key. 2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`. 3. Compute HMAC-SHA256 of that content and base64-encode it. 4. The header value is `v1,` followed by that string. To verify, compute the same value and compare it with each `v1,` entry in the header using a constant-time comparison. Also reject a `webhook-timestamp` more than five minutes away from your clock, so an old request can't be replayed. ## The four details that must be exact | Detail | Get it right | | - | - | | **Raw body bytes** | Sign exactly the bytes Blujay sent. Read the body before any JSON middleware parses it. Parsing and re-serializing changes whitespace and key order, and the signature stops matching. | | **Constant-time compare** | Compare the computed and given signatures with a constant-time function (`crypto.timingSafeEqual`, `hmac.compare_digest`). A plain `==` leaks timing information. | | **Timestamp tolerance** | Reject a `webhook-timestamp` more than 5 minutes from your clock, in either direction. Keep your server on NTP. | | **The key** | Strip `whsec_`, then base64-decode what's left. The decoded bytes are the HMAC key, not the text of the secret. | > [!WARNING] > > Use the **raw** request body, byte for byte. Parsing the JSON and serializing it again changes the bytes and breaks the signature. ## Code ```javascript Node.js (Express) theme={null} import crypto from "node:crypto"; import express from "express"; const app = express(); const secret = Buffer.from(process.env.BLUJAY_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64"); const TOLERANCE_SECONDS = 5 * 60; function verify(headers, rawBody) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) return false; const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${id}.${timestamp}.${rawBody}`) .digest(); return signatures.split(" ").some((entry) => { const [version, value] = entry.split(","); if (version !== "v1" || !value) return false; const given = Buffer.from(value, "base64"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } app.post("/webhooks/blujay", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); if (!verify(req.headers, rawBody)) return res.status(400).send("invalid signature"); const event = JSON.parse(rawBody); // Store the event, deduplicating by event.event_id, then return quickly. res.status(200).end(); }); app.listen(3000); ``` ```python Python (Flask) theme={null} import base64 import hashlib import hmac import os import time from flask import Flask, request app = Flask(__name__) SECRET = base64.b64decode(os.environ["BLUJAY_WEBHOOK_SECRET"].removeprefix("whsec_")) TOLERANCE_SECONDS = 5 * 60 def verify(headers, raw_body: bytes) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: return False try: if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False except ValueError: return False signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode() for entry in signatures.split(" "): version, _, value = entry.partition(",") if version == "v1" and hmac.compare_digest(value, expected): return True return False @app.post("/webhooks/blujay") def blujay_webhook(): raw_body = request.get_data() if not verify(request.headers, raw_body): return "invalid signature", 400 event = request.get_json() # Store the event, deduplicating by event["event_id"], then return quickly. return "", 200 ``` ## Use a library Because Blujay follows the Standard Webhooks specification, the Standard Webhooks libraries verify Blujay signatures unchanged. For example, with the `standardwebhooks` package for Node.js: ```javascript theme={null} import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.BLUJAY_WEBHOOK_SECRET); const event = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"], "webhook-timestamp": req.headers["webhook-timestamp"], "webhook-signature": req.headers["webhook-signature"], }); // throws if the signature or timestamp is invalid ``` Standard Webhooks libraries exist for many languages, including Python, Go, Ruby, PHP, Java and C#. See [standardwebhooks.com](https://www.standardwebhooks.com). ## Troubleshooting | Symptom | Likely cause | Fix | | - | - | - | | Every signature fails | Your framework parsed the body before you read it. | Read the raw body (`express.raw`, `request.get_data()`) and sign those bytes. | | Every signature fails | You used the secret text as the key. | Strip `whsec_` and base64-decode the rest; use the bytes. | | Every signature fails | Wrong secret: another subscription's, or one copied with a missing character. | Compare the end of your secret with `signing_secret_hint` on `GET /v1/webhook-subscriptions/{subscription_id}`. If it doesn't match, create a new subscription. | | Signatures fail only sometimes | Your server clock drifts past the 5-minute tolerance. | Use NTP. Log the difference between `webhook-timestamp` and your clock. | | Signatures fail only under load | Your endpoint answered after 10 seconds, so Blujay retried with a new timestamp while you were still checking the old attempt. | Return `2xx` first, then do the work. | | A redelivered event has a different signature | Expected. Every attempt is signed again with a new `webhook-timestamp`. | Nothing to fix. `webhook-id` and the body stay the same. | ## Limitations * One signing scheme: symmetric HMAC-SHA256 as `v1`. There is no public-key signature. * One secret per subscription, shown once. There is no endpoint to rotate it in place; create a new subscription to change it. * No source IP range is published, so the signature is your only proof of origin. ## Next steps > **[Events](/webhooks/events)** > > Every event type and its payload. > **[Delivery and retries](/webhooks/delivery)** > > What happens when your endpoint is down. # Versions Source: https://docs.blujay.io/webhooks/versions Webhook payloads are versioned by date, so new fields never surprise an existing receiver. Each subscription is pinned to a payload version with `webhook_version`. Blujay renders every event for that subscription in that version's shape, so your receiver keeps working when new versions ship. New subscriptions use the latest version unless you choose another. ## Versions | Version | Adds | | - | - | | `2026-10-03` (latest, default) | `reply_to` on every message event, and typing events for iMessage group chats with a `participants` list. Group typing is sent only for your Dedicated numbers (`access_type: "dedicated"`); earlier versions never receive it. Otherwise the same as `2026-09-02`. | | `2026-09-02` | `parts` on message events, including media with `attachment_id` and download details, and the media `failure_code` values on failed messages (`SENDER_RETIRED` is reported on every version). | | `2026-08-20` | `requested_service` reports the service you asked for, and `service` reports only the service that was confirmed to carry the message. | | `2026-08-01` | Full message details: `direction`, services, `sent_at`, `delivered_at`, `read_at`, `failed_at`, `failure` and `degradations`. | | `2026-06-14` | The original minimal message payload. | ### The 2026-06-14 shape On `2026-06-14`, a message event's `data` has four fields: `message_id`, `chat_id`, `phone_number_id` and `status`. A `message.failed` event adds `failure_code` only when your number was retired (`SENDER_RETIRED`); other failures have no reason. A `message.received` event adds `contact_id` (or `sender` in a group), `text`, and a `parts` list that holds the text only, never media. There is no `direction`, no service, no timestamps and no `failure` object. Reaction, typing, group, contact and phone number events have the same shape on every version. If you inherit a subscription on this version, read the message with `GET /v1/messages/{message_id}` for everything the event leaves out, or upgrade it. Use the latest version for new integrations. Older versions remain supported for existing receivers. There is no sunset policy yet: Blujay will announce one, with notice, before retiring any version. ## Upgrade a subscription 1. Update your receiver to accept the new fields, and keep it accepting the old shape for a while: events created before the change keep their original shape, including when they are delivered again. 2. Change the version: ```bash theme={null} curl -X PATCH https://api.blujay.io/v1/webhook-subscriptions/whsub_abc123 \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{"webhook_version": "2026-10-03"}' ``` 3. New events use the new version from then on. Every event says which version it uses in its `webhook_version` field, so your receiver can handle both shapes during the switch. A version string outside the table is rejected with `400` [`VALIDATION_ERROR`](/errors/validation-error). ## Limitations * A version applies to the whole subscription. You can't pick a version per event type. * An event keeps the version it was created with. Changing the version never re-renders events that already exist, including on replay. * Group typing needs `2026-10-03` and a Dedicated number. No version adds group typing for Free Plan numbers; see [Plans](/plans). ## Next steps > **[Events](/webhooks/events)** > > Every event type and its payload. > **[Making requests](/making-requests#versioning)** > > What Blujay can and can't change within v1. # Workflows Source: https://docs.blujay.io/workflows List the manual workflows in your workspace and enroll contacts in them, with safe retries. Workflows are automated message sequences you build in Blujay. A workflow that starts manually can be started for a contact from the API, so an event in your own system, such as a signup or a missed appointment, can put someone into a Blujay sequence. You find the workflows with `GET /v1/workflows` and enroll a contact with `POST /v1/workflows/{workflow_id}/enrollments`. ## List workflows `GET /v1/workflows` returns the active, published workflows in your workspace that start manually, oldest first, up to 100. Each has a `workflow_id` and a `label`. Workflows that start another way, such as when a tag is added, aren't listed. The list isn't paginated. ```bash curl theme={null} curl https://api.blujay.io/v1/workflows \ -H "Authorization: Bearer blu_your_api_key" ``` ```json Response (200) theme={null} { "status": "success", "data": { "workflows": [ { "workflow_id": "workflow_abc123", "label": "New customer welcome" }, { "workflow_id": "workflow_def456", "label": "Appointment reminder" } ] }, "trace_id": "trc_8b0d2f4a6c1e", "timestamp": "2026-10-14T17:50:00.000Z" } ``` Workflow steps and run history aren't returned. Build and edit workflows in the Blujay app. ## Enroll a contact Send the contact's ID and a `request_id` that identifies this enrollment in your system. ```bash curl theme={null} curl https://api.blujay.io/v1/workflows/workflow_abc123/enrollments \ -H "Authorization: Bearer blu_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "contact_abc123", "request_id": "signup-A1042", "metadata": { "source": "signup_form", "external_id": "A-1042" } }' ``` ```json Response (201) theme={null} { "status": "success", "data": { "workflow_id": "workflow_abc123", "contact_id": "contact_abc123", "request_id": "signup-A1042", "enrollment_status": "run_created", "workflow_run": { "status": "active" } }, "trace_id": "trc_2c4e6a8b0d1f", "timestamp": "2026-10-14T17:51:00.000Z" } ``` A `201` means the contact was enrolled and a workflow run started. The workflow's first step runs from Blujay's queue after the response; the messages it sends show up as `message.sent` and later events on your webhooks, with no link back to the enrollment. ### Response fields | Field | Values | Meaning | | - | - | - | | `enrollment_status` | `run_created`, `no_op` | Whether this request started a run. | | `no_op_reason` | `contact_opted_out`, `duplicate_non_terminal_run` | Present when `enrollment_status` is `no_op`. See below. | | `workflow_run.status` | `active`, `waiting`, `paused`, `blocked`, `completed`, `failed`, `exited` | The run's state at the time of the response. A new run is `active`. For `duplicate_non_terminal_run` it is the existing run's state: `active`, `waiting`, `paused` or `blocked`. `workflow_run` is `null` when no run exists. | | `workflow_run.status_reason_code` | string | Present when the run's state has a reason, for example `phone_number_unavailable`, `sender_retired`, `sender_suspended`, `workflow_interrupted`, `unsupported_workflow_step` or `internal_error`. Other reasons pass through from the workflow engine as written. | ## Retry safely with request\_id `request_id` is required (1 to 200 characters) and is how this endpoint avoids double enrollments. It doesn't use the `Idempotency-Key` header. * Sending the same `request_id` for the same workflow and contact again returns `200` with the original result. The contact isn't enrolled twice. * The original result wins: a repeat with different `metadata` doesn't change the enrollment. * The same `request_id` for a different contact is a separate enrollment. * A request that was rejected with an error isn't remembered. After you fix the problem, send it again with the same `request_id`. Use an ID that is stable for the event you're reacting to, such as your signup ID or order ID, so a retry after a timeout reuses it. Use a new ID when you want to enroll the same contact again later. See [Idempotency](/idempotency) for how other endpoints handle retries. ## Understand enrollments that don't start a run A `200` can also mean Blujay accepted the enrollment but didn't start a new run. Check `enrollment_status` and `no_op_reason`: | `no_op_reason` | Meaning | `workflow_run` | | - | - | - | | `contact_opted_out` | The contact has opted out of messages, on the contact itself or by an opt-out of their phone number or email. See [Opt-outs](/opt-outs). | `null` | | `duplicate_non_terminal_run` | The contact is already in a run of this workflow that hasn't finished. | The existing run | ```json Response (200) theme={null} { "status": "success", "data": { "workflow_id": "workflow_abc123", "contact_id": "contact_abc123", "request_id": "signup-A1043", "enrollment_status": "no_op", "no_op_reason": "contact_opted_out", "workflow_run": null }, "trace_id": "trc_4e6a8c0b2d1f", "timestamp": "2026-10-14T17:52:00.000Z" } ``` ## Add metadata `metadata` is optional context about the enrollment, such as where it came from. Blujay stores it on the enrollment record so the enrollment can be traced back to your system when you contact [support](/support). Workflow steps can't read it, it doesn't change what the workflow sends, and no endpoint or webhook returns it. * Up to 20 keys, each 1 to 64 characters. * Values are strings (up to 500 characters), numbers, booleans or `null`. * 4,096 bytes in total, measured as JSON. Metadata over these limits returns `400` [`VALIDATION_ERROR`](/errors/validation-error). ## Handle errors | Error | Status | What to do | | - | - | - | | [`NOT_FOUND`](/errors/not-found) | 404 | The workflow or contact doesn't exist in your workspace. | | [`WORKFLOW_LIFECYCLE_ERROR`](/errors/workflow-lifecycle-error) | 409 | The workflow can't accept enrollments right now: it isn't active, or it doesn't start manually. Not retryable. Check it in Blujay, or pick another workflow from `GET /v1/workflows`, then send the enrollment again. The same `request_id` is fine. | | [`WORKFLOW_VALIDATION_ERROR`](/errors/workflow-validation-error) | 422 | The workflow isn't set up to run, for example it has no published version or its phone number isn't available. Fix the workflow in Blujay. | | [`VALIDATION_ERROR`](/errors/validation-error) | 400 | The request body is invalid, for example `request_id` is missing or `metadata` is too large. | ## Limitations * Enrollment is fire-and-forget. There is no endpoint to list a contact's runs, read a run's progress, cancel a run, or remove a contact from a workflow, and no webhook when a run starts, finishes or fails. Manage runs in the Blujay app. * Only workflows that start manually can be enrolled through the API. Tag-triggered workflows start when you [add the tag](/contacts#add-a-tag). * One run per contact per workflow at a time: a second enrollment returns `no_op` with `duplicate_non_terminal_run` until the first run ends. * An opted-out contact is never enrolled (`no_op`, `contact_opted_out`). * The messages a workflow sends follow the same [sending rules](/messaging/sending-rules) and plan limits as API sends. A run on a Free Plan or AI Agent number can only message people who texted that number first; see [Plans](/plans). * `metadata` is write-only. ## Next steps > **[Contacts](/contacts)** > > Create or find the contact to enroll. > **[Opt-outs](/opt-outs)** > > Understand why an opted-out contact isn't enrolled. > **[Idempotency](/idempotency)** > > See how retries work across the API.