> ## Documentation Index
> Fetch the complete documentation index at: https://flow.engineer/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# permission

> The key may not do this.

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](https://flow.engineer/docs/unified-messaging-api/pricing.md#past-a-limit).
* 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`).

```bash
curl -X POST https://api.flow.engineer/v0/device/authorizations \
  -H "Content-Type: application/json" -d '{"claim_token": "'"$FLOW_CLAIM_TOKEN"'"}'
# Show verification_uri_complete to the person, then poll every `interval` seconds:
curl -X POST https://api.flow.engineer/v0/device/token \
  -H "Content-Type: application/json" -d '{"device_code": "fdc_..."}'
```

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:

```bash
curl -X POST https://api.flow.engineer/v0/messages \
  -H "Authorization: Bearer $FLOW_MESSAGING_LIVE_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"sender": "snd_...", "to": {"phone": "+14155550100"},
       "kind": "basic", "text": "Hi Sam, this is Example Clinic. Reply STOP to opt out.",
       "consent": {"obtained_at": "2026-10-01T15:04:05Z", "method": "web_form", "proof": "signup-8812"}}'
```

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`).

```bash
# Your join code (app.sandbox_join_code) and the sandbox senders (address.link, join_code)
curl https://api.flow.engineer/v0/app -H "Authorization: Bearer $FLOW_MESSAGING_KEY"
curl https://api.flow.engineer/v0/senders -H "Authorization: Bearer $FLOW_MESSAGING_KEY"
```

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.
