Skip to main content
HTTP 403. The key may not do this.

What it means

The key is valid but this action is not allowed for it.

Why it happens

  • A test key (fk_test_) used a dedicated sender, or a live key (fk_live_) used a shared sandbox sender.
  • On the sandbox, the contact has not joined your app (or has since joined another app by sending its join code).
  • The sender is pending, flagged or banned. A Telegram bot is flagged when Telegram rejected its token (revoked in @BotFather), and banned once disconnected or connected to another app.
  • A dedicated sender was requested or disconnected with a test key, or the Telegram bot you connected is one of Flow’s sandbox senders.
  • On iMessage, you started a new conversation from a line that may only reply (imessage.can_initiate is off). The contact must message the line first. Writing first is available on request for dedicated lines.
  • The app’s sandbox allowance is used up (channel_code sandbox_allowance_used). Apps made with POST /v0/sandbox/keys may send 50 messages in total to 1 contact; people who signed in, 100 messages to each of 3 contacts, one allowance per person shared by every app they own or claim. Only messages your agent sends count.
  • The allowance has no room for this contact (sandbox_contact_limit), or the send used a sandbox channel the allowance does not cover, such as iMessage (sandbox_channel_not_included; Flow’s shared iMessage line comes with the account’s free trial, which your first sign-in at flow.engineer or npx @flow-engineer/flow setup starts, and dedicated lines are arranged with the Flow team).
  • On Flow’s shared iMessage line, the account has no access to it (imessage_not_granted): it has not started the free trial, or the Flow team turned the line off for it. Sign in at flow.engineer to start the trial, or send from another sender.
  • The person is held by the account’s paid plan (plan_people_limit): the plan had no room for new people on that channel when they wrote. See Pricing.
  • An app made without an account tried something that needs a person signed in, such as connecting its own Telegram bot, or uploading more than 20 files in a day (sign_in_required).
  • SMS (US, in pilot): the contact opted out by texting STOP or a variant (contact_opted_out; their contact shows opted_out_at). Messages already queued to them fail with the same code in message.failed.
  • SMS: you started a conversation with someone who has never texted any of your SMS numbers, without recording their consent (consent_required). A simulated SMS contact (POST /v0/test/conversations) works the same way: before its first inbound turn, a send to it needs consent. After a simulated STOP turn, sends to it answer contact_opted_out until a START turn.
  • Your account handles health information (hipaa on the account in GET /v0/app) and the send or sender is on a channel without a business associate agreement: Telegram, WhatsApp or iMessage (hipaa_channel_not_covered). The same goes for simulated conversations: such an account can simulate only SMS, so creating a simulated Telegram, WhatsApp or iMessage conversation, or posting a turn to one made before the flag was set, is refused too.
  • A live key called a simulated-contact endpoint under /v0/test/ (test_mode_only): simulated conversations exist only in test mode. Or a test key posted a simulated turn to, or read the transcript of, a conversation with a real contact (not_simulated). Or the app already has 500 simulated conversations, the most one app may have (simulated_conversation_limit). Or a send from a real (sandbox or dedicated) sender named a simulated contact (simulated_contact): simulated contacts exist only inside Flow.
  • In message.failed (sandbox_contact_left): the contact joined another app on the sandbox sender (by sending that app’s join code) after your message was queued, so it was not sent.

How to fix it

Use the key of the right mode. On the sandbox, have the contact join your app: on Telegram they open the sandbox sender’s link (its address.link) and tap Start, which joins them; if the link can’t be used, they send the bot its join_code as it is (for example Hi Sandbox app (ref wild-otter-04508705)). The MCP tool sandbox_join gives the link (and a QR code with include_qr: true). Then send again. If Telegram rejected your bot’s token, get a new token from @BotFather (/mybots, API Token) and connect the bot again with POST /v0/senders and your live key: the same sender becomes active, and its queued messages go out (those queued for more than 72 hours fail with outside_window, channel_code queued_too_long, instead of going out late). When the sandbox allowance is used up, or the app was made without an account, have a person sign in with a Flow account (flow.engineer) to claim the app: run npx @flow-engineer/messaging login (it uses the claim_token saved by init), or start the device flow yourself with POST /v0/device/authorizations and your claim_token, show the person the link, and poll POST /v0/device/token for the new key. Claiming keeps the app and moves it under the person’s signed-in allowance: 3 contacts and 100 messages each, one allowance per person shared by all their apps (a person may claim up to 10 apps). GET /v0/app shows what is left (allowance).
On SMS, a contact who opted out gets nothing more until they text START (or UNSTOP, YES) to a number they opted out through; do not ask them to by other means unless they want it. To start a conversation with someone who has not texted you, record the consent they gave you (for example on your sign-up form) with the start:
In a HIPAA account, send from an SMS sender instead; the other channels cannot carry its messages. To test, simulate an SMS conversation: POST /v0/test/conversations with {"channel": "sms"}. For test_mode_only, call /v0/test/... with your fk_test_ key. For not_simulated, create a simulated conversation first (POST /v0/test/conversations) and use its ID; a real contact can only be written to by the person. For simulated_contact, reply in the contact’s simulated conversation (POST /v0/conversations/{conversation_id}/basic with its conv_ ID). For sandbox_contact_left, have the contact send your app’s join code to the sandbox sender again, then send the message again. For simulated_conversation_limit, reuse the simulated conversations you have: keep their conv_ IDs, or post the same contact address again, which returns its conversation. On an iMessage line that may only reply, wait for the contact to message it, then reply in that conversation (POST /v0/conversations/{conversation_id}/basic).
Every error also carries hint, one sentence specific to your request, and doc_url, this page. Coding agents connected to Flow’s MCP server (https://api.flow.engineer/mcp) can call explain_error with the type to read this page.