{"slug":"recall-ai-meeting-transcription","meta":{"title":"Recall.ai Meeting Bot — Transcript, Captions \u0026 Recording","slug":"recall-ai-meeting-transcription","category":"Integrations","summary":"Send an AI notetaker bot into a Zoom/Meet/Teams call, then pull back the transcript, WebVTT captions, and the video recording — wired to a record in your app.","tags":["recall","transcription","webhooks","video","captions","meetings","hmac","integrations"],"status":"stable","visibility":"public","source_project":"crm.llamapress.ai","layers":["model","sql","controller","view"]},"body":"# Recall.ai Meeting Bot — Transcript, Captions \u0026 Recording\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it\n\u003e there. (An optional copy-ready install package lives in\n\u003e `app/views/cookbook/recall-ai-meeting-transcription/` — see its `INSTALL.md`.)\n\n[Recall.ai](https://recall.ai) is a single API that sends a recording **bot** into a\nZoom / Google Meet / Microsoft Teams call. The bot joins like a participant, records\nthe meeting, and produces a **transcript** and a **video recording** you can pull back\ninto your app. This pattern wires that whole lifecycle to a record (here a\n`Conversation`): paste a meeting URL → a bot joins → when the call ends you get the\ntranscript, downloadable WebVTT **captions**, and an mp4 you can stream or store.\n\n\u003e **When to use:** any app that needs meeting notes/recordings — CRM call logging,\n\u003e sales-call analysis, interview capture, support-call review.\n\u003e **When not to:** you only need a transcript of an *uploaded* file (use a plain\n\u003e speech-to-text API); you can't expose a public HTTPS webhook endpoint (you can still\n\u003e use the manual \"pull\" path below, but you lose the automatic save-on-finish).\n\n---\n\n## The 80/20 in one breath\n\n1. **One service object** (`RecallAi`) wraps the REST API: `join_meeting`,\n   `get_bot`, `get_transcript`, `get_recording_url`, plus webhook verification and\n   transcript→VTT formatting. Everything else just calls into it.\n2. **On record create**, if a `meeting_url` is present, call `join_meeting` and stash\n   the returned bot id (`bot_id`) on the record. That's what dispatches the bot.\n3. **Two ways to get results back:** (a) a **webhook** (`bot.status_change` → `\"done\"`)\n   that auto-saves the transcript, or (b) a manual **\"Pull transcription\"** button that\n   calls `get_transcript` / `get_recording_url` on demand. Build both — the webhook for\n   hands-off capture, the button for retries and backfills.\n4. **Store the artifacts:** transcript text + raw JSON on the record, captions as a\n   WebVTT Active Storage attachment, and the recording either as a hot `video_url` or a\n   downloaded mp4 attachment.\n5. **Recording/transcript URLs are presigned and expire** — treat any saved\n   `video_url` as perishable and add a \"refresh\" action that re-fetches it from the bot.\n\nTwo env vars: `RECALL_API_KEY` (API auth) and `RECALL_WEBHOOK_SECRET` (HMAC secret).\n\n---\n\n## Layer 1 — Model \u0026 SQL\n\nAdd `bot_id` + `meeting_url` (and a hot `video_url`) to whatever record represents the\nmeeting, and attach the artifacts via Active Storage.\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_add_recall_fields_to_conversations.rb\nclass AddRecallFieldsToConversations \u003c ActiveRecord::Migration[7.2]\n  def change\n    add_column :conversations, :bot_id,      :string  # Recall bot UUID — the join key for every API call\n    add_column :conversations, :meeting_url, :string  # the Zoom/Meet/Teams URL the user pastes\n    add_column :conversations, :video_url,   :string  # presigned recording URL (EXPIRES — see gotchas)\n    # transcription / raw_transcript are :text columns holding the human-readable transcript\n  end\nend\n```\n\n```ruby\n# app/models/conversation.rb\nclass Conversation \u003c ApplicationRecord\n  belongs_to :contact            # whatever the meeting is \"about\" — adapt to your schema\n  belongs_to :project, optional: true\n\n  has_one_attached :video_file        # the downloaded mp4 (durable copy of the recording)\n  has_one_attached :vtt_file          # WebVTT captions for the \u003cvideo\u003e player\n  has_one_attached :transcript_json   # raw Recall transcript JSON, kept for re-generation\n\n  # A conversation is valid if it either already has a transcript, OR is \"pending\"\n  # (a bot was dispatched / a meeting URL was given and results will arrive later).\n  validates :transcription, presence: true,\n            unless: -\u003e { bot_id.present? || meeting_url.present? }\nend\n```\n\n**Why three attachments + columns?** Each serves a different consumer: `transcription`\n(text) is for humans and search, `transcript_json` is the lossless source for\nre-generating captions later, `vtt_file` is what the `\u003cvideo\u003e` element loads, and\n`video_file` is a durable copy so you don't depend on Recall's expiring URL.\n\n---\n\n## Layer 2 — The service object (the whole integration lives here)\n\nThis is the only file that knows Recall's API shape. Keep controllers thin and let\nthem call these methods.\n\n```ruby\n# app/services/recall_ai.rb\nrequire 'net/http'\nrequire 'json'\nrequire 'uri'\nrequire 'openssl'\n\nclass RecallAi\n  # Region-specific base URL. Use the region your Recall workspace is in.\n  # us-west-2 (default), us-east-1, eu-central-1, etc.\n  RECALL_API_URL = \"https://us-west-2.recall.ai/api/v1\"\n\n  def initialize(api_key: ENV[\"RECALL_API_KEY\"], webhook_secret: ENV[\"RECALL_WEBHOOK_SECRET\"])\n    @api_key = api_key\n    @webhook_secret = webhook_secret\n  end\n\n  # === Join Meeting === dispatches a bot into a live call; returns JSON incl. the bot \"id\".\n  def join_meeting(meeting_url, bot_name: \"Recall Bot\", transcribe: true,\n                   transcription_mode: \"prioritize_low_latency\")\n    uri = URI(\"#{RECALL_API_URL}/bot\")\n    req = Net::HTTP::Post.new(uri, headers)\n\n    payload = { meeting_url: meeting_url, bot_name: bot_name }\n\n    # NEW API shape: recording_config.transcript.provider.recallai_streaming.\n    # language_code is REQUIRED for low-latency mode.\n    if transcribe\n      payload[:recording_config] = {\n        transcript: {\n          provider: {\n            recallai_streaming: {\n              mode: transcription_mode,   # \"prioritize_low_latency\" | \"prioritize_accuracy\"\n              language_code: \"en\"\n            }\n          }\n        }\n      }\n    end\n\n    req.body = payload.to_json\n    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }\n    raise \"Recall.ai Connection Failed: #{res.code} #{res.body}\" unless res.is_a?(Net::HTTPSuccess)\n\n    data = JSON.parse(res.body)\n    raise \"Recall.ai Bot Error: #{data['status_changes']}\" if data[\"status\"] == \"fatal\"\n    data\n  end\n\n  # === Get Bot === the canonical read; everything else digs into this payload.\n  def get_bot(bot_id)\n    uri = URI(\"#{RECALL_API_URL}/bot/#{bot_id}\")\n    req = Net::HTTP::Get.new(uri, headers)\n    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }\n    raise \"Recall.ai Fetch Failed: #{res.code} #{res.body}\" unless res.is_a?(Net::HTTPSuccess)\n    JSON.parse(res.body)\n  end\n\n  # === Get Transcript === downloads the transcript JSON from the bot's presigned URL.\n  def get_transcript(bot_id)\n    bot = get_bot(bot_id)\n    transcript_url = bot.dig(\"recordings\", 0, \"media_shortcuts\", \"transcript\", \"data\", \"download_url\")\n    raise \"No transcript URL found for bot #{bot_id}\" unless transcript_url\n\n    uri = URI(transcript_url)\n    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(Net::HTTP::Get.new(uri)) }\n    raise \"Recall.ai Transcript Download Failed: #{res.code} #{res.body}\" unless res.is_a?(Net::HTTPSuccess)\n    JSON.parse(res.body)\n  end\n\n  # === Get Recording URL === presigned mp4 URL (EXPIRES — re-fetch when stale).\n  def get_recording_url(bot_id)\n    get_bot(bot_id).dig(\"recordings\", 0, \"media_shortcuts\", \"video_mixed\", \"data\", \"download_url\")\n  end\n\n  # === Status helpers ===\n  def current_status(bot_id)\n    get_bot(bot_id)[\"status_changes\"]\u0026.last\u0026.dig(\"code\")\n  end\n\n  def recording_ready?(bot_id)\n    bot = get_bot(bot_id)\n    status    = bot[\"status_changes\"]\u0026.last\u0026.dig(\"code\")\n    video_url = bot.dig(\"recordings\", 0, \"media_shortcuts\", \"video_mixed\", \"data\", \"download_url\")\n    status == \"done\" \u0026\u0026 video_url.present?\n  end\n\n  # === Verify Incoming Webhook === HMAC-SHA256 over \"timestamp.body\". Call from controller.\n  def verify_webhook!(payload_body, headers)\n    timestamp = headers['x-recall-signature-timestamp'] || headers['webhook-timestamp']\n    signature = headers['x-recall-signature']           || headers['webhook-signature']\n    raise \"Missing Webhook Headers\" unless timestamp \u0026\u0026 signature\n\n    signed_content = \"#{timestamp}.#{payload_body}\"\n    expected = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), @webhook_secret, signed_content)\n\n    match = signature.split(' ').any? do |sig_part|\n      version, hash = sig_part.split(',')\n      version == 'v1' \u0026\u0026 Rack::Utils.secure_compare(hash, expected)\n    end\n    raise \"Invalid Webhook Signature\" unless match\n    true\n  end\n\n  # === Generate WebVTT (captions) === turns the transcript JSON into a caption track.\n  # Breaks cues on: speaker change, silence gap \u003e 0.5s, terminal punctuation, or ~75 chars.\n  def generate_vtt(transcript_items)\n    return \"WEBVTT\\n\\n\" if transcript_items.nil? || transcript_items.empty?\n    vtt = \"WEBVTT\\n\\n\"\n\n    transcript_items.each do |item|\n      speaker = item.dig(\"participant\", \"name\") || \"Unknown\"\n      words = item[\"words\"] || []\n      next if words.empty?\n\n      current, cue_start = [], nil\n      words.each_with_index do |w, i|\n        start_rel = w.dig(\"start_timestamp\", \"relative\")\n        end_rel   = w.dig(\"end_timestamp\", \"relative\")\n        cue_start ||= start_rel\n        current \u003c\u003c w[\"text\"]\n\n        nxt = words[i + 1]\n        last = nxt.nil?\n        pause = nxt \u0026\u0026 (nxt.dig(\"start_timestamp\", \"relative\") - end_rel \u003e 0.5)\n        punct = w[\"text\"].match?(/[.!?]$/)\n        long  = current.join(\" \").length \u003e 75\n\n        if last || pause || punct || long\n          vtt += \"#{format_vtt_time(cue_start)} --\u003e #{format_vtt_time(end_rel)}\\n\"\n          vtt += \"\u003cv #{speaker}\u003e#{current.join(' ')}\\n\\n\"\n          current, cue_start = [], nil\n        end\n      end\n    end\n    vtt\n  end\n\n  def format_vtt_time(seconds)\n    ms = (seconds * 1000).to_i\n    format(\"%02d:%02d:%02d.%03d\", ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000)\n  end\n\n  # === Format transcript into readable \"[MM:SS] Speaker: text\" ===\n  def format_transcript(transcript_items)\n    return \"[Empty transcript]\" if transcript_items.nil? || transcript_items.empty?\n    transcript_items.map do |item|\n      if item[\"participant\"]\n        speaker = item.dig(\"participant\", \"name\") || \"Unknown\"\n        words   = item[\"words\"] || []\n        text    = words.map { |w| w[\"text\"] }.join(\" \")\n        ts      = words.first\u0026.dig(\"start_timestamp\", \"relative\")\n      else\n        speaker = item[\"speaker\"] || item[\"participant_name\"] || \"Unknown\"\n        text    = item[\"text\"] || item[\"words\"]\u0026.map { |w| w[\"text\"] }\u0026.join(\" \") || \"\"\n        ts      = item[\"start_time\"] || item[\"timestamp\"]\n      end\n      ts.present? ? \"[#{format_time(ts)}] #{speaker}: #{text}\" : \"#{speaker}: #{text}\"\n    end.join(\"\\n\\n\")\n  end\n\n  def format_time(seconds)\n    return seconds.to_s unless seconds.is_a?(Numeric)\n    s = seconds.to_i\n    s \u003e= 3600 ? format(\"%02d:%02d:%02d\", s / 3600, (s % 3600) / 60, s % 60) : format(\"%02d:%02d\", s / 60, s % 60)\n  end\n\n  private\n\n  def headers\n    # NOTE: Recall uses \"Token \u003ckey\u003e\", NOT \"Bearer \u003ckey\u003e\".\n    { \"Authorization\" =\u003e \"Token #{@api_key}\", \"Content-Type\" =\u003e \"application/json\", \"Accept\" =\u003e \"application/json\" }\n  end\nend\n```\n\n---\n\n## Layer 3 — Controller (dispatch the bot + pull/attach results)\n\n```ruby\n# config/routes.rb\nresources :conversations do\n  member do\n    post :pull_transcription   # on-demand: fetch transcript + captions + recording URL\n    post :refresh_recording    # re-fetch the expiring presigned video URL\n    post :attach_recording     # download the mp4 into Active Storage (durable copy)\n    get  :video                # dedicated captioned player page\n  end\nend\n\n# Recall.ai webhook (public — no CSRF, verified by HMAC instead)\npost \"/webhooks/recall\", to: \"webhooks#recall\"\n```\n\n```ruby\n# app/controllers/conversations_controller.rb\nclass ConversationsController \u003c ApplicationController\n  require 'open-uri'\n  require 'stringio'\n  before_action :set_conversation,\n                only: %i[show pull_transcription refresh_recording attach_recording video update destroy]\n\n  # Dispatch the bot the moment a conversation with a meeting URL is created.\n  def create\n    @conversation = Conversation.new(conversation_params)\n\n    if @conversation.meeting_url.present?\n      begin\n        res = RecallAi.new.join_meeting(@conversation.meeting_url, bot_name: \"🦙 Notetaker\")\n        @conversation.bot_id = res[\"id\"]   # stash the bot id — it's the key to everything later\n      rescue =\u003e e\n        @conversation.errors.add(:meeting_url, \"could not trigger Recall bot: #{e.message}\")\n        return render :new, status: :unprocessable_entity\n      end\n    end\n\n    if @conversation.save\n      redirect_to @conversation, notice: \"Conversation created — the bot is joining the meeting.\"\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  # On-demand pull: transcript text + raw JSON + WebVTT captions + recording URL.\n  def pull_transcription\n    return redirect_to(@conversation, alert: \"No Bot ID for this conversation.\") unless @conversation.bot_id.present?\n\n    recall = RecallAi.new\n    transcript_data = recall.get_transcript(@conversation.bot_id)\n    items = transcript_data.is_a?(Array) ? transcript_data : (transcript_data[\"results\"] || [])\n\n    @conversation.update!(\n      transcription:  recall.format_transcript(items),\n      raw_transcript: recall.format_transcript(items),\n      video_url:      (recall.get_recording_url(@conversation.bot_id) rescue nil)\n    )\n\n    @conversation.vtt_file.attach(\n      io: StringIO.new(recall.generate_vtt(items)),\n      filename: \"captions_#{@conversation.id}.vtt\", content_type: \"text/vtt\"\n    )\n    @conversation.transcript_json.attach(\n      io: StringIO.new(transcript_data.to_json),\n      filename: \"transcript_#{@conversation.id}.json\", content_type: \"application/json\"\n    )\n\n    redirect_to @conversation, notice: \"Transcript, captions, and recording pulled from Recall.\"\n  rescue =\u003e e\n    redirect_to @conversation, alert: \"Failed to pull transcription: #{e.message}\"\n  end\n\n  # Re-fetch the expiring presigned recording URL.\n  def refresh_recording\n    if fetch_fresh_video_url\n      redirect_to @conversation, notice: \"Recording URL refreshed.\"\n    else\n      redirect_to @conversation, alert: \"Recording is not ready yet.\"\n    end\n  rescue =\u003e e\n    redirect_to @conversation, alert: \"Failed to refresh recording: #{e.message}\"\n  end\n\n  # Download the mp4 into Active Storage. Presigned URLs 403 when expired — refresh \u0026 retry once.\n  def attach_recording\n    return redirect_to(@conversation, alert: \"No recording URL to attach.\") unless @conversation.video_url.present?\n    perform_attachment(@conversation.video_url)\n    redirect_to @conversation, notice: \"Recording downloaded and attached.\"\n  rescue OpenURI::HTTPError =\u003e e\n    if e.io.status[0] == \"403\" \u0026\u0026 (new_url = fetch_fresh_video_url)\n      perform_attachment(new_url)\n      redirect_to @conversation, notice: \"Link was expired (403) — refreshed and attached.\"\n    else\n      redirect_to @conversation, alert: \"Failed to attach recording: #{e.message}\"\n    end\n  rescue =\u003e e\n    redirect_to @conversation, alert: \"Failed to attach recording: #{e.message}\"\n  end\n\n  def video; end   # renders the captioned player (Layer 4 view)\n\n  private\n\n  def set_conversation\n    @conversation = Conversation.find(params[:id])\n  end\n\n  def conversation_params\n    params.require(:conversation).permit(:transcription, :raw_transcript, :meeting_url,\n                                         :video_url, :video_file, :contact_id, :project_id, tag_ids: [])\n  end\n\n  def fetch_fresh_video_url\n    return nil unless @conversation.bot_id.present?\n    url = RecallAi.new.get_recording_url(@conversation.bot_id)\n    url \u0026\u0026 @conversation.update!(video_url: url) \u0026\u0026 url\n  end\n\n  def perform_attachment(url)\n    @conversation.video_file.attach(\n      io: URI.open(url), filename: \"recording_#{@conversation.id}.mp4\", content_type: \"video/mp4\"\n    )\n  end\nend\n```\n\n```ruby\n# app/controllers/webhooks_controller.rb\nclass WebhooksController \u003c ApplicationController\n  skip_before_action :verify_authenticity_token   # external caller — verified by HMAC, not CSRF\n\n  def recall\n    payload_body = request.body.read   # read the body EXACTLY ONCE (it's a stream)\n\n    begin\n      RecallAi.new.verify_webhook!(payload_body, request.headers)\n    rescue =\u003e e\n      Rails.logger.error(\"Recall webhook verification failed: #{e.message}\")\n      return render json: { error: \"Unverified\" }, status: :unauthorized\n    end\n\n    event = JSON.parse(payload_body)\n    handle_status_change(event[\"data\"]) if event[\"event\"] == \"bot.status_change\"\n    head :ok\n  end\n\n  private\n\n  def handle_status_change(data)\n    bot_id = data[\"bot_id\"] || data.dig(\"bot\", \"id\")\n    code   = data.dig(\"status\", \"code\") || data[\"status_code\"]\n    return unless code == \"done\" \u0026\u0026 bot_id.present?\n\n    # Do the slow transcript fetch OUTSIDE the request so the webhook returns fast.\n    # PREFER an ActiveJob over Thread.new (see gotchas) — e.g.:\n    SaveRecallConversationJob.perform_later(bot_id)\n  end\nend\n```\n\n---\n\n## Layer 4 — The captioned player view\n\nThe payoff: a native `\u003cvideo\u003e` element with a WebVTT `\u003ctrack\u003e` for synced captions,\nfalling back from a downloaded mp4 to the live presigned URL.\n\n```erb\n\u003c%# app/views/conversations/video.html.erb %\u003e\n\u003cdiv class=\"card bg-base-300 shadow-2xl overflow-hidden\"\u003e\n  \u003cdiv class=\"aspect-video bg-black flex items-center justify-center\"\u003e\n    \u003c% if @conversation.video_file.attached? %\u003e\n      \u003cvideo controls autoplay class=\"w-full h-full object-contain\"\u003e\n        \u003csource src=\"\u003c%= url_for(@conversation.video_file) %\u003e\" type=\"\u003c%= @conversation.video_file.content_type %\u003e\"\u003e\n        \u003c% if @conversation.vtt_file.attached? %\u003e\n          \u003ctrack label=\"English\" kind=\"subtitles\" srclang=\"en\" src=\"\u003c%= url_for(@conversation.vtt_file) %\u003e\" default\u003e\n        \u003c% end %\u003e\n        Your browser does not support the video tag.\n      \u003c/video\u003e\n    \u003c% elsif @conversation.video_url.present? %\u003e\n      \u003cvideo controls autoplay class=\"w-full h-full object-contain\"\u003e\n        \u003csource src=\"\u003c%= @conversation.video_url %\u003e\" type=\"video/mp4\"\u003e\n        \u003c% if @conversation.vtt_file.attached? %\u003e\n          \u003ctrack label=\"English\" kind=\"subtitles\" srclang=\"en\" src=\"\u003c%= url_for(@conversation.vtt_file) %\u003e\" default\u003e\n        \u003c% end %\u003e\n      \u003c/video\u003e\n    \u003c% else %\u003e\n      \u003cp class=\"text-base-content/50\"\u003eNo video found for this conversation.\u003c/p\u003e\n    \u003c% end %\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nAnd the action buttons on the detail page (`show.html.erb`) — gated on what exists:\n\n```erb\n\u003c%# app/views/conversations/show.html.erb (excerpt) %\u003e\n\u003c% if @conversation.bot_id.present? %\u003e\n  \u003c%= button_to \"Pull transcription\", pull_transcription_conversation_path(@conversation),\n        method: :post, class: \"btn btn-primary\", data: { turbo: false } %\u003e\n\u003c% end %\u003e\n\n\u003c% if @conversation.video_url.present? || @conversation.video_file.attached? %\u003e\n  \u003c%= link_to \"Watch\", video_conversation_path(@conversation), target: \"_blank\", class: \"btn btn-accent\" %\u003e\n  \u003c% if @conversation.video_url.present? \u0026\u0026 !@conversation.video_file.attached? %\u003e\n    \u003c%= button_to \"Download \u0026 attach\", attach_recording_conversation_path(@conversation), method: :post, class: \"btn\" %\u003e\n  \u003c% end %\u003e\n  \u003c%= button_to \"Refresh link\", refresh_recording_conversation_path(@conversation), method: :post, class: \"btn btn-square\" %\u003e\n\u003c% end %\u003e\n```\n\nThe meeting-URL field that kicks the whole thing off (in `_form.html.erb`):\n\n```erb\n\u003c%# app/views/conversations/_form.html.erb (excerpt) %\u003e\n\u003cdiv class=\"my-5\"\u003e\n  \u003c%= form.label :meeting_url, \"Recall AI Meeting URL\" %\u003e\n  \u003c%= form.text_field :meeting_url, class: \"block w-full rounded-md border px-3 py-2 mt-2\" %\u003e\n  \u003cp class=\"text-sm text-gray-500 mt-1 italic\"\u003e\n    Enter a Zoom, Google Meet, or Microsoft Teams URL to invite the Recall AI bot.\n  \u003c/p\u003e\n\u003c/div\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Auth header is `Token \u003ckey\u003e`, not `Bearer`.** A `Bearer` prefix silently 401s.\n- **Region-specific base URL.** `https://us-west-2.recall.ai/api/v1` is one region.\n  EU workspaces use `eu-central-1`, etc. Wrong region = auth/404 confusion. Make it an\n  env var if you serve multiple regions.\n- **Recording \u0026 transcript URLs are presigned and EXPIRE.** A `video_url` you saved an\n  hour ago will `403`. That's why `attach_recording` catches `OpenURI::HTTPError`,\n  checks for `403`, calls `fetch_fresh_video_url` (re-reads the bot), and retries once.\n  Always re-fetch from `get_bot` rather than trusting a stored URL — or download the mp4\n  into Active Storage immediately for a durable copy.\n- **Read the webhook body exactly once.** `request.body.read` consumes the stream. Read\n  it into a variable, verify the HMAC against *that exact string*, then `JSON.parse`\n  the same variable. Parsing first and re-serializing will change bytes and break the\n  signature.\n- **HMAC is over `\"#{timestamp}.#{body}\"`**, SHA-256, hex digest, compared with\n  `Rack::Utils.secure_compare` (constant-time — don't use `==`). The signature header\n  is space-separated `v1,\u003chash\u003e` parts; check the `v1` version.\n- **`skip_before_action :verify_authenticity_token`** on the webhook controller — it's\n  an external POST with no CSRF token. The HMAC check *is* the authentication.\n- **New transcript API shape.** Enable transcription via\n  `recording_config.transcript.provider.recallai_streaming` (the current format), and\n  include `language_code` — it's **required** for `prioritize_low_latency` mode.\n- **Deep-dig the bot payload, defensively.** Results live at\n  `recordings[0].media_shortcuts.{transcript,video_mixed}.data.download_url`. Before the\n  meeting ends these are `nil` — guard every dig and surface \"not ready yet\" instead of\n  crashing.\n- **Don't block the webhook on the transcript fetch.** Downloading + formatting takes\n  seconds; the webhook must return `200` fast or Recall retries. The source app used\n  `Thread.new`, but **prefer an ActiveJob** (`perform_later`) — a raw thread is lost on\n  deploy/restart and has no retries, so a transcript can silently never save. (The\n  manual \"Pull transcription\" button is your backstop when a webhook is missed.)\n- **Two capture paths, on purpose.** The webhook auto-saves on `\"done\"`; the button\n  re-pulls anytime. Meetings drop, bots fail to join, webhooks get missed — the manual\n  pull is what makes the feature reliable in practice.\n- **`bot_id` is the join key for everything.** Persist it on create. Without it you\n  can't pull a transcript, refresh a URL, or poll status.\n\n---\n\n## Files this pattern touches\n\n```\ndb/migrate/XXXX_add_recall_fields_to_conversations.rb   # bot_id, meeting_url, video_url\napp/models/conversation.rb                              # attachments + pending-validity rule\napp/services/recall_ai.rb                               # the entire API integration\napp/controllers/conversations_controller.rb            # create(join) + pull/refresh/attach/video\napp/controllers/webhooks_controller.rb                 # HMAC-verified bot.status_change webhook\napp/jobs/save_recall_conversation_job.rb               # (recommended) background transcript save\nconfig/routes.rb                                        # member routes + POST /webhooks/recall\napp/views/conversations/_form.html.erb                 # meeting_url field\napp/views/conversations/show.html.erb                  # pull/watch/attach/refresh buttons\napp/views/conversations/video.html.erb                 # captioned \u003cvideo\u003e player\n```\n\n## How to adapt to your schema\n\n1. **Swap the host record.** `Conversation` is just \"the thing a meeting is about.\"\n   Rename to `Meeting`, `Call`, `Interview` — keep the four fields (`bot_id`,\n   `meeting_url`, `video_url`, a transcript text column) and the three attachments.\n2. **Set your region \u0026 secrets.** `RECALL_API_KEY`, `RECALL_WEBHOOK_SECRET`, and the\n   `RECALL_API_URL` region. Register the webhook URL (`/webhooks/recall`) and the\n   `bot.status_change` event in the Recall dashboard.\n3. **Pick your background runner.** Replace the inline `Thread.new`/`perform_later`\n   stub with a real job (`SaveRecallConversationJob` that calls `get_transcript` +\n   formats + attaches). Reuse the exact logic from `pull_transcription`.\n4. **Trim what you don't need.** If you never want a durable mp4, drop\n   `attach_recording`/`video_file` and just stream `video_url` (but then refresh it on\n   every view, since it expires). If you don't need captions, drop `generate_vtt` and\n   the `\u003ctrack\u003e`. The minimum viable version is: `join_meeting` on create + the webhook\n   (or button) calling `get_transcript` + `format_transcript`.\n5. **Customize the bot.** `bot_name` is what shows in the participant list; pass\n   `transcription_mode: \"prioritize_accuracy\"` when you care about quality over latency.\n```\n"}