Agent Mail by Ackuna

Every page here is also markdown — add .md to the URL.

Quickstart

This walks a coding agent through creating an inbox, registering a webhook so new mail is pushed to it, and replying to a message — the three actions almost every integration needs.

1. Get an API key

Two ways, same result — a key of the form ak_ + 40 characters, shown once:

An Ackuna operator can also issue a key for an organization that already exists, with php artisan api-key:issue {organization} {name}. Every request below sends the key as a bearer token.

2. Confirm what the key can do

Every request in this guide (and every path shown anywhere in these docs) is relative to the base URL https://agents.ackuna.com/api/v1:

curl https://agents.ackuna.com/api/v1/me \
  -H "Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4"

Shown from here on as a bare path against that same base, for brevity:

GET /v1/me
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
{
  "data": {
    "object": "account",
    "organization": {"id": "org_01k2n9v8qf3m7d5b0y6xz4rt1c", "name": "Acme Robotics"},
    "api_key": {
      "id": "key_01k2n9v8qf3m7d5b0y6xz4rt2d",
      "name": "prod agent",
      "scopes": ["inboxes:read", "inboxes:write", "messages:read", "messages:send", "webhooks:read", "webhooks:write"]
    },
    "access": "active",
    "quotas": {
      "inboxes": {"limit": 3, "used": 0},
      "outbound_daily": {"limit": 100, "used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_daily_shared_domain": {"limit": 100, "used": 0, "workspace_used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_monthly": {"limit": 3000, "used": 0, "scope": "service", "service_used": 0, "resets_at": "2026-08-31T23:59:59.999999Z"},
      "webhook_endpoints": {"limit": 1, "used": 0},
      "custom_domains": {"limit": 0, "used": 0}
    }
  }
}

If access were "none" here, every other call in this guide would fail with 403 agent_mail_not_enabled; "suspended" fails with organization_suspended — see Errors.

outbound_daily, outbound_daily_shared_domain and outbound_monthly each carry a resets_at; the structural caps (inboxes, webhook_endpoints, custom_domains) do not clear with time — only a larger grant helps.

3. Create an inbox

POST /v1/inboxes
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
Content-Type: application/json
Idempotency-Key: create-support-inbox-1

{"username": "orders-bot"}
{
  "data": {
    "id": "inb_01k2n9v8qf3m7d5b0y6xz4rt3e",
    "object": "inbox",
    "address": "orders-bot@agentmail.ackuna.com",
    "local_part": "orders-bot",
    "domain": "agentmail.ackuna.com",
    "display_name": null,
    "status": "active",
    "thread_count": 0,
    "created_at": "2026-08-20T14:00:00.000Z"
  }
}

Omitting domain hosts the inbox on the shared platform domain, as above. Pass a domain (a dom_... id from Receiving domains) to host it on a domain you control instead.

4. Register a webhook so new mail is pushed to you

POST /v1/webhook-endpoints
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
Content-Type: application/json
Idempotency-Key: register-agent-webhook-1

{"url": "https://agent.example.com/webhooks/ackuna", "event_types": ["inbox.message.received"]}
{
  "data": {
    "id": "whe_01k2n9v8qf3m7d5b0y6xz4rt4f",
    "object": "webhook_endpoint",
    "url": "https://agent.example.com/webhooks/ackuna",
    "event_types": ["inbox.message.received"],
    "status": "active",
    "created_at": "2026-08-20T14:01:00.000Z",
    "secret": "whsec_k3n9v8qf3m7d5b0y6xz4rt1c9a2b7e4d"
  }
}

Save secret now — it is shown exactly this once. See Webhooks for the delivery payload shape and how to verify X-Ackuna-Signature.

5. Reply to a message that arrived

When mail lands in orders-bot@agentmail.ackuna.com, the webhook above delivers an inbox.message.received event carrying the message's id. Reply to it:

POST /v1/inboxes/inb_01k2n9v8qf3m7d5b0y6xz4rt3e/messages
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
Content-Type: application/json
Idempotency-Key: reply-to-msg-01k2n9-1

{"reply_to_message_id": "msg_01k2n9v8qf3m7d5b0y6xz4rt5g", "text_body": "Thanks for reaching out — a human will follow up shortly."}
{
  "data": {
    "id": "msg_01k2n9v8qf3m7d5b0y6xz4rt6h",
    "object": "message",
    "inbox_id": "inb_01k2n9v8qf3m7d5b0y6xz4rt3e",
    "thread_id": "thr_01k2n9v8qf3m7d5b0y6xz4rt7i",
    "direction": "outbound",
    "status": "queued",
    "error_message": null,
    "from": {"email": "orders-bot@agentmail.ackuna.com", "name": null},
    "to": [{"email": "customer@example.com", "name": "A Customer"}],
    "cc": [],
    "subject": "Re: Question about my order",
    "text_body": "Thanks for reaching out — a human will follow up shortly.",
    "html_body": null,
    "headers": {"message_id": null, "in_reply_to": "<def456@example.com>", "references": ["<def456@example.com>"]},
    "attachments": [],
    "spam_verdict": null,
    "virus_verdict": null,
    "authentication": {"spf": null, "dkim": null, "dmarc": null, "spam": null, "state": "unknown"},
    "provider_message_id": null,
    "created_at": "2026-08-20T14:05:00.000Z"
  }
}

The status is 202: the message is accepted into the send queue, not yet delivered, so status is queued and provider_message_id is null. The outcome arrives as an inbox.message.sent or inbox.message.failed webhook (add both to event_types in step 4 to receive them), or read it from GET /v1/messages/{id}; see Messages. A message held by Ackuna's pre-send content check waits in in_review until a person reviews it.

reply_to_message_id is enough — to is inferred from the message being replied to. See Messages for sending without a reply target, and for how attachments and threads work.

From here: Inboxes, Threads, Messages, Webhooks, Receiving domains, Errors.