Automating Enterprise SFTP Ingestion with AWS Transfer Family

A partner drops a few hundred gigabytes of CSV on an SFTP server every morning. You need it in S3, reliably, before the analysts arrive. This is one of the oldest problems in data engineering and it is still routinely solved by spinning up a compute cluster whose only job is to be a very expensive FTP client.

There is a better primitive. AWS Transfer Family SFTP connectors pull from a remote server straight into S3 as a managed service — no cluster, no memory ceiling, no code in the data path. Below is the pipeline we build around it: scheduled discovery, batched transfer, and an event contract that routes success and failure to different places.

Why not just use Glue?

Because moving bytes is not a data-processing problem, and paying for Spark to do it has three specific costs:

Spark job as SFTP clientTransfer Family connector
Maximum file sizeBounded by executor memory or local disk; a large file needs deliberate streaming code150 GB per file — managed streaming, no compute to provision
Cost modelDPU-hours for the whole transfer window, mostly spent idle on network I/OPer-transfer, no compute to provision
Failure modeOOM or task retry storms halfway through a downloadService-level retry, then a discrete failure event
Code you ownConnection handling, resumption, credential rotationIAM policy and a secret ARN

Keep Spark for the part that actually needs it — parsing, validating, joining — and let it start from a file that is already in S3. If you are building that downstream half, our Glue and PySpark field guide covers it.

The architecture

Architecture diagram: an EventBridge schedule invokes a Lambda monitor, which lists the remote directory and starts an AWS Transfer Family SFTP connector transfer into Amazon S3. The connector emits retrieve-completed and retrieve-failed events, which EventBridge routes to a Step Functions state machine and a Slack notifier Lambda respectively.
Three stages: pull on a schedule, react to the connector's events, act on the outcome.
  1. EventBridge Scheduler fires on a cron expression.
  2. A Lambda asks the connector to list the remote directory, and a second one requests exactly the files that are new.
  3. Transfer Family streams them into the S3 landing zone.
  4. The connector emits an event per outcome. EventBridge rules send success to Step Functions and failure to a Slack notifier.

Nothing in the data path is code you maintain. The Lambdas decide what to move; they never touch the bytes.

The connector: credentials and IAM

Two things the connector needs, and one that is easy to skip:

  • A secret in Secrets Manager holding the partner's password or private key.
  • An access role the connector assumes to write to your bucket and read that secret.
  • The partner's host key, pinned in trusted_host_keys. Skip this and you have no protection against a man-in-the-middle on a connection that carries your vendor credentials.
Pin the host key

Fetch the host key once, out of band, and record where it came from. Rotating it later is a deliberate change with a paper trail — not something to fix by pasting whatever the server offers on the morning it breaks.

Discover files, do not guess them

The tempting shortcut is to build tomorrow's filename from today's date:

The pattern that breaks in week three
target = f"/exports/vendor_feed_{datetime.utcnow():%Y_%m_%d}.csv"

It works until the vendor is an hour late, ships two files, backfills a missed day, or changes the naming convention without telling you. Then the job either silently transfers nothing or fails on a file that was never going to exist.

StartDirectoryListing asks the remote server what is actually there. It writes the answer to S3 as <connector-id>-<listing-id>.json and emits its own completion event.

Lambda — list the remote directory
import os, json, boto3

transfer = boto3.client("transfer")

CONNECTOR_ID   = os.environ["CONNECTOR_ID"]
REMOTE_DIR     = os.environ["REMOTE_DIR"]        # e.g. /exports/daily
LISTING_OUTPUT = os.environ["LISTING_OUTPUT"]    # e.g. /my-bucket/_listings


def lambda_handler(event, context):
    """Ask the connector what is actually on the remote server.

    Deliberately does NOT guess a filename from today's date: vendors are late,
    rename files, and backfill. The listing is written to S3 and a
    'SFTP Connector Directory Listing Completed' event follows.
    """
    resp = transfer.start_directory_listing(
        ConnectorId=CONNECTOR_ID,
        RemoteDirectoryPath=REMOTE_DIR,
        OutputDirectoryPath=LISTING_OUTPUT,
        MaxItems=1000,                 # one level deep only; no recursion
    )
    print(json.dumps({"listing_id": resp["ListingId"], "output_file": resp["OutputFileName"]}))
    return {"listingId": resp["ListingId"], "outputFileName": resp["OutputFileName"]}
Note

The listing is one level deep and capped by MaxItems (default 1,000). If the partner nests by date, list the specific subdirectory rather than expecting recursion.

The completion event tells you exactly where the listing landed:

Event — Directory Listing Completed
{
  "source": "aws.transfer",
  "detail-type": "SFTP Connector Directory Listing Completed",
  "detail": {
    "operation": "LIST",
    "connector-id": "c-0123456789abcdef0",
    "listing-id": "6666abcd-11aa-22bb-cc33-EXAMPLE0000",
    "remote-directory-path": "/exports/daily",
    "output-file-location": {
      "domain": "S3",
      "bucket": "delantech-sftp-landing-zone",
      "key": "_listings/c-0123456789abcdef0-6666abcd-11aa-22bb-cc33-EXAMPLE0000.json"
    },
    "status-code": "COMPLETED"
  }
}
API response is not the event

Watch the shape here. StartDirectoryListing's API response returns ListingId and OutputFileName. The event that follows returns output-file-location, an object of {domain, bucket, key}. Same information, different shape, different place — and reaching for detail['output-file-name'] in the handler raises a KeyError at runtime, not at deploy time.

The useful side effect: because the event carries the bucket, the consuming Lambda needs no bucket name in its configuration for that read.

Starting the transfer

The second Lambda reacts to the listing event, diffs against what has already landed, and requests just the new paths. Files are submitted in batches of up to 10 paths per request.

Know the two limits

Two service limits shape this code, and both bite in production rather than in testing:

  • RetrieveFilePaths accepts at most 10 paths per call. Pass the whole list and the request fails validation — on the first morning the vendor ships eleven files, which is precisely when nobody is watching. Chunk it.
  • 150 GB is the maximum size of a single file a connector will transfer. Generous, but not unlimited: if a partner ever ships one enormous archive, ask them to split it at source rather than discovering the ceiling at 3am.
Lambda — request the new files

Skipping files already in the landing zone is what makes a re-run safe: the schedule can fire twice and the second run queues nothing.

import os, json, boto3

s3       = boto3.client("s3")
transfer = boto3.client("transfer")

CONNECTOR_ID = os.environ["CONNECTOR_ID"]
LOCAL_DIR    = os.environ["LOCAL_DIR"]       # /bucket/prefix in S3

# The landing zone is described by LOCAL_DIR, so derive it rather than carrying
# a second, separately-configurable bucket name that can drift out of step.
LANDING_BUCKET, _, LANDING_PREFIX = LOCAL_DIR.lstrip("/").partition("/")

MAX_PATHS_PER_CALL = 10          # hard AWS limit on RetrieveFilePaths


def lambda_handler(event, context):
    """Triggered by the directory-listing-completed event.

    Reads the listing JSON the connector wrote, decides what is new, and asks
    for exactly those paths. Everything already in the landing zone is skipped,
    which is what makes a re-run safe.
    """
    # The EVENT gives output-file-location {domain, bucket, key}. Do not reach
    # for output-file-name — that belongs to the StartDirectoryListing API
    # RESPONSE, not to this event, and is not present here.
    location = event["detail"]["output-file-location"]

    listing = json.loads(
        s3.get_object(Bucket=location["bucket"], Key=location["key"])["Body"].read()
    )
    remote_files = [f["filePath"] for f in listing.get("files", [])]

    wanted = [p for p in remote_files if p.endswith(".csv") and not already_landed(p)]
    if not wanted:
        print("nothing new on the remote server")
        return {"queued": 0}

    # StartFileTransfer accepts a MAXIMUM OF 10 paths per call, so chunk.
    # Passing the whole list fails validation the first morning the vendor
    # ships eleven files — which is exactly when you are not watching.
    transfer_ids = []
    for i in range(0, len(wanted), MAX_PATHS_PER_CALL):
        batch = wanted[i:i + MAX_PATHS_PER_CALL]
        resp = transfer.start_file_transfer(
            ConnectorId=CONNECTOR_ID,
            RetrieveFilePaths=batch,
            LocalDirectoryPath=LOCAL_DIR,
        )
        transfer_ids.append(resp["TransferId"])

    print(json.dumps({"transfer_ids": transfer_ids, "count": len(wanted)}))
    return {"transferIds": transfer_ids, "queued": len(wanted)}


def already_landed(remote_path):
    """True if this file is already in the landing zone."""
    key = f"{LANDING_PREFIX}/{os.path.basename(remote_path)}".lstrip("/")
    try:
        s3.head_object(Bucket=LANDING_BUCKET, Key=key)
        return True
    except s3.exceptions.ClientError:
        return False

The event contract (where most builds break)

This is the part that costs people an afternoon. Transfer Family does not emit one generic transfer event with a status field. It emits a different detail-type per outcome:

detail-typeMeaning
SFTP Connector File Retrieve CompletedA pull finished successfully
SFTP Connector File Retrieve FailedA pull failed
SFTP Connector File Send CompletedA push finished (the other direction)
SFTP Connector File Send FailedA push failed
SFTP Connector Directory Listing CompletedA listing finished
Three mistakes we see

Three consequences, all of which produce a pipeline that looks wired up and does nothing.

  • Retrieve and send are different events. A rule written for Send never fires on a pull.
  • You filter on detail-type, not on a status field. Matching one detail-type and then branching on a status inside detail silently matches nothing.
  • The detail object is flat. There is no file-transfers[] array to index into — the file is described by file-path and local-directory-path directly on detail. A JSON path like $.detail.file-transfers[0].source-file-path resolves to nothing, and an input transformer built on it hands your state machine empty strings rather than failing loudly.

The payload itself, which is worth reading once before writing any rule:

Event — File Retrieve Completed
{
  "version": "0",
  "source": "aws.transfer",
  "detail-type": "SFTP Connector File Retrieve Completed",
  "detail": {
    "operation": "RETRIEVE",
    "connector-id": "c-0123456789abcdef0",
    "transfer-id": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111",
    "file-transfer-id": "11112222-3333-4444-5555-EXAMPLE22222",
    "url": "sftp://partner.example.com",
    "file-path": "/exports/daily/orders_2026-04-25.csv",
    "local-directory-path": "/delantech-sftp-landing-zone/imports",
    "status-code": "COMPLETED",
    "bytes": 63533
  }
}
Event — File Retrieve Failed

Failures carry both failure-code (a stable enum such as RETRIEVE_FILE_NOT_FOUND or CONNECTION_ERROR) and a human-readable failure-message. Alert on the message; branch on the code.

{
  "source": "aws.transfer",
  "detail-type": "SFTP Connector File Retrieve Failed",
  "detail": {
    "operation": "RETRIEVE",
    "connector-id": "c-0123456789abcdef0",
    "transfer-id": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111",
    "file-path": "/exports/daily/orders_2026-04-25.csv",
    "status-code": "FAILED",
    "failure-code": "RETRIEVE_FILE_NOT_FOUND",
    "failure-message": "File not found on remote server"
  }
}

Note transfer-id and file-transfer-id are different things: the first identifies your StartFileTransfer call, the second identifies one file within it. Since one event is emitted per file, file-transfer-id is what you deduplicate on.

Filter by connector id as well, or every connector in the account will trigger this pipeline:

EventBridge pattern — success
{
  "source": ["aws.transfer"],
  "detail-type": ["SFTP Connector File Retrieve Completed"],
  "detail": {
    "connector-id": ["c-0123456789abcdef0"]
  }
}
EventBridge pattern — failure
{
  "source": ["aws.transfer"],
  "detail-type": ["SFTP Connector File Retrieve Failed"]
}

Routing success to Step Functions

Send the state machine a small, stable payload rather than the raw AWS event. An input transformer does this in the rule, so your state machine is not full of $.detail.* path expressions that have to be revisited whenever the event schema changes.

Input transformer
input_paths = {
  destination = "$.detail.local-directory-path"
  source      = "$.detail.file-path"
  transferId  = "$.detail.transfer-id"
}

input_template = {
  "pipeline":    "delantech-sftp-ingestion",
  "destination": "<destination>",
  "source":      "<source>",
  "transferId":  "<transferId>"
}
Note

One event is emitted per file transfer, so a batch of 40 files starts 40 executions. If your downstream work is per-batch rather than per-file, aggregate first — a Step Functions Distributed Map over the landing prefix, or an SQS queue with a batch window — rather than letting 40 concurrent executions fight over the same warehouse.

Routing failure to Slack

The failure event carries failure-message, which is usually the remote server's own words: bad credentials, no such file, connection reset. Put it in the alert — it is the difference between a page and a fix.

Lambda — Slack notifier

Note the notifier swallows its own errors. If Slack is unreachable you want the transfer failure recorded, not a second failure obscuring it.

import os, json, urllib.request, urllib.error

WEBHOOK = os.environ["SLACK_WEBHOOK_URL"]


def lambda_handler(event, context):
    # The detail is FLAT — one event per file, no file-transfers[] array.
    d = event.get("detail", {})
    connector   = d.get("connector-id", "unknown")
    transfer_id = d.get("transfer-id", "n/a")
    file_path   = d.get("file-path", "n/a")
    code        = d.get("failure-code", "UNKNOWN")
    failure     = d.get("failure-message") or "No reason supplied by the remote host."

    body = {
        "text": f"SFTP ingestion failure: {code}",
        "attachments": [{
            "color": "#dc2626",
            "fields": [
                {"title": "Connector",    "value": connector,   "short": True},
                {"title": "Transfer ID",  "value": transfer_id, "short": True},
                {"title": "File",         "value": file_path,   "short": False},
                {"title": "Failure code", "value": code,        "short": True},
                {"title": "Reason",       "value": failure,     "short": False},
            ],
        }],
    }

    req = urllib.request.Request(
        WEBHOOK,
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            return {"slack_status": r.getcode()}
    except urllib.error.URLError as e:
        # Never let the notifier mask the original failure — log and move on.
        print(f"could not reach Slack: {e}")
        return {"slack_status": "unreachable"}

The Terraform

main.tf

Trimmed to the parts that matter: the Lambda function and role definitions follow the usual pattern and are omitted.

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "landing" {
  bucket = "delantech-sftp-landing-zone"
}

# --- IAM for the connector: write to S3, read the SFTP credentials ---
resource "aws_iam_role" "connector" {
  name = "delantech-sftp-connector-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "sts:AssumeRole"
      Principal = { Service = "transfer.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "connector" {
  name = "delantech-sftp-connector-policy"
  role = aws_iam_role.connector.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:PutObject", "s3:GetObject", "s3:ListBucket"]
        Resource = [aws_s3_bucket.landing.arn, "${aws_s3_bucket.landing.arn}/*"]
      },
      {
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = [aws_secretsmanager_secret.sftp_creds.arn]
      }
    ]
  })
}

resource "aws_secretsmanager_secret" "sftp_creds" {
  name = "delantech/sftp/partner-credentials"
}

resource "aws_transfer_connector" "partner" {
  url          = "sftp://partner.example.com"
  access_role  = aws_iam_role.connector.arn

  sftp_config {
    user_secret_id      = aws_secretsmanager_secret.sftp_creds.arn
    trusted_host_keys   = [var.partner_host_key]
  }
}

# --- Route the two outcomes to two different places ---
resource "aws_cloudwatch_event_rule" "retrieve_completed" {
  name = "delantech-sftp-retrieve-completed"
  event_pattern = jsonencode({
    source        = ["aws.transfer"]
    "detail-type" = ["SFTP Connector File Retrieve Completed"]
    detail        = { "connector-id" = [aws_transfer_connector.partner.id] }
  })
}

resource "aws_cloudwatch_event_target" "to_step_functions" {
  rule      = aws_cloudwatch_event_rule.retrieve_completed.name
  target_id = "StepFunctions"
  arn       = aws_sfn_state_machine.processor.arn
  role_arn  = aws_iam_role.events_invoke.arn

  input_transformer {
    input_paths = {
      destination = "$.detail.local-directory-path"
      source      = "$.detail.file-path"
      transferId  = "$.detail.transfer-id"
    }
    input_template = jsonencode({
      pipeline    = "delantech-sftp-ingestion"
      destination = "<destination>"
      source      = "<source>"
      transferId  = "<transferId>"
    })
  }
}

resource "aws_cloudwatch_event_rule" "retrieve_failed" {
  name = "delantech-sftp-retrieve-failed"
  event_pattern = jsonencode({
    source        = ["aws.transfer"]
    "detail-type" = ["SFTP Connector File Retrieve Failed"]
  })
}

resource "aws_cloudwatch_event_target" "to_slack" {
  rule      = aws_cloudwatch_event_rule.retrieve_failed.name
  target_id = "SlackNotifier"
  arn       = aws_lambda_function.slack_notifier.arn
}

resource "aws_lambda_permission" "allow_events" {
  statement_id  = "AllowExecutionFromEventBridge"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.slack_notifier.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.retrieve_failed.arn
}

# --- Schedule ---
resource "aws_scheduler_schedule" "daily" {
  name                         = "delantech-sftp-daily"
  schedule_expression          = "cron(0 6 * * ? *)"
  schedule_expression_timezone = "Europe/London"
  flexible_time_window { mode = "OFF" }

  target {
    arn      = aws_lambda_function.list_remote.arn
    role_arn = aws_iam_role.scheduler.arn
  }
}

Production considerations

  • Idempotency. S3 overwrites by default, so a re-run silently replaces a file that may already be downstream. The already_landed check above prevents the transfer; write to a date-partitioned prefix so a corrected re-delivery is a new object rather than a mutation.
  • The silent-success problem. If the vendor uploads nothing, this pipeline succeeds perfectly and delivers no data. Alert on absence — a scheduled check that the expected prefix has grown — not only on failure events.
  • Secrets. Keep the Slack webhook in Secrets Manager, not a Lambda environment variable. Environment variables are visible to anyone with lambda:GetFunctionConfiguration.
  • Large files. The service handles the streaming, but downstream Lambdas must not read the object into memory to inspect it. Use S3 Select, a range request, or hand the key to Step Functions and let Glue open it.
  • Retention on the landing zone. Raw vendor files are your replay capability. Lifecycle them to Glacier rather than deleting them.

Once the file is in S3, the interesting work starts. Our companion pieces cover the processing layer — Building Production Data Pipelines with AWS Glue and PySpark — and how to schedule the whole thing: Apache Airflow vs AWS Glue.

Running into these problems on your own platform?

We do free data platform assessments — we map your pipelines, surface quick wins, and hand you a 90-day engineering roadmap.

Book an assessment