Microsoft Teams Error Guide: 'BotNotInConversationRoster' — Fix Proactive Bot Messaging
Fix Microsoft Teams 'BotNotInConversationRoster' when sending proactive bot messages: install the app for the user or team, capture a real conversation reference, and reinstall after uninstall.
- #microsoft-teams
- #adaptive-cards
- #troubleshooting
- #errors
Stuck on this Microsoft Teams error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Overview
A Teams bot cannot start a conversation with a user or channel until the app is installed there. When you try to send a proactive message (create a conversation or continue a stored conversation reference) to someone who has not installed the bot, the Bot Framework connector returns a 403 with BotNotInConversationRoster:
HTTP/1.1 403 Forbidden
{
"error": {
"code": "BotNotInConversationRoster",
"message": "The bot is not part of the conversation roster."
}
}
The same underlying cause can surface as a Graph-side failure when you try to send an activity to a chat the app was never installed into:
HTTP/1.1 404 Not Found
{ "error": { "code": "NotFound", "message": "Conversation not found." } }
Symptoms
createConversation/continueConversation(proactive send) returns 403BotNotInConversationRoster.- Proactive messages work for users who chatted with the bot first, but fail for everyone else.
- Notifications worked yesterday and stopped for one user — they uninstalled the app.
- A team-scope proactive post fails while personal-scope works (or vice versa) because the app is installed in only one scope.
- Bulk notification jobs fail for a subset of recipients who never installed the app.
Common Root Causes
- App never installed for the target — proactive messaging requires the bot to already be installed for that user, chat, or team; there is no “cold DM” in Teams.
- App uninstalled — the user or admin removed the app, invalidating the stored conversation reference.
- Stale/incorrect conversation reference — a saved reference points at a conversation the bot is no longer part of, or was fabricated instead of captured from a real activity.
- Wrong scope — the app is installed in personal scope but you are messaging a channel (or vice versa); each scope is a separate roster.
- Tenant/service-URL mismatch — using a conversation reference or connector
serviceUrlfrom the wrong tenant/cloud. - Trusted-serviceUrl not set — the bot did not trust the connector
serviceUrlbefore calling it (older SDK setups).
Diagnostic Workflow
First, confirm whether the app is actually installed for the target. With Graph you can check a user’s installed apps (requires TeamsAppInstallation.ReadForUser.All / app-only equivalent):
curl -s "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps?\$expand=teamsApp" \
-H "Authorization: Bearer $GRAPH_TOKEN" | jq '.value[].teamsApp.externalId'
If your app’s externalId (the manifest app id) is absent, that is the root cause — install it before messaging.
The proactive install pattern with Graph: install the app for the user, then fetch the chat the install created, then send. Install first:
curl -i -X POST "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps" \
-H "Authorization: Bearer $GRAPH_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "teamsApp@odata.bind": "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/APP_CATALOG_ID" }'
Then retrieve the chat that installation created so you have a valid conversation to post into:
curl -s "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps/INSTALL_ID/chat" \
-H "Authorization: Bearer $GRAPH_TOKEN" | jq '.id'
Capture — never fabricate — the conversation reference on the Bot Framework side. The correct pattern is to store the reference from any incoming activity, including the installationUpdate event fired when your app is installed:
// Store on every activity; the installationUpdate "add" event lets you greet proactively.
class NotifyBot extends TeamsActivityHandler {
constructor(refs) {
super();
this.onInstallationUpdate(async (ctx, next) => {
const ref = TurnContext.getConversationReference(ctx.activity);
refs[ref.conversation.id] = ref; // persist this
await next();
});
}
}
// Later, send proactively using the STORED reference:
await adapter.continueConversationAsync(appId, storedRef, async (ctx) => {
await ctx.sendActivity(MessageFactory.attachment(cardAttachment));
});
If continueConversationAsync throws BotNotInConversationRoster, treat it as “install lost”: remove the stale reference and trigger a reinstall/re-onboarding rather than retrying the same reference.
Example Root Cause Analysis
An on-call notification bot proactively DMed engineers when they were paged. New hires reported never getting notified. The service logs showed 403 BotNotInConversationRoster for exactly those users.
The Graph check .../installedApps?$expand=teamsApp confirmed the app’s externalId was missing for every affected user — onboarding added them to PagerDuty but nobody had installed the Teams app for them, and the bot had no prior conversation from which to capture a reference. The team’s code assumed a conversation reference could be built from a user id alone; Teams does not allow that.
The fix added a provisioning step: on the installationUpdate “add” event (and via a Graph POST .../installedApps during onboarding automation), the bot installed itself for each new engineer, fetched the resulting chat, and stored the real conversation reference. Proactive sends then used the stored reference. New-hire notifications started arriving, and the team added handling to mark a reference stale and re-provision whenever BotNotInConversationRoster recurred (an uninstall).
Prevention Best Practices
- Install before you message — provision the app for each user/team (Graph
POST .../installedApps) as part of onboarding; never assume a cold proactive DM will work. - Capture, never fabricate, the conversation reference — store
TurnContext.getConversationReferencefrom real incoming activities, includinginstallationUpdate. - Handle uninstall gracefully — on
BotNotInConversationRoster, mark the reference stale, stop retrying it, and trigger re-onboarding. - Match the scope — install and message in the same scope (personal vs team vs group chat); each roster is separate.
- Persist references durably — keep conversation references in a database keyed by user/team so restarts and scale-out do not lose them.
- Use the installationUpdate event — send your welcome/onboarding card there, which guarantees the roster exists.
- Pin the correct serviceUrl/tenant — store the
serviceUrlfrom the activity and trust it; do not hardcode one across clouds/tenants.
Quick Command Reference
# Is the app installed for a user? (empty externalId list = not installed)
curl -s "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps?\$expand=teamsApp" \
-H "Authorization: Bearer $GRAPH_TOKEN" | jq '.value[].teamsApp.externalId'
# Install the app for a user (proactive-install pattern)
curl -i -X POST "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps" \
-H "Authorization: Bearer $GRAPH_TOKEN" -H "Content-Type: application/json" \
-d '{ "teamsApp@odata.bind": "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/APP_CATALOG_ID" }'
# Get the chat created by the installation (valid conversation to post into)
curl -s "https://graph.microsoft.com/v1.0/users/USER_ID/teamwork/installedApps/INSTALL_ID/chat" \
-H "Authorization: Bearer $GRAPH_TOKEN" | jq '.id'
# List a team's installed apps (team-scope proactive check)
curl -s "https://graph.microsoft.com/v1.0/teams/TEAM_ID/installedApps?\$expand=teamsApp" \
-H "Authorization: Bearer $GRAPH_TOKEN" | jq '.value[].teamsApp.displayName'
Conclusion
BotNotInConversationRoster is Teams telling you the bot has no relationship with the target: proactive messaging requires the app to be installed for that user, chat, or team first. Install the app during onboarding (or on the installationUpdate event), capture the real conversation reference from an incoming activity rather than fabricating one, and treat the 403 as a stale-install signal that should trigger re-provisioning. Get the roster right and proactive notifications become reliable — including for the new hire who was paged five minutes after joining.
Fixed it? Get 500 Microsoft Teams & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.