How to Convert Inbound JSON Webhooks to Normalized CSV in FTP

Receive batches of JSON events over a webhook, flatten and reshape them into a tidy, normalized CSV file, and drop that file into an FTP folder that a legacy system picks up on its own schedule.

What This Integration Does

Many legacy back-office systems cannot consume modern JSON over HTTP. They expect a flat CSV file to appear in a folder, which they sweep on a timer. This Spojit workflow bridges that gap: a modern source posts a JSON batch to your workflow's webhook URL, and Spojit turns each nested event into one clean CSV row with consistent column names, then writes the file to an FTP drop folder. You get a dependable, auditable handoff without writing or hosting any glue code.

The workflow is triggered by an external HTTP POST to its Webhook URL. The parsed JSON body becomes the run input, available as {{ input }}. Spojit flattens each event, maps the fields you care about into a fixed column order, builds a CSV string, and uploads it as a uniquely named file over FTP. Each call produces a new file, so re-runs never overwrite earlier batches unless you deliberately reuse a file name. The webhook returns 202 with an executionId immediately, so the sender does not wait for the upload to finish.

Prerequisites

  • An FTP connection configured under Connections, with write access to the destination drop folder (for example /incoming/orders/).
  • A signing connection for webhook verification (schemes: Spojit, Shopify, GitHub, Slack, or Custom), so only your trusted source can post events. See Setting Up a Webhook Connection.
  • A sample JSON payload from your source so you know the exact field paths to map.
  • The legacy system's expected CSV layout: column names, column order, and delimiter.

Step 1: Add a Webhook Trigger

Start a new workflow and add a Trigger node. Set Trigger Type to Webhook and attach your signing connection so inbound posts are verified by HMAC. Save to reveal the workflow's Webhook URL, then point your source system at it. When an event batch arrives, the parsed JSON body is exposed as {{ input }}. A typical batch looks like this:

{
  "events": [
    {
      "id": "evt_001",
      "type": "order.created",
      "customer": { "id": "C-42", "email": "sam@example.com" },
      "order": { "total": 129.5, "currency": "AUD" },
      "occurredAt": "2026-06-21T03:15:00Z"
    },
    {
      "id": "evt_002",
      "type": "order.created",
      "customer": { "id": "C-77", "email": "lee@example.com" },
      "order": { "total": 58.0, "currency": "AUD" },
      "occurredAt": "2026-06-21T03:16:10Z"
    }
  ]
}

Your array of events is now available as {{ input.events }} for the next steps.

Step 2: Flatten Each Nested Event

Add a Loop node in ForEach mode iterating over {{ input.events }}. Inside the loop body, add a Connector node in Direct mode using the json connector and the flatten tool. Set data to the current item (for example {{ item }}). The flatten tool collapses nested objects into dot-notation keys, so customer.email and order.total become top-level fields:

{
  "id": "evt_001",
  "type": "order.created",
  "customer.id": "C-42",
  "customer.email": "sam@example.com",
  "order.total": 129.5,
  "order.currency": "AUD",
  "occurredAt": "2026-06-21T03:15:00Z"
}

Collect the flattened rows into a single array using a Transform node, so you end with one array of flat objects ready for CSV conversion. If your source already sends a flat array, you can skip the loop and the flatten step and feed {{ input.events }} straight into Step 3.

Step 3: Convert the Flat Array to CSV

Add a Connector node in Direct mode using the csv connector and the from-json tool. Map data to your array of flattened objects. Use the columns field to pin the exact column order the legacy system expects, which also drops any flattened key you do not want exported:

{
  "data": "{{ flattened.rows }}",
  "columns": [
    "id",
    "type",
    "customer.id",
    "customer.email",
    "order.total",
    "order.currency",
    "occurredAt"
  ],
  "delimiter": ",",
  "header": true
}

The tool returns the CSV string on csv and the written row count on rows. Keep header: true if the legacy importer expects a header line; set it to false if it expects data rows only.

Step 4: Rename and Reorder Columns for the Legacy Schema

Legacy systems often expect specific header names that differ from your JSON field paths. Add another Connector node in Direct mode using the csv connector and the transform tool. Feed it the CSV from Step 3 on csv, then use rename to map dotted keys to the legacy header names and select to lock the final column order:

{
  "csv": "{{ csv_build.csv }}",
  "rename": {
    "customer.id": "CUSTOMER_ID",
    "customer.email": "EMAIL",
    "order.total": "TOTAL",
    "order.currency": "CURRENCY",
    "occurredAt": "EVENT_TIME"
  },
  "select": ["id", "type", "CUSTOMER_ID", "EMAIL", "TOTAL", "CURRENCY", "EVENT_TIME"]
}

If you would rather drop exact duplicate rows that can appear in noisy batches, run the csv connector's dedupe tool before this step, optionally passing columns such as ["id"] to dedupe on the event id alone.

Step 5: Build a Unique File Name

Add a Connector node in Direct mode using the date connector with the format tool to produce a timestamp, or use the uuid connector with the generate tool for a collision-proof suffix. Combine the pieces with a Transform node into a remote path such as:

/incoming/orders/events-{{ ts.result }}-{{ id.result }}.csv

A unique name per run keeps each batch as its own file, which is exactly what a sweep-and-delete legacy importer expects. Reuse a fixed name only if the target system explicitly wants overwrite-in-place behavior.

Step 6: Upload the CSV over FTP

Add a final Connector node in Direct mode using the ftp connector and the upload-file tool. Map path to the remote path from Step 5 and content to the normalized CSV string from Step 4. Because the CSV is plain text, leave encoding at its default of utf8 (reserve base64 for binary files):

{
  "path": "{{ filename.result }}",
  "content": "{{ csv_final.csv }}",
  "encoding": "utf8"
}

The tool overwrites the file if that exact path already exists, so your unique file name from Step 5 is what guarantees each batch lands separately. If you want the sender to receive a confirmation body instead of the default 202 acknowledgement, add a Response node returning the uploaded path and the row count.

Tips

  • Use the ftp connector's verify-connection tool once during setup, or create-directory if the drop folder may not exist yet, so the first real batch does not fail on a missing path.
  • Keep the columns list in from-json explicit rather than relying on the keys of the first object. That way a batch where the first event is missing an optional field still produces every expected column.
  • Normalize timestamps with the date connector's format tool before the CSV step if the legacy system needs a fixed date format rather than ISO 8601.
  • Scaffold the whole pipeline quickly by asking Miraxa, the intelligent layer across your automation, something like "Build a webhook workflow that flattens {{ input.events }}, converts it to CSV, and uploads it with the ftp connector," then fine-tune each node in the properties panel.

Common Pitfalls

  • Webhook replays: senders may retry a batch and post the same events twice. Enable opt-in dedup via an event-id header on the trigger, or dedupe rows with the csv connector's dedupe tool on a stable key like the event id.
  • Schema drift: if the source adds or renames nested fields, your dotted column names change. Pin the output with columns in from-json and select in transform so unexpected fields are simply dropped instead of shifting the layout.
  • Delimiter mismatch: confirm whether the legacy importer wants a comma, semicolon, or tab. Set delimiter consistently across the from-json and transform steps to avoid a malformed file.
  • Overwritten files: reusing a static path means upload-file replaces the previous batch before the legacy system reads it. Always include a timestamp or UUID in the file name unless overwrite is intended.

Testing

Before pointing production traffic at the webhook, post a small two-event batch with a tool like curl or your source system's test mode, then open Executions to inspect each step's output. Confirm the flattened objects have the dotted keys you expect, that the CSV string carries the right header row and column order, and that the file name is unique. Verify the file actually landed by running the ftp connector's list-directory tool against your drop folder, or download-file to read the uploaded content back. Once a small batch round-trips cleanly into the legacy folder, enable the workflow and let real traffic flow.

Learn More

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.