Integrations · September 22, 2026

Make.com Scenarios With an Agent-Owned Inbox

Make a Make.com AI agent send email from its own address: the HTTP call, an Idempotency-Key that survives Make's retry, and inbound events.

Make’s own documented recipe for an email-triggered AI agent is three modules: Gmail watches for mail, the agent module reads it, Gmail replies. Everything the scenario sends goes out under the Google account that authorised the connection. That is fine until the agent is answering strangers, at which point a person’s mailbox is absorbing the replies and a person’s sending reputation is carrying the consequences.

Below is the arrangement where the agent owns the address instead, plus the part nobody writes down: Make’s error handling re-runs the module that failed, and a send module that timed out is precisely the module you must not re-run blind. If you want the general mechanics rather than the Make-specific ones, handling email webhooks without processing events twice covers the neighbouring problem.

Making a Make.com AI agent send email from its own address

One HTTP module. Add HTTP > Make a request, set Method to POST, and point it at the messages endpoint for an inbox the project owns:

https://api.emailforagents.ai/v1/projects/{project_id}/inboxes/{inbox_id}/messages

Set Body content type to Application/JSON, choose the raw JSON string input rather than the field-by-field data structure, and paste this as the body:

{
  "to": [{ "email": "{{1.from_email}}" }],
  "subject": "Re: {{1.subject}}",
  "reply_to_message_id": "{{1.message_id}}",
  "text": "{{2.reply_text}}"
}

Then three Headers items:

Authorization: Bearer efa_live_...
Content-Type: application/json
Idempotency-Key: reply-{{1.message_id}}-v1

Four shape rules, in the order they bite. to is an array of objects each carrying an email field, so a bare string or a comma-separated list is an HTTP 400 rather than a coercion. The schema is strict, so an unknown top-level field is a rejection rather than a warning. Up to 25 unique recipients across To, Cc and Bcc combined, counted after deduplication. And supplying reply_to_message_id makes the request require messages:read in addition to messages:send, which is why a key minted with the send scope alone passes your manual test and then 403s on the first real reply. The scope list is on the developer reference, and the reasoning behind narrow keys is in agent email permissions and scopes.

Wrap any model-generated string so that a quotation mark in the reply does not invalidate the JSON. Make will happily send a malformed body and report the resulting 400 as though the API were at fault.

Both success paths return HTTP 202 and you branch on result, not on the status code. "result": "queued" gives you message_id, thread_id and state. "result": "approval_required" gives you draft_id, approval_id and reason_codes, and means no email exists yet. Route that to a notification and stop; it is not an error and retrying it does nothing.

What Make’s retry actually re-runs

This is the section that changes how you configure the module.

Make’s documentation on automatic retry of incomplete executions is explicit about scope: a retry “runs the scenario again, starting with the module that caused the error”. Not the whole scenario, and not the failing bundle in isolation. The erroring module executes again, and so does everything downstream of it.

Three error types are retried automatically, without any error handler attached: RateLimitError, ConnectionError and ModuleTimeoutError. The published backoff is eight attempts, at 1 minute, then 10, 10, 30, 30, 30 minutes, then 3 and 3 hours, running roughly 7 hours 51 minutes past the original run.

Now put those two facts against the HTTP module’s own settings. Make’s HTTP app documentation lists a Timeout under advanced settings, configurable between 1 and 300 seconds and set to 40 by default. A timeout is a client-side abort. It tells you that Make stopped waiting; it tells you nothing about whether the server accepted the message.

One precondition before the sequence: incomplete executions are disabled by default and have to be switched on in scenario settings, so an out-of-the-box scenario drops the bundle rather than retrying it. That is the wrong default for everything except a send, and every Make resilience guide tells you to turn it on. The moment you do, this becomes available:

  1. The send module posts the message. The API accepts it and starts work.
  2. The response is slow, or the connection drops. Make aborts at 40 seconds with a timeout or connection error, both of which are in the automatic retry set.
  3. A minute later Make runs the scenario again starting at the send module, because that is the module that raised the error.
  4. Without a stable Idempotency-Key, that is a second email, and potentially eight.

Nothing in the Make UI is wrong here, and nothing in that sequence is unusual. It is what at-most-once code looks like when an at-least-once orchestrator runs it. An explicit error handler produces the same shape: attach a Retry handler to the send module, let it complete the execution automatically on the documented example of three attempts fifteen minutes apart, and each attempt re-runs that module from the same stored mappings.

Raising the HTTP timeout does not remove the hazard. It only moves the line at which an ambiguous outcome starts being called a failure.

Idempotency-Key semantics, precisely

Idempotency-Key is mandatory on the send endpoint. Omit it and the request fails with HTTP 400 invalid_request before the body is parsed.

What it guarantees is narrower and more useful than “no duplicates”. The request is normalised into a canonical payload and hashed with SHA-256, and the key is bound to that exact message rather than to the endpoint. The consequences are worth memorising, because each is a different HTTP status your scenario has to branch on:

Because the binding is to the payload and not to the route, the key must name the intent. reply-{{1.message_id}}-v1 names one reply to one inbound message, and it is identical on the first attempt and on the eighth retry three hours later, which is exactly the property the default retry set demands. A key built from {{now}}, or from a freshly generated UUID, is evaluated at attempt time, changes on every attempt, and provides nothing at all while appearing to satisfy the header. Idempotency keys: stop an agent sending duplicate emails covers the quota accounting and why an unknown outcome must not be auto-resent.

One asymmetry if you also build the approval branch. The send route requires only that the header be present, with no length ceiling. The approve route, POST /v1/projects/{project_id}/approvals/{approval_id}/approve, rejects a key longer than 128 characters with the same invalid_request, and namespaces it separately, so the same string used on both routes cannot collide. Size one expression for the stricter route and reuse it.

Receiving mail into a custom webhook

Register a webhook against the project, subscribe to message.received, and point it at a Webhooks > Custom webhook URL. Under advanced settings, turn on JSON pass-through so the module hands you the payload as received rather than a re-serialised structure, because the signature is computed over exact bytes.

Each callback carries three headers: webhook-id, which is the event ID, webhook-timestamp in Unix seconds, and webhook-signature, which is v1, followed by the base64 HMAC-SHA256 of id.timestamp.rawBody keyed on the signing secret shown once at registration. Verify against the raw bytes before parsing and reject anything more than five minutes off the clock. The verification code is in the events and webhooks reference.

Event delivery is at-least-once, by design. Dispatch is abandoned at 25 seconds and retried up to eight attempts on a fixed backoff of 10s, 60s, 5m, 30m, 2h, 6h and 12h. Any 4xx is terminal except 408, 409, 425 and 429. A duplicate callback is therefore normal traffic, not an incident, and the scenario has to deduplicate on the event ID in a data store before it does any work. The event ID and the Idempotency-Key solve different problems and neither substitutes for the other: one stops you processing the same arrival twice, the other stops you sending the same reply twice.

Make’s own queue behaviour matters here more than it usually does. A custom webhook accepts up to 300 incoming requests per 10-second interval and parks payloads in a queue whose capacity scales with your plan, documented as 667 items per 10,000 monthly credits up to a ceiling of 10,000. Make’s documentation also states what the endpoint returns in each state: 200 when the payload is queued, 400 when the queue is full, and 429 when rate limited.

When your Make queue fills, those events are gone

Put the two retry policies side by side and a specific failure falls out.

429 is one of the four statuses this dispatcher treats as retryable, so a burst that trips Make’s rate limit is redelivered later and nothing is lost. 400 is not. A 400 is terminal on the first attempt, no further attempts are scheduled, and the delivery is recorded as exhausted. So the moment your Make queue fills, every event that arrives while it is full is dropped permanently from the push path. There is no alert for this on either side. The scenario simply stops seeing some inbound mail, and the gap is invisible unless you go looking.

Two things close it. First, subscribe the webhook to the narrow set of event types the scenario actually consumes, rather than all of them, so routine traffic never fills the queue. Second, and non-negotiable for this integration, keep a scheduled scenario reading GET /v1/projects/{project_id}/events as the recovery path. The cursor is the project sequence number as a decimal string, limit runs 1 to 100, and next_cursor must be persisted after the batch is processed rather than when it is received. A cursor that has fallen behind the retained window returns HTTP 410 cursor_expired with retryable: false, which means restart without a cursor rather than loop.

Reading the deliveries endpoint after an outage

When a scenario has been down, the question is which events reached it. That is what the deliveries endpoint answers:

curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/webhooks/$EFA_WEBHOOK_ID/deliveries" \
  -H "Authorization: Bearer $EFA_API_KEY"
{
  "data": [
    {
      "id": "whd_...",
      "event_id": "evt_...",
      "replay_number": 0,
      "state": "exhausted",
      "attempts": [
        {
          "attempt_number": 1,
          "sent_at": "2026-09-22T11:02:14.882Z",
          "outcome": "rejected",
          "http_status": 400,
          "safe_response_excerpt": "Bad Request"
        }
      ]
    }
  ],
  "next_cursor": null,
  "has_more": false
}

Three things about that response are worth knowing before you rely on it.

safe_response_excerpt is whatever your own endpoint wrote in the response body, captured from our side and truncated to 200 characters. When a Make webhook rejects a callback, that rejection’s own text appears here, which is usually faster than reconstructing it from Make’s execution history, and it is available even when the scenario never ran at all.

state distinguishes the cases people conflate. succeeded means Make acknowledged with a 2xx. exhausted means attempts stopped, either because eight failed or because one returned a terminal 4xx. canceled means the delivery was dropped without being sent, which is what happens to queued callbacks when the endpoint’s revision advances, for example after you disable it or rotate its secret. Rotating a signing secret cancels everything already queued against the old revision, so the cursor rather than the webhook is how you recover that window.

It is a diagnostic, not a feed. The endpoint returns the last 25 deliveries for that webhook ordered by next attempt, with next_cursor fixed at null and has_more fixed at false. It does not page. Twenty-five is enough to answer “what happened to my endpoint an hour ago” and not enough to reconstruct a day.

The MCP Client module instead of HTTP

Make ships an MCP Client module that calls tools on a remote MCP server from inside a scenario or from a Make AI Agent, loading the tool list from the server and rendering mapped input fields for it. It supports OAuth, which is required here: an API key is rejected at https://mcp.emailforagents.ai/mcp by design, and only an OAuth connection grant may invoke the tools. A GET on that URL returns 405 with Allow: POST, so select streamable HTTP rather than SSE.

The reason to consider it is the boundary rather than the ergonomics. There are exactly six tools, each declaring the scopes it needs, and tools/list returns only those the grant holds. Request the email.draft scope and the agent’s session contains five tools with no send_message among them, so a hostile instruction buried in an inbound email has no send tool to name, and a tools/call naming it anyway is re-checked and refused. That agent still composes a complete, correctly threaded reply through create_draft, which cannot transmit mail, and the reply waits for a human. The presets are on the integrations page.

The cost is a clock. A connection grant is created with a 30-day expiry and nothing extends it, so a scenario that ran cleanly for a month begins failing at the MCP module for no reason you changed. An API key on the HTTP path has no equivalent expiry. For an unattended scenario, that alone is usually the deciding argument for the HTTP module.

Limits worth knowing before this goes live

Point the scenario at a test project first. Nothing in the scenario changes; the credential does. Environment is carried in the key prefix and cannot be crossed, so an efa_test_ key resolves only to test projects and test inboxes and no flag lets it reach live. Test inboxes use a non-routable .mail.invalid address and a simulated transport, and POST /v1/projects/{project_id}/test/inbound injects a synthetic inbound message that fires message.received, exercises your signature check and your deduplication, and never leaves the system. That matters more in Make than in hand-written code, because you will run the scenario repeatedly getting the mappings right.

The free allowance is one live inbox and 100 email units a month per verified owner identity, claimed by that identity’s first eligible workspace. An email unit is one message received or one recipient sent to, so a send to three people uses three, and a second workspace does not renew it. When it runs out sending stops while received mail stays readable. Builder and Team are proposed plans at proposed prices with checkout disabled, and custom domains require a paid plan, so every live address today sits on agents.emailforagents.ai.

There is no Email for Agents app in Make’s app directory, checked 22 September 2026, so the HTTP module is the integration rather than a workaround. No certification is held here, no uptime commitment is published, and no MCP client is certified. And HTTP 202 with message.accepted means a provider took the message, not that anybody received it: recipient outcomes arrive separately as message.delivery_updated, and a send to five people has five delivery states that can disagree. What each status actually proves sets out the diagnosis order before you write “sent” into a data store.


Keep reading

All guides · Documentation · Integrations