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

# Survey notifications

> Send emails, schedule deliveries, and react to respondent events for human surveys.

Use survey notifications to invite a known respondent list, remind people who have not started, or send a transcript after completion. An immediate **delivery** sends messages now, a **schedule** sends them at a chosen time, and a **callback** sends them in response to an event such as survey completion.

`HumanSurveyNotificationHandler` keeps these operations associated with one hosted survey. Creating the handler does not send a message; the send, schedule, and callback methods below perform the corresponding action.

> [!NOTE]
>
> The survey must have an agent list with an email delivery channel configured before any notifications can be sent. Build a job with `survey.by(agents)` and pass `delivery_map` to its `.humanize()` method. See [Respondent lists](https://docs.expectedparrot.com/en/latest/humanize.md#with-agent-lists-for-email-notifications) for the construction workflow.

The examples below operate on an existing hosted survey. Send and schedule calls contact the service and can deliver real messages to its respondents. Replace placeholder identifiers with values returned by your own survey workflow.

## Getting a handler

```python theme={null}
from edsl.coop import HumanSurveyNotificationHandler

handler = HumanSurveyNotificationHandler("your-human-survey-uuid")
```

A `Coop` client is created automatically. Pass one explicitly if you need a specific API key or base URL:

```python theme={null}
from edsl import Coop
from edsl.coop import HumanSurveyNotificationHandler

coop = Coop()
handler = HumanSurveyNotificationHandler("your-human-survey-uuid", coop=coop)
```

***

## Statuses

A delivery can contain one email task per selected respondent. Its dispatch and delivery statuses describe the message; the respondent's response status describes participation. A delivered invitation therefore does not mean a completed survey.

### Dispatch status

Controls whether the platform has attempted to hand off the message to the downstream channel (e.g. the email provider).

| Value | Meaning |
| - | - |
| `pending` | The task has been created but not yet processed. |
| `dispatched` | The message was successfully handed off to the channel. |
| `failed` | The dispatch attempt failed (e.g. missing address, provider error). |

### Delivery status

Reflects what the email provider reports back after dispatch.

| Value | Meaning |
| - | - |
| `pending` | Waiting for a delivery event from the provider. |
| `sent` | The provider accepted the message for delivery. |
| `delivered` | The provider confirmed the message reached the recipient. |
| `bounced` | The message was rejected by the recipient's mail server. |
| `failed` | The provider reported an error sending the email. |

### Response status

Tracks whether a respondent has engaged with the survey itself.

| Value | Meaning |
| - | - |
| `not_started` | The respondent has not opened the survey link. |
| `completed` | The respondent submitted a complete response. |

***

## Quickstart examples

### Send an email right now

Trigger an immediate delivery to all respondents.

```python theme={null}
result = handler.send_respondent_email(name="Initial outreach")
# print() also shows output in scripts. In a notebook, you can instead
# put the value you want to inspect on the last line of a cell.
print(result)
# {"delivery_uuid": "...", "routes": [...]}
```

Pass a custom subject, HTML email body, or restrict which respondents receive it:

```python theme={null}
from edsl.coop.coop_humanize_notifications import (
    HumanizeRespondentFilter,
    RespondentCondition,
    ResponseStatus,
)

# Only email respondents who haven't started yet
condition = RespondentCondition(
    response_status=[ResponseStatus.not_started]
)
respondent_filter = HumanizeRespondentFilter(conditions=[condition])

result = handler.send_respondent_email(
    name="Reminder to non-starters",
    subject="Don't forget to complete our survey",
    delivery_template='<p>Please <a href="{{ url }}">complete our survey</a>.</p>',
    respondent_filter=respondent_filter,
)
```

***

### Schedule a one-time email

Schedule a single delivery at a specific time. `run_at` must be a timezone-aware datetime or an ISO 8601 string.

```python theme={null}
from datetime import datetime, timedelta, timezone

run_at = datetime.now(timezone.utc) + timedelta(days=1)

schedule = handler.create_one_time_schedule(
    name="Reminder tomorrow",
    run_at=run_at,
)
print(schedule["schedule_uuid"])
```

Update the time before it fires:

```python theme={null}
handler.update_one_time_schedule(
    schedule_uuid=schedule["schedule_uuid"],
    run_at=run_at + timedelta(days=1),
)
```

***

### Schedule recurring emails (cron)

Send emails on a repeating schedule using standard cron syntax. Provide either `max_jobs` (fire at most N times) or `deadline` (stop firing after this datetime) as the termination condition. Use `start_at` to delay when the schedule begins firing; omit it to start immediately.

```python theme={null}
from datetime import datetime, timedelta, timezone

# Every Monday at 9 AM Eastern, up to 4 times
schedule = handler.create_cron_schedule(
    name="Weekly Monday reminder",
    cron_expression="0 9 * * MON",
    timezone="America/New_York",
    max_jobs=4,
)
```

With a deadline instead:

```python theme={null}
schedule = handler.create_cron_schedule(
    name="Daily reminders for one week",
    cron_expression="0 8 * * *",
    timezone="America/Chicago",
    deadline=datetime.now(timezone.utc) + timedelta(days=7),
)
```

With a delayed start:

```python theme={null}
schedule = handler.create_cron_schedule(
    name="Weekly reminders starting next week",
    cron_expression="0 9 * * MON",
    timezone="America/New_York",
    max_jobs=4,
    start_at=datetime.now(timezone.utc) + timedelta(days=7),
)
```

Pause and resume a schedule:

```python theme={null}
handler.deactivate_schedule(schedule["schedule_uuid"])
handler.activate_schedule(schedule["schedule_uuid"])
```

***

### React when someone completes the survey (callback)

A **respondent** is a person with a corresponding entry in the survey's agent list. Each respondent has a unique survey link and a tracked response status. Submissions from people outside the agent list (e.g. anonymous links) are not associated with a respondent.

`create_callback` sends emails to respondents in your agent list. Two respondent event types are supported:

| Event | When it fires |
| - | - |
| `"human_survey_respondent.completed"` | When a respondent's status is set to completed. |
| `"human_survey_respondent.response_submitted"` | Each time a respondent submits a response. |

```python theme={null}
callback = handler.create_callback(
    name="Completion thank-you",
    callback_type="human_survey_respondent.completed",
)
print(callback["callback_uuid"])
```

Limit how many times the callback fires (e.g. only notify for the first 100 completions):

```python theme={null}
callback = handler.create_callback(
    name="First 100 completions",
    callback_type="human_survey_respondent.completed",
    max_fires=100,
)
```

### Notify the owner on every new response

Use `create_owner_response_callback` to email the survey owner each time a response is submitted. This fires for all responses, including anonymous ones — not just respondents in the agent list.

```python theme={null}
callback = handler.create_owner_response_callback(name="New response notification")
print(callback["callback_uuid"])
```

### Send a transcript after each submission

Use `create_transcript_callback` to automatically email a transcript each time a response is submitted. Pass `recipient` to control who receives it:

* `"respondent"` (default) — emails each respondent a copy of their own answers.
* `"owner"` — emails the survey owner a copy of the answers. Fires for all submissions, including anonymous ones.

```python theme={null}
# Email the respondent their own transcript
callback = handler.create_transcript_callback(
    name="Send respondent transcript on submission",
    recipient="respondent",
)

# Email the owner a transcript on every submission
callback = handler.create_transcript_callback(
    name="Send owner transcript on submission",
    recipient="owner",
)

print(callback["callback_uuid"])
```

Activate and deactivate:

```python theme={null}
handler.deactivate_callback(callback["callback_uuid"])
handler.activate_callback(callback["callback_uuid"])
```

***

### Filter which respondents receive a delivery

`HumanizeRespondentFilter` lets you compose conditions to target specific subsets of respondents. Conditions can be nested with `and` / `or` operators.

```python theme={null}
from edsl.coop.coop_humanize_notifications import (
    HumanizeRespondentFilter,
    RespondentCondition,
    DeliveryStatus,
    DispatchStatus,
    ResponseStatus,
    FilterOperator,
)

# Target respondents who were never contacted
never_contacted = RespondentCondition(never_contacted=True)

# OR respondents whose last email bounced
bounced = RespondentCondition(
    most_recent_delivery_status=[DeliveryStatus.bounced]
)

# Combine: never contacted OR previously bounced
respondent_filter = HumanizeRespondentFilter(
    operator=FilterOperator.or_,
    conditions=[never_contacted, bounced],
)

handler.send_respondent_email(
    name="Re-engagement",
    respondent_filter=respondent_filter,
)
```

**Available condition fields:**

| Field | Type | Filters to respondents where… |
| - | - | - |
| `respondent_uuids` | `list[str]` | UUID is in the provided list |
| `response_status` | `list[ResponseStatus]` | Survey response status matches |
| `never_contacted` | `bool` | Has never received a delivery |
| `any_dispatch_status` | `list[DispatchStatus]` | Any past task has this dispatch status |
| `any_delivery_status` | `list[DeliveryStatus]` | Any past task has this delivery status |
| `most_recent_dispatch_status` | `list[DispatchStatus]` | Most recent task has this dispatch status |
| `most_recent_delivery_status` | `list[DeliveryStatus]` | Most recent task has this delivery status |

***

### Check delivery status

List all delivery jobs for the survey:

```python theme={null}
deliveries = handler.list_deliveries()
for d in deliveries["deliveries"]:
    print(d["delivery_uuid"], d["status"], d["sent_count"], d["failed_count"])
```

Inspect a specific delivery job:

```python theme={null}
delivery = handler.get_delivery(delivery_uuid="...")
print(delivery)
# {
#   "delivery_uuid": "...",
#   "status": "completed",
#   "total_respondents": 50,
#   "processed_respondents": 50,
#   "sent_count": 48,
#   "failed_count": 2,
#   "started_at": "...",
#   "completed_at": "..."
# }
```

Drill into individual tasks (one per respondent per delivery):

```python theme={null}
tasks = handler.list_delivery_tasks(delivery_uuid="...")
for task in tasks["tasks"]:
    print(task["identifier"], task["dispatch_status"], task["delivery_status"])
```

Get a single delivery task by UUID:

```python theme={null}
task = handler.get_delivery_task(task_uuid="...")
print(task)
# {
#   "task_uuid": "...",
#   "channel": "email",
#   "identifier": "respondent@example.com",
#   "dispatch_status": "dispatched",
#   "delivery_status": "delivered",
#   "respondent": {"respondent_uuid": "...", "response_status": "completed"},
#   ...
# }
```

***

## Route types

Each delivery or schedule sends email to survey respondents via a `RespondentEmailRouteConfig` route. A delivery template is always required; convenience methods like `send_respondent_email` default to the built-in invitation template when none is provided.

`RespondentEmailRouteConfig` options:

| Field | Description |
| - | - |
| `delivery_template` | The email body. Pass an HTML string to use a custom template, or omit to use the built-in invitation template. |
| `respondent_filter` | `HumanizeRespondentFilter` to restrict which respondents receive the email. |
| `subject` | Email subject line (1-200 characters). Uses the default subject when omitted. |

### Template variables

Custom HTML templates support the following variables, which are filled in per-respondent at send time:

| Variable | Value |
| - | - |
| `{{ survey_name }}` | The name of the human survey. |
| `{{ url }}` | The respondent's unique survey link (includes their access token). |

Example:

```python theme={null}
handler.send_respondent_email(
    name="Initial outreach",
    subject="You're invited to take our survey",
    delivery_template="""
<p>Hi there,</p>
<p>You've been invited to take <strong>{{ survey_name }}</strong>.</p>
<p><a href="{{ url }}">Click here to begin</a></p>
""",
)
```

Update the subject and template on an existing respondent email route on a schedule:

```python theme={null}
handler.patch_schedule_respondent_email_route(
    schedule_uuid="...",
    route_uuid="...",
    subject="A quick reminder about our survey",
    delivery_template='<p>Please <a href="{{ url }}">complete our survey</a>.</p>',
)
```

Update the subject and template on an existing respondent email route on a callback:

```python theme={null}
handler.patch_callback_respondent_email_route(
    callback_uuid="...",
    route_uuid="...",
    subject="Thanks for completing our survey",
    delivery_template="<p>Hi! Thank you for completing <strong>{{ survey_name }}</strong>.</p>",
)
```

***

## Find and revise schedules and callbacks

The handler keeps the human survey UUID, so these methods only need the identifier of the schedule, callback, or route:

| Task | Method |
| - | - |
| List schedules or callbacks | `handler.list_schedules(page=1, page_size=50)` / `handler.list_callbacks(page=1, page_size=50)` |
| Inspect one | `handler.get_schedule(schedule_uuid)` / `handler.get_callback(callback_uuid)` |
| Reschedule | `handler.update_one_time_schedule(schedule_uuid, run_at=timestamp)`; for recurring schedules, `handler.update_cron_schedule(schedule_uuid, cron_expression=expression, timezone=timezone)` |
| Rename a callback | `handler.patch_callback(callback_uuid, name="New name")` |
| Add a route | `handler.add_schedule_route(schedule_uuid, route_config)` / `handler.add_callback_route(callback_uuid, route_config)` |
| Remove a route | `handler.delete_schedule_route(schedule_uuid, route_uuid)` / `handler.delete_callback_route(callback_uuid, route_uuid)` |
| Remove a schedule or callback | `handler.delete_schedule(schedule_uuid)` / `handler.delete_callback(callback_uuid)` |

Deactivating a schedule or callback pauses future triggers without deleting its configuration. Deletion removes it. Adding or changing active routes affects future deliveries, so review recipients and templates first. Listing methods return one page; use their pagination metadata to request subsequent pages. `list_deliveries()`, `list_delivery_tasks()`, and `get_respondents()` also accept `page` and `page_size`.

## Managing respondents

List all respondents and their current response status:

```python theme={null}
respondents = handler.get_respondents()
for r in respondents["respondents"]:
    print(r["respondent_uuid"], r["response_status"])
```

Check and update the agent list delivery configuration (the trait column that holds each respondent's email address):

```python theme={null}
from edsl.coop.coop_humanize_notifications import ChannelConfig, DeliveryMap

# See current config
config = handler.get_agent_list()
print(config["agent_list_config"])

# Update the email column mapping
handler.patch_agent_list(
    delivery_map=DeliveryMap(email=ChannelConfig(col_name="email_address"))
)
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.