Human in the loop
An email API with human approval for AI agents.
SendRaven is an email API with human approval for AI agents: mark the key an agent holds requires_approval, and every send from it becomes a draft that a person releases or rejects from a queue. The agent gets a normal response and stops. Nothing goes out until someone who is not the agent says so.
202 {
"id": "msg_8a1…",
"status": "pending_approval",
"approval_id": "apr_4c…"
}{ "decision": "approve",
"decided_by": "maya@acme.com" }
200 { "status": "approved",
"message_id": "msg_8a1…" }How it works
Four steps, and the agent is only in one of them.
Mark the key
POST /v1/api-keys
{ "name": "outreach-agent",
"scopes": ["emails:send"],
"requires_approval": true }Set when the key is created, in the dashboard or over the API. It sits beside the daily cap and the recipient allowlist, the other two limits a key carries.
The agent sends
202 { "status": "pending_approval",
"approval_id": "apr_4c…" }The message is persisted, stamped with its Message-ID and visible in the log. The response is a 202, not an error. There is nothing to retry, and the tool descriptions say so.
A person reads it
GET /v1/approvals
→ to, subject, text, html,
expires_atThe queue carries the whole message, so the reviewer reads what would be sent rather than a reference to it. The same queue is a page in the dashboard with a count in the sidebar.
Approve or reject
POST /v1/approvals/apr_4c…
{ "decision": "approve" }
# or "reject", with a reasonApproving dispatches the message. Rejecting cancels it. Either way the decision records who made it.
What the agent sees
A held send is not a failure.
POST /v1/emails
Authorization: Bearer sk_live_… # a requires_approval key
Idempotency-Key: 7f3e…
{ "to": ["maya@acme.com"],
"subject": "Re: Your invoice",
"text": "Attached as a PDF, as asked." }
202 { "id": "msg_8a1…",
"status": "pending_approval",
"approval_id": "apr_4c…",
"thread_id": "thr_91…" }Suppression, the recipient allowlist and the daily cap are all checked first, so a send that would have been refused is refused rather than queued for someone to reject. What reaches the queue is mail that could go.
The right move for an agent is to report that the message awaits review, with its approval_id, and stop. Sending again queues a second draft. Replaying the same Idempotency-Key returns the same held response, so a client that retries on timeout does not create one either.
llms.txt, the API reference and the MCP tool descriptions all say this in as many words, because the model reading them is the one that has to get it right.
The guarantees
Released once. Held before the schedule. Expired, not forgotten.
- Two reviewers cannot release the same message twice. The decision is a conditional update that applies only while the approval is still pending and unexpired. The second click, or the second reviewer, gets
409 invalid_statebefore anything is dispatched. The approval is marked approved only once the send is accepted: if the release is refused, it stays pending and can be approved again, and a release already under way answers409 approval_in_progress, so a retry cannot become a second send. - A schedule cannot walk around the hold. The hold is checked before the schedule branch. A held message keeps its
scheduled_at, and approving it places it on the schedule for then rather than sending at once. In an earlier version the deferred path returned before the hold was consulted, and a key that required approval could send anything by asking for “in one minute”. - A draft nobody looked at does not go out days later. An approval expires 72 hours after it was requested and drops out of the queue. Approving after that answers the same 409.
- The hold reaches the database. Creating a key with the hold ticked, in the dashboard or over
POST /v1/api-keys, is covered by a test that reads the key back out of storage. For the first version of the API it was not, the field never reached the document, and a key marked as requiring approval sent without it. - A key cannot lift its own hold.
PATCH /v1/api-keys/:idrefuses when the id is the calling key, and key management has no MCP tool. An agent can send, read and be limited. It cannot rewrite its limits.
Three ways to decide
The API, the dashboard, or an MCP client with a person at it.
POST /v1/approvals/:idTakesapproveorreject, adecided_bynaming who authorised it, and an optional reason. Scopeemails:send, from an API key without guardrails: the key that drafted the message, any key with a hold, a cap or an allowlist, and any OAuth access token get403 forbidden. Wire it to a Slack button, a ticket, or your own review screen.Dashboard → ApprovalsThe same queue as a page, oldest first, with the message rendered and two buttons. The sidebar shows how many are waiting.decide_approvalThe MCP tool, for a person deciding from Claude or Cursor with the server configured on an API key without guardrails. A client signed in through OAuth cannot decide: an OAuth token is an agent’s credential, and the hold exists precisely so that the agent is not the one deciding. The description also tells a model not to call it unless a human has said which way to go. Approvals reference.
When to use it
Where a wrong send is expensive. Then loosen it, key by key.
Hold
- Outreach to strangers, and anything to a domain you have not mailed before.
- Anything that commits the company to a price, a date or a refund.
- The first week of any agent you have not watched yet.
Do not hold
- A support agent replying to your own customers from a key with an allowlist of their domains. A hold there is a queue nobody clears.
- Transactional mail: receipts, resets, alerts. Give that key a cap and an allowlist instead.
The controls are independent. Start with all three on a new agent’s key, and remove the hold once the log shows it has earned it. The cap and the allowlist stay.
Questions
Is there an email API with human approval for AI agents?
Yes. SendRaven holds every send from a key marked requires_approval as a draft until a person approves or rejects it in the dashboard, or over the API or the decide_approval MCP tool with an API key that has no guardrails (an OAuth token, which is always an agent's, cannot decide). The agent receives 202 pending_approval and an approval_id, which is not an error and has nothing to retry.
How do I require a human to approve an AI agent's emails before they are sent?
Create the agent's API key with requires_approval: true. Every send from that key is persisted and held; GET /v1/approvals lists what is waiting with the full message, and POST /v1/approvals/:id with decision approve or reject releases or cancels it, recording who decided.
Can two people approve the same held email twice?
No. The decision is a conditional update that applies only while the approval is still pending and unexpired, so the second reviewer gets 409 invalid_state and the message is dispatched once. If the release is refused the approval stays pending and can be approved again, and a release already under way answers 409 approval_in_progress, so a retry cannot become a second send.
Can an agent bypass the approval hold by scheduling the email?
No. The hold is checked before the schedule branch, so a send with scheduled_at from a requires_approval key is held with its schedule attached, and approving it places it on the schedule rather than sending immediately. An earlier version got this order wrong, which is why the page says so.
What happens to a held email nobody reviews?
It expires 72 hours after the send was requested and drops out of the queue, so a forgotten draft never goes out days later, out of context. Approving an expired approval answers 409.

Let the agent write. Keep the send.
Create a key under Developers with the hold ticked, point the agent at it, and watch the queue. The story of how the hold was built, and the two ways it used to leak, is in the approval hold. The other two limits a key carries are on guardrails.

