Failure Sub-codes
The granular failure taxonomy returned on job.failed webhooks. Tells you whether to retry, escalate, or surface to your end-user.
When a bot can't complete its job, the job.failed webhook (envelope
version: 2) carries both a structured failure_code and a
human-readable failure_detail:
{
"deliveryId": "dlv_01HXZ…",
"event": "job.failed",
"emittedAt": "2026-05-09T14:00:00.000Z",
"attempt": 1,
"version": 2,
"data": {
"jobId": "job_01HXY…",
"status": "failed",
"failure_code": "host_denied_admission",
"failure_detail": "host clicked 'Deny' after 47s in waiting room",
"failure_reason": "host clicked 'Deny' after 47s in waiting room"
}
}failure_reason is mirrored from failure_detail for v1 receivers
that pre-date M1.2 — see the migration section below. New code MUST
branch on failure_code and surface failure_detail to humans.
The HTTP request also carries the x-meetbot-webhook-version: 2
header so middleware can route on the version without parsing the body.
The matching SDK type (@meetbot/sdk):
import type { FailureCode, WebhookEvent } from "@meetbot/sdk";
type FailureCode =
| "waiting_for_host"
| "meeting_not_found"
| "host_denied_admission"
| "removed_from_meeting" // canonical for Recall.ai's `bot_kicked` too
| "meeting_ended"
| "internal_error"
| "dispatch_timeout"
| "network_loss"
| "recording_disabled"
| "lobby_timeout"
| "auth_failure"
| "quota_exceeded";The codes
| Code | Description | Billed | Recommended action |
|---|---|---|---|
waiting_for_host | Bot is in the waiting room and the host hasn't joined within the configured timeout. | No | Retry later or notify your user. |
meeting_not_found | The URL resolved to a meeting that does not exist (e.g. mistyped Meet code). | No | Surface to the user as "URL invalid". |
host_denied_admission | Host actively denied the bot from joining. | No | Surface as "host denied" — do not auto-retry. |
removed_from_meeting | Bot was kicked mid-call. Canonical code for both passive disconnects and explicit "remove participant" actions; we deliberately collapsed Recall.ai's bot_kicked into this one. | Yes (for recorded portion) | Treat as a successful partial recording — manifest is still uploaded. |
meeting_ended | Meeting ended before the bot could join (URL was already-stale at dispatch). | No | Don't retry. |
internal_error | Something on our side broke. | No | Safe to retry; if it happens repeatedly, open an issue. |
dispatch_timeout | Bot container failed to start within the deadline (60s default). | No | Auto-retry once is reasonable. |
network_loss | Bot lost network mid-call and could not reconnect within the grace window. | Yes (partial) | Manifest still uploaded for the recorded portion. |
recording_disabled | Host or org policy disabled recording (Zoom "recording locked", Meet "host management" toggles). | No | Surface to user; we cannot bypass. |
lobby_timeout | Admitted to lobby but never to the actual meeting room within the timeout. | No | Retry later. |
auth_failure | Bot's signed-in credentials (Zoom OAuth / Google login pool) were invalid or revoked. | No | Reconnect the OAuth integration. |
quota_exceeded | Your account is out of credit and has no card on file. | No | Add a card at /account/billing. |
Billed vs not-billed
The billed field tells you definitively whether the run consumed
credit. The rule is simple — if the bot recorded at least 60s of
content, the recorded portion is billed. Codes like host_denied_admission,
waiting_for_host, meeting_not_found, and any pre-join failure are
always not-billed — we don't charge for failures that produced no
audio.
When billed: true on a failure, you also get the partial manifest at
event.data.manifestUri — the recording up to the moment of disconnect
is uploaded normally.
Pattern: branch on code
import type { WebhookEvent } from "@meetbot/sdk";
async function onJobFailed(event: Extract<WebhookEvent, { data: { event: "job.failed" } }>) {
const { failure_code, failure_detail, jobId } = event.data;
switch (failure_code) {
case "host_denied_admission":
case "recording_disabled":
// User-facing — do not retry, tell the user.
await notifyUserBotBlocked(jobId, failure_detail);
return;
case "waiting_for_host":
case "lobby_timeout":
case "dispatch_timeout":
// Transient — retry with backoff.
await scheduleRetry(jobId, { delaySec: 60 });
return;
case "auth_failure":
case "quota_exceeded":
// Account-level — page on-call.
await pageOnCall({ jobId, failure_code, failure_detail });
return;
case "internal_error":
// Our problem. Log, ack, move on.
log.warn("meetbot internal_error", { jobId, failure_detail });
return;
default:
// removed_from_meeting / network_loss / meeting_ended → recording
// is partial-or-final, treat like a normal job.finalized event.
return;
}
}Migration from failure_reason (v1 → v2)
Pre-M1.2 webhooks shipped only a free-text failure_reason. v2
keeps emitting that field — populated from the same string as
failure_detail — so v1 receivers continue to work without code
changes during the cut-over. The header x-meetbot-webhook-version
and the envelope's version field both expose the schema version
so consumers can branch:
const isV2 = event.version === 2;
const code = isV2 ? event.data.failure_code : mapLegacyReason(event.data.failure_reason);We'll remove failure_reason from outbound payloads in M3 (Aug 2026)
after a 60-day announcement window.
See also
- Bot Quickstart — where the
bot.failedwebhook fits in the lifecycle. - Webhooks: Signature Verification — failure events carry the same signature scheme as success events.
Bot Quickstart
End-to-end walkthrough — dispatch a bot to a Google Meet URL, listen for the finalize webhook, fetch the manifest, and download per-speaker audio.
TypeScript / JavaScript SDK
Official @meetbot/sdk for Node.js 22+. Typed HTTP client, webhook signature verification, full surface for the meetbot orchestrator API.