> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getdialed.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Send and wait for a reply

> Email a person asking for a CSV, pause the flow until they answer, parse their attachment straight into a record set, and hand it to the flow that does the work

This guide builds one flow that stops and asks a human for something, then carries on with what they sent back. Three steps:

1. **Ask** — email the owner requesting this week's list, and pause.
2. **Parse** — turn the CSV they attached into a record set, straight from the stored file.
3. **Hand off** — start the flow that processes the list.

The pause in step 1 is durable. It can last a day and cost nothing while it does.

There is no fetch-the-attachment step in the middle. When the reply arrives, its attachments are stored at that moment and the flow is handed a **file reference** for each one — so parsing goes directly from the reply to the record set.

<Info>
  You'll need an API key (see [Authentication](/authentication)) and a Postmark credential (see [transactional email](/concepts/transactional-email#connecting-postmark)). Export both:

  ```bash theme={null}
  export BASE_URL="https://api.getdialed.ai/v1"
  export GETDIALED_API_KEY="<your key>"
  ```
</Info>

<Warning>
  **Before you start: the reply mailbox needs a domain that accepts incoming mail.** For Postmark that means an **inbound domain bound to the server your credential belongs to**, with its `MX` record pointing at `inbound.postmarkapp.com` — and it must be a **dedicated subdomain**, never a domain that already receives your real mail. Read [where mail is sent from](/concepts/transactional-email#where-mail-is-sent-from) first; publishing that record on the wrong domain takes over its mail.

  Without inbound mail, everything below saves, runs and sends — and every wait ends on its timeout, because the replies never arrive.
</Warning>

<Steps>
  <Step title="Ask, and wait">
    The whole feature is one task. `wait_for_reply` in the `execution_config` turns the send into a send-and-wait; `reply_to_base` is the mailbox answers come back to, and the platform builds a unique per-send reply address from it so the answer can be matched to this exact step.

    ```json theme={null}
    {
      "step_id": "step_ask",
      "name": "Ask the owner for this week's list",
      "tasks": [
        {
          "task_id": "task_ask",
          "task_name": "Request the list by email",
          "platform_id": "postmark",
          "service_id": "postmark__email",
          "action_id": "postmark__email__send_and_wait",
          "connection_id": "cred_e5f6g7h8",
          "execution_type": "immediate",
          "execution_config": {
            "wait_for_reply": true,
            "timeout": "24h",
            "on_timeout": "continue",
            "on_timeout_output": { "answered": false },
            "sender_match": true
          },
          "parameters": {
            "from": "requests@example.com",
            "from_name": "Acme Operations",
            "reply_to_base": "requests@reply.example.com",
            "to": ["{{ input.owner_email }}"],
            "subject": "Please send this week's list",
            "text_body": "Hit Reply on this message and attach the list as a CSV. Please leave the subject line as it is."
          }
        }
      ]
    }
    ```

    The parameters this step takes are the same set the plain [Send Email](/concepts/transactional-email#sending) step takes, with one substitution:

    | Parameter                 | Required | What it is                                                                                                                                                                 |
    | ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `to`                      | yes      | The recipient addresses. At least one, no more than 50. The reply is authorized against the **first** one.                                                                 |
    | `from`                    | yes      | The sending address. It must be covered by a confirmed sender signature, or belong to a sending domain your Postmark account has verified.                                 |
    | `from_name`               |          | The display name shown beside the sending address.                                                                                                                         |
    | `reply_to_base`           | yes      | The **mailbox** replies come back to. You supply the plain address; the platform adds the identifying part per send. Do not supply an address that already contains a `+`. |
    | `cc` / `bcc`              |          | Additional recipients, visible and hidden respectively. No more than 50 each.                                                                                              |
    | `subject`                 |          | The subject line. It also carries the backup correlation tag.                                                                                                              |
    | `text_body` / `html_body` | one of   | The message body. Supply either or both — a send with neither is refused.                                                                                                  |

    There is deliberately **no `reply_to`** here. The plain send has one; this step writes it itself, because it is the address the wait is listening on. Two answers to "where do replies go" would mean the first author to set one silently disabled the wait.

    What each part of the `execution_config` does:

    | Field               | Effect                                                                                                                             |
    | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
    | `wait_for_reply`    | Turns this into a send-and-wait. Without it the message goes out and the flow carries straight on.                                 |
    | `timeout`           | How long to wait. `"24h"`, `"48h"`, `"30m"` — durable either way.                                                                  |
    | `on_timeout`        | `continue` (resolve with `on_timeout_output`), `abort_step`, or `abort_execution`.                                                 |
    | `on_timeout_output` | With `continue`, the value the step returns when nobody answered. Make it something the rest of the flow can branch on.            |
    | `sender_match`      | `true` (default) requires the reply to come from the address you asked. Set `false` to accept a forwarded answer from a colleague. |

    <Warning>
      **`execution_type` must be `immediate`.** Combining `wait_for_reply` with `scheduled` or `windowed` is refused with a `422` naming the field when you save — see [the v1 boundary](/concepts/human-in-the-loop#the-v1-boundary). Put the delay in an earlier step if you need one.
    </Warning>

    Ask the person to reply rather than to compose a new message, and to leave the subject alone. Both the reply address and the subject tag carry the identifier that matches their answer to this step, and a reply carrying neither cannot be matched — the flow just waits out its timeout as though nobody wrote back.
  </Step>

  <Step title="Guard the no-answer path">
    `on_timeout: continue` means the flow proceeds either way, so say what happens when nobody answered before you start reading an attachment that is not there:

    ```json theme={null}
    {
      "step_id": "step_ask",
      "name": "Ask the owner for this week's list",
      "tasks": [ "..." ],
      "exit_conditions": [
        {
          "condition": "{{ step_ask.output.answered }} == false",
          "action": "abort_execution",
          "reason": "No reply within 24 hours"
        }
      ]
    }
    ```

    `answered` is there because `on_timeout_output` put it there — it exists only on the timeout path. The [resume payload](/concepts/human-in-the-loop#what-lands-in-context-on-resume) carries no such field, so the condition is false whenever somebody actually replied.
  </Step>

  <Step title="Parse the attachment into a record set">
    When the reply arrives, each attached file is stored and the step's output carries a **file reference** for it — never the bytes:

    ```json theme={null}
    {
      "attachments": [
        {
          "file_id": "file_0a1b2c3d4e5f6071",
          "filename": "week-34.csv",
          "content_type": "text/csv",
          "size_bytes": 4821,
          "sha256": "9f86d0818..."
        }
      ]
    }
    ```

    Bytes never ride the resume, and that is structural rather than a tuning choice: a resume payload is persisted as workflow history, and history has a hard payload **error** ceiling rather than a slow path. A payload that grows with the attachment is a payload that eventually fails the very resume it exists to carry. So the bytes are written to storage at the moment the reply arrives, and what travels is an id.

    `getdialed__records__parse_csv` takes that id directly and produces a [record set](/concepts/record-transforms). The file's contents are read inside the step and never enter the flow:

    ```json theme={null}
    {
      "step_id": "step_parse",
      "name": "Read the CSV into records",
      "tasks": [
        {
          "task_id": "task_parse",
          "task_name": "Parse CSV",
          "platform_id": "getdialed",
          "service_id": "getdialed__records",
          "action_id": "getdialed__records__parse_csv",
          "parameters": {
            "file_id": "{{ step_ask.output.attachments[0].file_id }}",
            "has_header": true,
            "delimiter": ","
          }
        }
      ],
      "exit_conditions": [
        {
          "condition": "{{ step_parse.output.row_count }} == 0",
          "action": "abort_execution",
          "reason": "The attachment held no rows"
        }
      ]
    }
    ```

    <Note>
      **Supply either `file_id` or `text` — never both.** They are the two doors onto the same parser. Supplying neither is refused by name, and so is supplying both: an author who bound both would otherwise get a set built from whichever input the step happened to prefer, with the other one looking as though it had been honoured.
    </Note>

    A set of zero records is never created, so `record_set_id` is empty when the file held nothing. Guard on `row_count` rather than assuming a set exists.

    `parse_csv` reads up to **1 MB** in this mode, refuses a file that is not UTF-8 text, and refuses a file that is not one of yours. A file too large is refused rather than partly read — a truncated CSV parses cleanly and produces a silently short record set, which is the worst available outcome here.

    ### If you want the text itself

    Parsing straight from the file is the shortest correct chain, and for a CSV it is the one to use. Where the flow needs the **contents** — to branch on them, to pass them to an HTTP step, to put them in another message — insert `getdialed__files__read_text` between the two steps and bind its output instead:

    ```json theme={null}
    {
      "step_id": "step_read",
      "name": "Read the attachment as text",
      "tasks": [
        {
          "task_id": "task_read",
          "task_name": "Read file as text",
          "platform_id": "getdialed",
          "service_id": "getdialed__files",
          "action_id": "getdialed__files__read_text",
          "parameters": {
            "file_id": "{{ step_ask.output.attachments[0].file_id }}"
          }
        }
      ]
    }
    ```

    It returns `text`, `size_bytes`, `sha256`, `content_type`, `filename` and `truncated`. Then bind `"text": "{{ step_read.output.text }}"` on the parse step instead of `file_id`.

    Two limits to know before you choose this shape. `read_text` reads files up to **512 KB** — smaller than `parse_csv`'s own 1 MB, because this text has to travel through the flow and `parse_csv`'s does not. And it **refuses rather than truncates**: a file over the cap fails the step by name, and `truncated` is always `false` on a successful read. A file that is not valid UTF-8 is refused too, rather than handed back as replacement characters that would read as a successful parse.
  </Step>

  <Step title="Hand the set to the flow that does the work">
    `getdialed__utils__trigger_flow` starts another definition with the set. `bulk` gives that flow the whole list in one run; `per_record` starts one run per row.

    ```json theme={null}
    {
      "step_id": "step_handoff",
      "name": "Start the processing flow",
      "tasks": [
        {
          "task_id": "task_trigger",
          "task_name": "Trigger with the parsed records",
          "platform_id": "getdialed",
          "service_id": "getdialed__utils",
          "action_id": "getdialed__utils__trigger_flow",
          "parameters": {
            "definition_id": "def_a1b2c3d4",
            "mode": "bulk",
            "record_set_id": "{{ step_parse.output.record_set_id }}"
          }
        }
      ]
    }
    ```

    Records travel [by reference](/concepts/record-transforms#how-the-records-travel), so this hands over an identifier rather than the rows — the size of the list does not change the shape of the flow.
  </Step>

  <Step title="Save it and trigger it">
    Create the definition with the steps in order:

    ```bash theme={null}
    curl -X POST "$BASE_URL/flows/definitions" \
      -H "X-API-Key: $GETDIALED_API_KEY" \
      -H "Content-Type: application/json" \
      -d @definition.json
    ```

    Then trigger it:

    ```bash theme={null}
    curl -X POST "$BASE_URL/flows/definitions/def_9a8b7c6d/trigger" \
      -H "X-API-Key: $GETDIALED_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "input_data": { "owner_email": "owner@example.com" } }'
    ```
  </Step>

  <Step title="Watch it park and resume">
    Read the execution while the ask step is waiting:

    ```bash theme={null}
    curl "$BASE_URL/flows/executions/exec_aabbccdd" \
      -H "X-API-Key: $GETDIALED_API_KEY"
    ```

    ```json theme={null}
    {
      "id": "exec_aabbccdd",
      "status": "waiting",
      "started_at": "2026-08-25T09:00:00Z",
      "completed_at": null
    }
    ```

    `waiting` means *parked on an answer* — non-terminal, and not stuck. It returns to `running` the moment the wait ends, whichever way it ends. `started_at` keeps pointing at the real start of the run, so a two-day wait reads as a two-day run rather than a two-second one.

    To watch it live instead of polling, [stream the execution](/guides/stream-executions): you will see an `execution.waiting` frame when it parks and an `execution.resumed` frame when it continues. Neither closes the stream.
  </Step>

  <Step title="Reply, and read what came back">
    Reply to the message — from the address it was sent to, with the CSV attached, leaving the subject as it is. Within moments the run resumes, and the ask step's output is the [normalized reply](/concepts/human-in-the-loop#what-lands-in-context-on-resume):

    ```json theme={null}
    {
      "result": {
        "step_ask": {
          "output": {
            "channel": "email",
            "from_address": "owner@example.com",
            "from_name": "Sam Owner",
            "subject": "Re: Please send this week's list",
            "body": "Here you go.",
            "body_truncated": false,
            "attachments": [
              {
                "file_id": "file_0a1b2c3d4e5f6071",
                "filename": "week-34.csv",
                "content_type": "text/csv",
                "size_bytes": 4821,
                "sha256": "9f86d0818..."
              }
            ],
            "received_at": "2026-08-25T09:04:11Z",
            "sender_matched": true
          }
        },
        "step_parse": {
          "output": { "record_set_id": "rs_1a2b3c4d", "row_count": 117, "field_count": 4 }
        }
      }
    }
    ```

    `sender_matched` is reported whether or not you required it, so a flow that loosened the rule can still branch on who actually answered.

    The `sha256` on a stored attachment is **ours**: it was computed over the bytes as they were written, not copied from anything the sender claimed. Anything that reads the same file back can compute the same value.
  </Step>
</Steps>

## What changed

An earlier version of this guide showed a four-step chain with a **fetch-the-attachment** step in the middle, which read the file out of the provider's storage before the parse. That step is gone from this chain, and the reason is worth stating rather than quietly dropping:

* **Attachment bytes are now stored at the moment the reply arrives**, by the platform, into the platform's own file store. There is nothing left to fetch — the file already exists before the flow wakes up.
* **The reply's `attachments` entries therefore carry a `file_id`**, which every file-taking step accepts, instead of a provider address that only one provider-specific step could use.
* **The old chain also depended on the provider publishing a content hash** for the attachment, and refused without one. That refusal was correct — a download that cannot be verified should not happen — but it meant the chain could not complete at all where the hash was absent.

**If you have a flow written against the old chain:** delete the fetch step and re-bind the parse step's input to `{{ <ask-step>.output.attachments[0].file_id }}`. If that flow needs the text rather than the records, replace the fetch step with `getdialed__files__read_text` on the same `file_id`, as shown above.

## When it does not resume

Almost every "the reply did nothing" report comes down to one of these:

| Symptom                                                   | Likely cause                                                                                                                                                                                                  |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every wait ends on its timeout, and no reply ever arrives | The reply domain has no inbound mail — the `MX` record is missing, or the domain is not bound as an inbound domain on the server your credential belongs to.                                                  |
| One reply was ignored, others work                        | The subject tag was edited, or the reply was composed as a **new** message rather than a reply, so neither carrier survived.                                                                                  |
| A reply from a colleague was ignored                      | `sender_match` is `true` and the answer came from somewhere other than the address you asked. Set it to `false` for delegated answers.                                                                        |
| The step failed instead of waiting                        | The send itself failed. A failed send never enters the wait — check the step's error, and see [suppressed recipients](/concepts/transactional-email#suppressed-recipients) if the address had bounced before. |
| The parse step failed on a file that looked fine          | The attachment is not UTF-8 text, is over the size cap, or is not a CSV at all. Both size caps refuse rather than truncate, so the failure is loud by design.                                                 |
| `attachments` is empty on a reply that had a file         | The sender embedded the file inline in the message body rather than attaching it, or the message carried no file at all.                                                                                      |

## Next steps

<CardGroup cols={2}>
  <Card title="Human in the loop" icon="user-check" href="/concepts/human-in-the-loop">
    The full model: correlation, authorization, timeouts and the resume payload.
  </Card>

  <Card title="Transactional email" icon="envelope" href="/concepts/transactional-email">
    Sending domains, suppression outcomes and what the send step reports.
  </Card>

  <Card title="Files" icon="folder" href="/concepts/files">
    File references, the read step, and how long a stored attachment is kept.
  </Card>

  <Card title="Record transforms" icon="table" href="/concepts/record-transforms">
    Clean, filter and reshape the set before handing it on.
  </Card>
</CardGroup>
