Net-Base Magazine

27.05.2026

Multipart/Form-Data Upload in Delphi: robust streams, boundary control and debugging without guesswork

Multipart/Form-Data uploads appear trivial, but in Delphi they quickly become fragile when handling streams, filenames, Content-Type, boundary handling and timeouts. This code snippet shows a robust, debuggable implementation using THTTPClient — including a correctly calculated Content-Length...

27.05.2026

Why Multipart in Delphi often only fails in production

A Multipart/Form-Data upload in Delphi can be assembled quickly — and then fails in real integrations due to details: wrong Content-Type per part, a boundary string that accidentally appears in the payload, inappropriate line breaks, non-ASCII filenames, or servers that reject chunked transfer encoding (HTTP without Content-Length). On top of that come typical practical issues in bespoke enterprise software: large files (CAD, PDFs, scans), fluctuating networks, reverse proxies, strict API gateways and admin requirements for debugging.

Delphi ships a usable stack with System.Net.HttpClient, but the “Happy Path” examples leave important edge conditions unaddressed. The following source snippet deliberately goes deeper: we build multipart deterministically as a stream, calculate Content-Length correctly, support RFC-5987 for filenames and provide a debug option that makes the request reproducible without having to break TLS.

Architectural decision: THTTPClient instead of Indy — and when that becomes problematic

THTTPClient (System.Net) uses different backends depending on the platform (under Windows typically WinHTTP/WinINet). That is often advantageous in enterprise environments: proxy and TLS policies tend to be more compatible with the system. Indy, by contrast, is very transparent and adaptable, but brings its own TLS bindings and is sometimes “maintained separately” in operation (OpenSSL versions, cipher suites).

The approach here uses THTTPClient because it is often already in use during modernizations (REST client, OAuth, downloads). If, however, you need strong control over TLS handshakes, client certificates in special forms or very specific proxy chains, Indy (or a dedicated HTTP stack) can be appropriate. That changes little about multipart construction — but it affects debugging and operations.

Multipart/Form-Data upload in Delphi: a stream, no magic

The core idea: multipart is, in the end, just a byte stream. If we build it ourselves, we can:

  • deliberately choose and reliably test the boundary
  • set headers per part correctly (including Content-Disposition, Content-Type)
  • reliably calculate Content-Length (important for servers without chunked support)
  • stream large files without keeping everything in RAM

The code: multipart builder with streaming and RFC-5987 filenames

The builder below optionally produces either a purely memory-based body (for small uploads) or a spool file on disk (for large payloads). That feels “oldschool”, but is extremely practical in operation because it avoids chunked encoding and simplifies debugging. Spooling means: you can reuse the same request body even if a retry is required.

Delphi
unit Net-Base.Multipart;

interface

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  System.NetEncoding, System.Hash;

type
  TMultipartFormData = class
  private
    FBoundary: string;
    FParts: TObjectList<TObject>;
    function CRLF: TBytes;
    function BytesOfAscii(const S: string): TBytes;
    function Quote(const S: string): string;
    function Rfc5987FileNameStar(const FileName: string): string;
    function NewBoundary: string;
  public
    constructor Create(const ABoundary: string = '');
    destructor Destroy; override;

    function ContentType: string;
    function Boundary: string;

    procedure AddField(const Name, Value: string; const ContentType: string = 'text/plain; charset=utf-8');
    procedure AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);

    // Builds the complete body into a stream. If ASpoolToFile is empty,
    // a TMemoryStream is used; otherwise a file is created.
    function BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string = ''): TStream;
  end;

implementation

uses
  System.Generics.Collections;

type
  TPartKind = (pkField, pkFile);

  TPart = class
  public
    Kind: TPartKind;
    Name: string;
    ContentType: string;
    // Field
    Value: string;
    // File
    FileName: string;
    FileStream: TStream;
    constructor Create; 
  end;

constructor TPart.Create;
begin
  inherited Create;
bend;

constructor TMultipartFormData.Create(const ABoundary: string);
begin
  inherited Create;
  FParts := TObjectList<TObject>.Create(True);
  if ABoundary <> '' then
    FBoundary := ABoundary
  else
    FBoundary := NewBoundary;
end;

destructor TMultipartFormData.Destroy;
begin
  FParts.Free;
  inherited;
end;

function TMultipartFormData.NewBoundary: string;
var
  R: TBytes;
begin
  // Boundary should be sufficiently random. Important: no spaces.
  SetLength(R, 16);
  TNetEncoding.Base64.Decode(TNetEncoding.Base64.EncodeBytesToBytes(THashSHA2.GetHashBytes(GuidToString(TGuid.NewGuid))), R);
  Result := '----DelphiBoundary' + THashSHA2.GetHashString(GuidToString(TGuid.NewGuid));
  Result := Result.Replace('{','').Replace('}','').Replace('-','');
end;

function TMultipartFormData.Boundary: string;
begin
  Result := FBoundary;
end;

function TMultipartFormData.ContentType: string;
begin
  Result := 'multipart/form-data; boundary=' + FBoundary;
end;

function TMultipartFormData.CRLF: TBytes;
begin
  Result := TBytes.Create($0D, $0A);
end;

function TMultipartFormData.BytesOfAscii(const S: string): TBytes;
begin
  // Multipart headers are ASCII. For values in the body (e.g. UTF-8) we set Content-Type per part.
  Result := TEncoding.ASCII.GetBytes(S);
end;

function TMultipartFormData.Quote(const S: string): string;
begin
  Result := '"' + S.Replace('"', '"') + '"';
end;

function TMultipartFormData.Rfc5987FileNameStar(const FileName: string): string;
var
  Utf8: TBytes;
  Enc: string;
begin
  // filename*="UTF-8''..." is significantly more robust for non-ASCII filenames than just filename="...".
  Utf8 := TEncoding.UTF8.GetBytes(FileName);
  Enc := TNetEncoding.URL.EncodeBytesToString(Utf8);
  Result := 'filename*=' + 'UTF-8'''''+ Enc;
end;

procedure TMultipartFormData.AddField(const Name, Value: string; const ContentType: string);
var
  P: TPart;
begin
  P := TPart.Create;
  P.Kind := pkField;
  P.Name := Name;
  P.Value := Value;
  P.ContentType := ContentType;
  FParts.Add(P);
end;

procedure TMultipartFormData.AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);
var
  P: TPart;
begin
  if FileStream = nil then
    raise EArgumentNilException.Create('FileStream must not be nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // allowed, but often an error: empty file

  P := TPart.Create;
  P.Kind := pkFile;
  P.Name := FieldName;
  P.FileName := FileName;
  P.ContentType := ContentType;
  P.FileStream := FileStream; // Owner remains with the caller
  FParts.Add(P);
end;

function TMultipartFormData.BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string): TStream;
var
  OutStream: TStream;
  WriterUtf8: TBytes;
  PartObj: TObject;
  P: TPart;
  Header: string;
  Sep, EndSep: string;
  B: TBytes;

  procedure WriteAscii(const S: string);
  begin
    B := BytesOfAscii(S);
    OutStream.WriteBuffer(B, Length(B));
  end;

  procedure WriteBytes(const Bytes: TBytes);
  begin
    if Length(Bytes) > 0 then
      OutStream.WriteBuffer(Bytes, Length(Bytes));
  end;

  procedure CopyStreamFully(Src: TStream);
  var
    Buf: array[0..64*1024-1] of Byte;
    ReadN: Integer;
  begin
    // Note: the stream position will be consumed.
    while True do
    begin
      ReadN := Src.Read(Buf, SizeOf(Buf));
      if ReadN <= 0 then
        Break;
      OutStream.WriteBuffer(Buf, ReadN);
    end;
  end;

begin
  if ASpoolToFile <> '' then
    OutStream := TFileStream.Create(ASpoolToFile, fmCreate or fmShareDenyWrite)
  else
    OutStream := TMemoryStream.Create;

  try
    Sep := '--' + FBoundary + #13#10;
    EndSep := '--' + FBoundary + '--' + #13#10;

    for PartObj in FParts do
    begin
      P := TPart(PartObj);

      WriteAscii(Sep);

      if P.Kind = pkField then
      begin
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  #13#10;
        WriteAscii(Header);
        // Field body in UTF-8, if charset=utf-8 is set.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Two filename parameters: filename (for old servers) and filename* (RFC 5987)
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + '; ' +
                  'filename=' + Quote(ExtractFileName(P.FileName)) + '; ' +
                  Rfc5987FileNameStar(ExtractFileName(P.FileName)) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  'Content-Transfer-Encoding: binary' + #13#10 +
                  #13#10;
        WriteAscii(Header);

        // Important: set position to the beginning, otherwise only the remainder will be uploaded.
        if P.FileStream.Seek(0, soBeginning) <> 0 then
          ;
        CopyStreamFully(P.FileStream);
        WriteBytes(CRLF);
      end;
    end;

    WriteAscii(EndSep);

    AContentLength := OutStream.Size;
    OutStream.Position := 0;
    Result := OutStream;
  except
    OutStream.Free;
    raise;
  end;
end;

end.

What the code intentionally does differently

  • No „automatic multipart“: Control over headers, encodings and the boundary remains with you. That is often decisive for strict REST APIs.
  • RFC-5987 support via filename*: As soon as filenames contain umlauts (e.g. „Prüfbericht.pdf“), this is the most common interoperability bug. Some servers ignore filename*; then filename is used as a fallback.
  • Spool-to-File as an operational feature: For large uploads and retries a reusable body stream is invaluable.
  • Content-Length is available because the body is generated in advance. That avoids chunked encoding if the target system does not accept it.

Sending the request: timeouts, headers and a sensible retry strategy

Multipart alone does not solve integration problems: you need timeouts, error classification and optional retries. It is important to distinguish between idempotent and non-idempotent: uploads are often non-idempotent (duplicates possible). Retries should therefore only be performed if the server provides idempotent semantics (e.g. upload ID, dedicated Idempotency-Key header) or you have server-side deduplication.

Delphi
uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  Net-Base.Multipart;

function PostMultipart(const Url: string; const Token: string; const MP: TMultipartFormData;
  const SpoolFile: string = ''): IHTTPResponse;
var
  Client: THTTPClient;
  Body: TStream;
  ContentLen: Int64;
  Req: IHTTPRequest;
begin
  Client := THTTPClient.Create;
  try
    // Timeouts: je nach Datei und Leitung realistisch setzen.
    Client.ConnectionTimeout := 15000; // ms
    Client.ResponseTimeout := 600000;  // ms (10 min)

    Body := MP.BuildBodyStream(ContentLen, SpoolFile);
    try
      Req := Client.GetRequest('POST', Url);
      Req.SourceStream := Body;

      Req.AddHeader('Content-Type', MP.ContentType);
      // Manche Server oder Proxies erwarten Content-Length zwingend.
      Req.AddHeader('Content-Length', ContentLen.ToString);

      if Token <> '' then
        Req.AddHeader('Authorization', 'Bearer ' + Token);

      // Optional: wenn der Server sauber JSON liefert, kann Accept helfen.
      Req.AddHeader('Accept', 'application/json');

      Result := Client.Execute(Req, nil);
    finally
      Body.Free;
    end;
  finally
    Client.Free;
  end;
end;

Practical pitfalls

  • Stream position: If the file stream is not at position 0, you upload only the remainder. The builder therefore enforces Seek(0).
  • Chunked vs. Content-Length: Some gateways (or older server stacks) reject chunked encoding. This is a common legacy case in process-integrated software solutions. Spool-to-File is pragmatic in that case.
  • CRLF: Multipart expects CRLF (#13#10), not just LF. Some servers are tolerant, others are not.
  • Content-Type per file: If you send application/octet-stream as a blanket, that is often OK. If the server validates (e.g. PDF), set it correctly. In Delphi you can implement MIME mapping via your own table or OS functions, but do not rely blindly on file extensions.

Debugging: reproducible wire dump without TLS termination

With HTTPS you do not see the body in the proxy if you are not allowed to deploy a MitM (e.g. a Fiddler certificate). That is normal in enterprise environments. The Builder helps because you own the complete body as a stream and (for a spool file) also have it available as a file.

Recommended procedure:

  1. Write the spool body to a temporary file.
  2. Log Content-Type including boundary and Content-Length.
  3. Optionally produce a curl repro for Support/DevOps: you do not need to reproduce the body 1:1 here, but you can mirror the parameters and file(s).

Important: Never log production tokens or personal data. In many business software integrations that is precisely the compliance-relevant part.

Variants: multiple files, optional fields, servers with „odd“ expectations

Multiple files under the same field name

Many APIs expect files[] or the same name repeated multiple times. The Builder supports this directly: call AddFile multiple times with the same FieldName. Whether you use files, files[] or attachments is purely a server convention.

Server requires exactly „application/json“ as an additional part

A common pattern: a JSON metadata block plus a file. Then send the JSON as a field part, but with Content-Type: application/json; charset=utf-8. That is not a „form field“ in the UI sense, but it maps cleanly in multipart:

Delphi
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');

Legacy: server accepts only filename, not filename*

Then a fallback using filename helps. However, if the server decodes non-ASCII in filename incorrectly, a robust approach often remains: ignore filenames server-side and instead send an additional originalName field in the JSON.

Context for modernization and operations

In established Delphi landscapes multipart is often at the periphery: an interface to DMS, archive, ticketing, a customer portal or an internal REST-server. That is exactly where pressure arises from new security requirements (TLS, gateways, proxies) and from larger file sizes.

The presented approach is particularly worthwhile when:

  • You need to debug uploads reproducibly (operations/administration)
  • You want/need to avoid chunked transfer encoding
  • Filenames/encodings actually occur in practice (umlauts, spaces, parentheses)
  • Retry/idempotency should be solved conceptually and cleanly

It is less worthwhile if you only send small files to a tolerant server and require no operational transparency. In that case a simple high-level solution is sufficient — until the first „odd“ file arrives from the business unit.

Conclusion: Stable multipart upload is a streaming and operations problem

A clean multipart/form-data upload in Delphi is less a question of „which component“ than of control: boundary, CRLF, filename, Content-Type and above all a deterministic body stream. Those who build this cleanly early save time later in debugging loops with API gateways and reverse proxies.

Limitation of the approach: If you need to upload extremely large files (several GB) without spooling and without Content-Length, the topic of streaming without precomputation becomes relevant – in that case target servers and infrastructure must reliably support Chunked, and you need a different debugging concept. For many integrations in digital enterprise solutions, however, the builder shown here is precisely the pragmatic midpoint between robustness, traceability and controllable resource consumption.

If you are tied to an evolved Delphi integration where uploads fail sporadically or only ‚for some files‘, this is usually an indicator of exactly those boundary conditions. For targeted support in analysis, modernization or operational clarification you can reach us here:

In the technical context, Delphi Thttpclient and REST API file upload also play an important role when integrations, data flows and ongoing development need to work together cleanly.

Discuss a project or modernization initiative with Net-Base.

Share post

Share this post directly

LinkedIn, X, XING, Facebook, WhatsApp and email are available immediately. For Instagram, we will prepare the link and a short caption immediately.

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.