instructure/canvas-lms · error · BasicLTI::BasicOutcomes::InvalidRequest

Content-Type must be 'application/xml'

Error message

Content-Type must be 'application/xml'

What it means

Raised in LtiApiController#grade_passback when the LTI 1.1 Outcomes (grade passback) request is not sent with Content-Type application/xml. The Basic Outcomes processor expects the OAuth-signed XML envelope per the IMS spec, so any other media type is rejected as an invalid request before parsing.

Solutions

  1. Set the Content-Type header to application/xml on the outcomes POST
  2. Configure the HTTP client to not override the header (e.g. don't pass JSON bodies/objects)
  3. Re-test with curl -H 'Content-Type: application/xml' --data-binary @envelope.xml
  4. Ensure any intermediary proxy preserves the Content-Type

Example fix

// before
fetch(url, { method: 'POST', body: xmlString })
// after
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xmlString })
Defensive patterns

Strategy: validation

Validate before calling

if (!xmlString || xmlString.trim().startsWith('{')) throw new Error('Body must be LTI Basic Outcomes XML');
opts.headers['Content-Type'] = 'application/xml';

Type guard

const isXml = (s) => typeof s === 'string' && /<\?xml|<imsx_POXEnvelopeRequest/.test(s.trim());

Try / catch

begin
  post(url, xml_body, headers: { 'Content-Type' => 'application/xml' })
rescue BasicLTI::BasicOutcomes::InvalidRequest => e
  log_warn("outcomes rejected: #{e.message}")
end

Prevention

When it happens

Trigger: Tool sends a replaceResult/readResult/deleteResult request with missing, JSON, form-encoded, or text Content-Type headers; HTTP client defaults to form-urlencoded when posting the XML body; proxies/gateways rewriting the Content-Type.

Common situations: Integrating an LTI 1.1 tool whose grade passback was written casually (e.g. fetch without explicit headers); testing with curl without -H 'Content-Type: application/xml'; migrating code to a client that serializes JSON by default.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/45762944c9d45bfb. Report an issue: GitHub.

Appendix: source

Thrown at app/controllers/lti_api_controller.rb:38

require "oauth"
require "oauth/client/action_controller_request"
require "nokogiri"

class LtiApiController < ApplicationController
  skip_before_action :load_user, :require_user
  skip_before_action :verify_authenticity_token

  # these exceptions will happen on bad external requests,
  # we don't need to tell sentry about every one of them
  rescue_from BasicLTI::BasicOutcomes::Unauthorized, BasicLTI::BasicOutcomes::InvalidRequest, with: :rescue_expected_error_type

  # this API endpoint passes all the existing tests for the LTI v1.1 outcome service specification
  def grade_passback
    verify_oauth

    if request.media_type != "application/xml"
      raise BasicLTI::BasicOutcomes::InvalidRequest, "Content-Type must be 'application/xml'"
    end

    @xml = Nokogiri::XML.parse(request.body)

    lti_response, status = check_outcome BasicLTI::BasicOutcomes.process_request(@tool, @xml)

    # Log asset access for participation tracking
    if lti_response && lti_response.operation_ref_identifier == "replaceResult" && lti_response.code_major == "success"
      begin
        assignment = lti_response.assignment
        user = lti_response.user
        @context = assignment.context
        @current_user = user
        log_asset_access(assignment, "assignments", assignment.assignment_group, "participate")
      rescue
        # Don't fail the grade passback if asset logging fails
      end
    end

View on GitHub (pinned to 1c9f0bb801)