AwaitResponseAsync — Declarative Follow-Up for Courier Workflows

Created Mar 2, 2026 public

A blocking, declarative API on ClaimWorkflowBase that lets any courier say "wait for a reply, and if none comes, do this."

The Problem

Today, reminder timing is tied to an implicit reference time chain (LastReminderSentAt → LastEmailProcessedAt → WorkflowStartedAt). When a courier sends an outbound email — a submission, a follow-up, anything — there's no clean way to say "start waiting for a response, and escalate if one doesn't come."

The existing ReminderHandler handles the main post-submission chase loop well, but it's not reusable for ad-hoc "send and wait" scenarios elsewhere in the workflow. A developer adding a new outbound email has to understand the full reminder internals to get follow-up behavior.

The API

Two overloads on ClaimWorkflowBase — one simple, one with escalating intervals:

Simple: Single Wait

// Wait up to 7 days. If no response, run the callback.
var responded = await AwaitResponseAsync(TimeSpan.FromDays(7), async () =>
{
    await SendFollowUpEmail(new OutboundEmail
    {
        To = "customerservice.uk@asendia.com",
        Subject = $"Follow-up: Claim {State.TrackingNumber}",
        Body = "We haven't received a response..."
    });
    TransitionTo(ClaimStatuses.DatabaseIds.Chasing);
});

Returns true if an email arrived before timeout, false if the callback fired.

Multi-Interval: Escalating Follow-Ups

// Try 3 times with increasing urgency
var responded = await AwaitResponseAsync(
    intervals: [TimeSpan.FromDays(7), TimeSpan.FromDays(14), TimeSpan.FromDays(21)],
    onElapsed: async (reminder) =>
    {
        var to = reminder.IsLast
            ? AsendiaWorkflowConstants.EmailAddresses.UK_ESCALATION
            : AsendiaWorkflowConstants.EmailAddresses.UK;

        var urgency = reminder.Number switch
        {
            1 => "Friendly reminder",
            2 => "Second reminder",
            _ => "URGENT: Final reminder"
        };

        await SendFollowUpEmail(new OutboundEmail
        {
            To = to,
            Subject = $"{urgency}: Claim {State.TrackingNumber}",
            Body = $"{urgency} — we submitted this claim and haven't heard back."
        });

        if (reminder.IsLast)
            TransitionTo(ClaimStatuses.DatabaseIds.Chasing);
    });

The reminder.Number (1, 2, 3...) and reminder.IsLast let the callback adapt its behavior per interval — different recipients, subjects, urgency levels — all in one readable block.

How It Works

graph TD
    Call["AwaitResponseAsync(intervals, onElapsed)"] --> Loop

    Loop["for each interval"] --> Wait["Workflow.WaitConditionAsync<br/>(email arrives OR timeout)"]

    Wait -->|Email arrives| Return["return true ✓<br/>email stays in queue<br/>for normal processing"]

    Wait -->|ShouldComplete| Return2["return true ✓<br/>workflow ending"]

    Wait -->|Timeout| Fire["onElapsed(reminder)"]
    Fire --> Next{More intervals?}
    Next -->|Yes| Loop
    Next -->|No| ReturnFalse["return false ✗<br/>all intervals exhausted"]

    style Call fill:#fff3e0
    style Fire fill:#fce4ec
    style Return fill:#e8f5e9
    style Return2 fill:#e8f5e9
    style ReturnFalse fill:#fce4ec

Key behaviors:

Base Implementation

// In ClaimWorkflowBase

protected async Task<bool> AwaitResponseAsync(
    TimeSpan[] intervals,
    Func<ReminderContext, Task> onElapsed)
{
    for (var i = 0; i < intervals.Length; i++)
    {
        var gotResponse = await Workflow.WaitConditionAsync(
            () => EmailQueue.Count > 0 || ShouldComplete,
            intervals[i]);

        if (gotResponse || ShouldComplete)
            return true;

        await onElapsed(new ReminderContext
        {
            Number = i + 1,
            IsLast = i == intervals.Length - 1
        });
    }
    return false;
}

// Single-interval overload
protected Task<bool> AwaitResponseAsync(TimeSpan timeout, Func<Task> onElapsed)
    => AwaitResponseAsync([timeout], _ => onElapsed());
public record ReminderContext
{
    public int Number { get; init; }
    public bool IsLast { get; init; }
}

That's it. No new state fields, no reference time chain changes, no config. The intervals are in the code, the behavior is in the callback.

Concrete Example: Asendia Submission

protected override async Task OnSubmissionAsync(ClaimWorkflowRequest request)
{
    if (request.IsBackfill || State.ClaimSubmissionAttempted)
        return;

    // ... delay + send submission email (existing code) ...

    if (response is { Success: true, WasSubmitted: true })
    {
        State.SubmittedAt = Workflow.UtcNow;
        TransitionTo(ClaimStatuses.DatabaseIds.SubmittedSent);

        // Wait for Asendia to respond, escalate if they don't
        await AwaitResponseAsync(
            intervals: [TimeSpan.FromDays(7), TimeSpan.FromDays(14), TimeSpan.FromDays(21)],
            onElapsed: async (reminder) =>
            {
                var to = reminder.IsLast
                    ? AsendiaWorkflowConstants.EmailAddresses.UK_ESCALATION
                    : AsendiaWorkflowConstants.EmailAddresses.UK;

                await SendFollowUpEmail(new OutboundEmail
                {
                    To = to,
                    Subject = $"Reminder #{reminder.Number}: Claim {State.TrackingNumber}",
                    Body = BuildReminderBody(reminder.Number)
                });

                AddAuditLog($"Sent follow-up #{reminder.Number} to {to}");
            });
    }
}

Reading this code, a developer immediately knows:

Question Answer (visible in code)
How long before first follow-up? 7 days
How many follow-ups? 3 (length of intervals array)
Who gets emailed? UK address for #1-2, escalation for #3
What does it say? Subject and body right there in the callback
What if they reply? AwaitResponseAsync returns true, email stays in queue for the normal pipeline

Timeline: What Happens When

sequenceDiagram
    participant A as Asendia Workflow
    participant Q as Email Queue
    participant C as Courier

    Note over A: OnSubmissionAsync
    A->>C: Submit claim email
    A->>A: AwaitResponseAsync([7d, 14d, 21d])

    rect rgb(232, 245, 233)
        Note over A: Blocking — waiting for response
        Note over Q: If email arrives → return true
    end

    Note over A: Day 7 — no response
    A->>C: onElapsed(#1) → Friendly reminder to UK

    rect rgb(232, 245, 233)
        Note over A: Blocking — waiting again
    end

    Note over A: Day 14 — still no response
    A->>C: onElapsed(#2) → Second reminder to UK

    rect rgb(232, 245, 233)
        Note over A: Blocking — last chance
    end

    Note over A: Day 21 — no response
    A->>C: onElapsed(#3, IsLast) → URGENT to escalation

    Note over A: returns false — all exhausted
    Note over A: OnSubmissionAsync completes
    Note over A: Base template enters email loop

Relationship to Existing ReminderHandler

AwaitResponseAsync doesn't replace the existing ReminderHandler — they serve different purposes:

ReminderHandler AwaitResponseAsync
Where Main email loop (post-submission) Anywhere in a lifecycle hook
Driven by Config (ReminderIntervalsDays) + ReminderAnalyzer email thread analysis Intervals + callback passed at the call site
Approval Human-in-the-loop intervention system Up to the callback (can include approval or not)
State tracking LastReminderSentAt, RemindersSentCount None — purely blocking

For the main post-submission chase loop, ReminderHandler remains the right tool — it has email thread analysis, human approval, config-driven intervals, and status management.

AwaitResponseAsync is for ad-hoc waits — places where a courier needs to send something and wait for a reply, with full control over what happens if nobody responds. It's a building block, not a replacement.

Scope of Changes

File Change
ClaimWorkflowBase.cs Add AwaitResponseAsync (2 overloads, ~20 lines)
ClaimWorkflowModels.cs Add ReminderContext record

That's it. No state field changes, no config changes, no changes to existing couriers. Couriers opt in by calling it.