# Why did my Trigger.dev task send two emails?

> Trace duplicate email through task run IDs, retry attempts, app triggers, and provider events before replaying or changing idempotency settings.

Canonical HTML: https://canopyide.dev/guides/trigger-dev-task-sent-email-twice
Article date: 2026-09-28

A customer receives two welcome emails, but the agent says the Trigger.dev task succeeded. Do not replay the run or assume that two messages mean two workers. First match the app action, Trigger.dev run IDs and attempts, and the email provider's send records. The number of runs tells you whether to inspect the app trigger path, a retry, or the task's own send logic.

## Count runs, attempts, and provider sends separately

Pick one affected signup or event, then record its timestamp and a redacted business identifier. In the correct Trigger.dev project and environment, find every matching run and its run ID. Open each run's trace to check attempts, errors, and any child task runs. In the email provider, match the recipient, template or subject, provider email IDs, and accepted-send times. Keep private addresses and message bodies out of a public issue or agent prompt. Canopy's Servers panel helps you identify which app and development worker were running from which checkout, but Trigger.dev and the provider are the sources for task and delivery evidence.

*The first split is between separate runs and work repeated inside a run.*

| Evidence | Likely boundary to inspect | Next check |
| --- | --- | --- |
| Two run IDs for one signup | Browser, backend, webhook, schedule, or manual test triggered twice | Match each run to a request or source event |
| One run ID, several attempts | Configured retry after an error | Locate each provider send relative to the failure |
| One run and one attempt, two provider sends | Task code, helper, loop, or provider retry | Trace both send calls and provider IDs |
| One provider send, two inbox copies | Delivery or forwarding path | Compare provider message IDs and mailbox headers |

## If there are two runs, find the second trigger

Trace each run backward to its initiating app request, webhook event, scheduled job, child task, or dashboard test. A double-click may create two backend requests; a webhook can be delivered again; a developer may have tested from the dashboard and then signed up through the app. Do not label the worker as the cause just because two Canopy tabs or worktrees were open. Compare the actual source events, environment, task ID, and code version. A Trigger.dev idempotency key can make repeated trigger requests for the same task and key return the original run, but the key must identify the same business event. Trigger.dev documents that keys are scoped by task and environment, and a key created inside a parent run has different default scope from a key used in backend code. Do not use one constant key for every user's welcome email or a fresh random key on every retry.

- Give one signup or event a stable identifier and trace it through the backend to the run handle.
- Inspect the exact task trigger call and current SDK behavior before changing key scope or lifetime.
- Test a legitimate second signup separately so deduplication does not suppress a different event.

## If one run retried, locate the uncertain side effect

Trigger.dev's current retry documentation says an uncaught error fails an attempt; retry settings can be configured globally or per task, with task settings taking precedence. Its CLI-init default disables retrying in Development, so inspect the actual configuration and attempt count instead of assuming a retry happened. The dangerous sequence is an email provider accepting the first send, followed by a timeout or later task error, then another attempt that sends again. A Trigger.dev idempotency key on the task trigger does not itself protect an email API call made inside the task. A separate child send task can prevent a parent retry from creating duplicate child runs when keyed correctly, but the child task's own external send still needs a duplicate policy.

- Check the first attempt's provider response or email ID before deciding whether a retry is safe.
- Inspect errors after the send as well as errors on the send call.
- Treat an unknown provider result as unknown; do not infer that failure means no email left the provider.

## Protect the send at the provider boundary

If the application uses Resend, its Email API supports an idempotency key on the send request. Resend says the same key and same payload return the original email ID for requests within its 24-hour retention window; a different payload with that key returns an error. A key such as `welcome-user/<user-id>` expresses one intended welcome email better than a per-attempt UUID. Verify whether your provider has an equivalent feature and what it guarantees. Provider protection has a time window, so a durable application record or reconciliation rule may still be needed for long-delayed replays, changed payloads, or another provider. Decide with the product owner whether this event should send once per account, once per signup attempt, or again after an explicit user request; those are different behaviors.

- Use the same business-event identity through app trigger and provider request where appropriate.
- Record the provider email ID or an explicit uncertain state so a later run can reconcile rather than blindly resend.
- Test with a provider-approved destination and a known duplicate request; do not test on customers.

## Reproduce safely and review the agent's fix

A Trigger.dev dashboard replay creates a copy of a run against the latest version in the selected environment. It can therefore execute the send path again; replay is not a read-only inspection. Before using it, confirm every downstream destination and whether the original email was accepted. In Canopy, start the intended app and one development worker from the correct checkout, run a controlled signup once, and compare the app request, task run, attempts, and provider record. Then repeat only the specific duplicate condition with a documented test address. Ask the agent to show the final trigger and send call sites, key construction, retry settings, relevant tests, and the final PR diff. Accept the fix when one intended event produces one accepted send and a genuinely new event still works.

- Keep the safe testing procedure separate from production customer data and credentials.
- Verify the worker's task version and app checkout after the last code change.
- Have an owner review any change to live email, retry, or account state before deployment.

## Copyable resources

### Duplicate email investigation card

Use redacted identifiers and test recipients when sharing this with an agent or reviewer.

````text
Affected event and intended send rule: [ ]
App URL, checkout, commit, request IDs, and source event IDs: [ ]
Trigger.dev project/environment, task ID, run IDs, attempts, and replay status: [ ]
Each attempt's first error and whether a provider call preceded it: [ ]
Provider send IDs, accepted times, and test recipient: [ ]
Current trigger idempotency key/scope and provider send key/window: [ ]
Cause: second trigger / retry / duplicate call / delivery path / unknown [choose with evidence]
Safe reproduction and expected result for same event and new event: [ ]
Agent changed files, tests, final diff, and reviewer decision: [ ]
````

## Frequently asked questions

### Does a Trigger.dev idempotency key stop an email API call from running twice?

It deduplicates task-trigger requests at the task level. An external email call inside a task needs its own protection or reconciliation rule, especially if the task can retry after the provider accepted a send.

### Can I replay a failed Trigger.dev email run to check it?

A replay is a new copy of the run with the payload against the latest version in the selected environment. Check whether the original attempt sent mail and use a controlled destination before replaying.

### Do two Canopy worker tabs mean the same email task executed twice?

Not by themselves. Compare Trigger.dev run IDs, attempts, and provider sends. Two local workers on one development project can cause consumption problems, but that is a different diagnosis from duplicate email delivery.

### What if the provider request timed out after sending?

The outcome is uncertain until provider records or a stable send key establish what happened. Reconcile the original request before another send; do not treat a timeout as proof that no email was accepted.

## Sources and further reading

- [Trigger.dev: task-trigger idempotency and key scope](https://trigger.dev/docs/idempotency)
- [Trigger.dev: attempt errors and retry configuration](https://trigger.dev/docs/errors-retrying)
- [Trigger.dev: dashboard replay behavior](https://trigger.dev/docs/replaying)
- [Resend: send-request idempotency and 24-hour window](https://resend.com/changelog/idempotency-keys)
- [Trigger.dev issue: distinction between task and external side-effect idempotency](https://github.com/triggerdotdev/trigger.dev/issues/4627)
- [Canopy app README: local services and project workspace](https://github.com/FluidWorksApp/canopy-ide/blob/main/README.md)

## Related Canopy pages

- [How do I test a Trigger.dev email task without emailing real users?](https://canopyide.dev/guides/test-trigger-dev-email-task-without-real-users.md)
- [How do I run a Trigger.dev worker beside my app in Canopy?](https://canopyide.dev/guides/run-trigger-dev-worker-in-canopy.md)
- [Trigger.dev runs stuck in dev? Check for a second agent worker](https://canopyide.dev/guides/trigger-dev-runs-queued-with-two-agent-worktrees.md)
- [How to review an AI-generated pull request](https://canopyide.dev/guides/review-ai-generated-pull-requests.md)
- [Is my local AI-built app using the production database?](https://canopyide.dev/guides/is-local-ai-built-app-using-production-database.md)

Canopy runs installed coding CLIs; CLI accounts, model selection, and provider billing remain separate. Check the installed release before relying on version-specific behavior.
