meetbot / docs
Meeting Bots

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

CodeDescriptionBilledRecommended action
waiting_for_hostBot is in the waiting room and the host hasn't joined within the configured timeout.NoRetry later or notify your user.
meeting_not_foundThe URL resolved to a meeting that does not exist (e.g. mistyped Meet code).NoSurface to the user as "URL invalid".
host_denied_admissionHost actively denied the bot from joining.NoSurface as "host denied" — do not auto-retry.
removed_from_meetingBot 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_endedMeeting ended before the bot could join (URL was already-stale at dispatch).NoDon't retry.
internal_errorSomething on our side broke.NoSafe to retry; if it happens repeatedly, open an issue.
dispatch_timeoutBot container failed to start within the deadline (60s default).NoAuto-retry once is reasonable.
network_lossBot lost network mid-call and could not reconnect within the grace window.Yes (partial)Manifest still uploaded for the recorded portion.
recording_disabledHost or org policy disabled recording (Zoom "recording locked", Meet "host management" toggles).NoSurface to user; we cannot bypass.
lobby_timeoutAdmitted to lobby but never to the actual meeting room within the timeout.NoRetry later.
auth_failureBot's signed-in credentials (Zoom OAuth / Google login pool) were invalid or revoked.NoReconnect the OAuth integration.
quota_exceededYour account is out of credit and has no card on file.NoAdd 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

On this page