A reference for errors you might encounter when using commune-mail.
Message: Invalid or expired API key
Cause: The COMMUNE_API_KEY environment variable is not set, the key has been revoked, or the key was copied incorrectly.
Fix:
# Option 1: Set env var before running your script
export COMMUNE_API_KEY="comm_your_key_here"
# Option 2: Pass directly to the client
from commune import CommuneClient
client = CommuneClient(api_key="comm_your_key_here")Get your key from the Commune dashboard. API keys start with comm_ followed by 64 hex characters. The raw key is only shown once at creation — if you've lost it, revoke it and create a new one.
Message: Inbox not found / Thread not found / Domain not found
Cause: The resource ID passed doesn't exist in your organization, or it was deleted. Common cause: passing the inbox email address (support@agents.commune.email) where the inbox ID (i_abc123) is expected.
Fix:
from commune import NotFoundError
try:
inbox = client.inboxes.get("d_abc123", "i_xyz")
except NotFoundError:
# Re-create or look up the correct inbox
inboxes = client.inboxes.list()
inbox = next((i for i in inboxes if i.local_part == "support"), None)Remember: inbox_id is the id field (e.g. i_abc123), not the email address string.
Message: Invalid email address / Missing required field: subject / text or html is required
Cause: A required parameter is missing, or a value fails server-side validation. Common cases: missing to or subject, an invalid email address format, an empty body (neither text nor html provided), or limit out of the allowed range.
Fix:
from commune import ValidationError
try:
client.messages.send(
to="user@example.com",
subject="Hello",
text="Hi there", # must provide text or html
inbox_id=inbox.id, # use inbox.id, not inbox.address
)
except ValidationError as e:
print(f"Bad request: {e.message}")Check the API_REFERENCE.md for required vs. optional fields on each method.
Message: Rate limit exceeded. Retry after {n} seconds.
Cause: Your organization has exceeded the per-hour or per-day send limit, or a burst of requests triggered the sliding-window rate limiter.
Fix:
import time
from commune import RateLimitError
for recipient in recipients:
try:
client.messages.send(to=recipient, subject="...", text="...", inbox_id=inbox.id)
except RateLimitError as e:
retry_after = int(e.headers.get("Retry-After", 60))
time.sleep(retry_after)
client.messages.send(to=recipient, subject="...", text="...", inbox_id=inbox.id)Check the X-RateLimit-Remaining and X-RateLimit-Reset headers on successful responses to monitor usage. Upgrade your plan if you consistently hit limits.
Message: API key does not have the required scope: messages:write
Cause: Your API key was created with restricted permission scopes and the operation you're attempting requires a scope that wasn't granted. For example, a read-only key trying to send email.
Fix:
from commune import PermissionDeniedError
try:
client.messages.send(...)
except PermissionDeniedError as e:
print(f"Permission denied: {e.message}")
# Go to the dashboard, create a new key with the required scopes:
# messages:write, inboxes:write, etc.From the Commune dashboard, create a new API key with the scopes your agent needs. Available scopes: domains:read, domains:write, inboxes:read, inboxes:write, threads:read, messages:read, messages:write, attachments:read, attachments:write.
Message: WebhookVerificationError: Invalid signature or WebhookVerificationError: Timestamp too old
Cause: Either the wrong webhook secret is being used, the raw request body was modified before verification (e.g. parsed by a JSON middleware and re-serialized), or the timestamp is more than 5 minutes old (replay attack protection).
Fix:
from commune import verify_signature, WebhookVerificationError
@app.post("/webhook")
async def handle_email(request: Request):
# IMPORTANT: read the raw body bytes before any parsing
body = await request.body()
try:
verify_signature(
payload=body, # raw bytes, not parsed JSON
signature=request.headers["x-commune-signature"],
secret=os.environ["COMMUNE_WEBHOOK_SECRET"], # whsec_... from dashboard
timestamp=request.headers["x-commune-timestamp"],
)
except WebhookVerificationError as e:
return Response(status_code=401, content=str(e))
payload = json.loads(body)
# ... processThe webhook secret (whsec_...) is separate from the API key (comm_...). Find it in the inbox settings on the dashboard. Make sure your framework isn't consuming request.body() before your handler runs.
Symptom: After creating an inbox with domain_id="d_abc123", the address still shows support@agents.commune.email instead of support@yourcompany.com.
Cause: Your custom domain hasn't been verified yet. DNS records need to propagate before Commune can accept mail for your domain.
Fix:
# Check domain status
domain = client.domains.get("d_abc123")
print(domain.status) # "not_started", "pending", or "verified"
if domain.status != "verified":
records = client.domains.records("d_abc123")
print("Add these DNS records at your registrar:")
for r in records:
print(f" {r['type']} {r['name']} → {r['value']}")
# After adding records, trigger verification:
client.domains.verify("d_abc123")DNS propagation typically takes 5–30 minutes. Re-check with client.domains.get() after a few minutes. If status stays "pending" for over an hour, double-check the records are added correctly with dig MX yourcompany.com.
Symptom: You reply to an inbound email with messages.send(...), but the customer sees it as a new separate email conversation instead of a reply in the same thread.
Cause: The thread_id parameter was not passed to messages.send(). Without it, Commune creates a new message with no relation to the original thread.
Fix:
# In your webhook handler, extract thread_id from the payload
payload = json.loads(body)
thread_id = payload["thread_id"] # always present on inbound messages
sender = payload["from"]
# Pass thread_id when replying
client.messages.send(
to=sender,
subject="Re: " + payload["subject"],
text="Thanks for writing in — here's our response...",
inbox_id=inbox.id,
thread_id=thread_id, # this is what groups it in the email client
)The thread_id comes from the webhook payload when an email arrives, or from threads.list() if you're processing older emails. It is a required field for any reply — never optional.
Symptom: client.messages.send() returns without error, but the recipient never receives the email. No bounce notification either.
Cause: The recipient's email address is on the suppression list. Hard bounces and spam complaints automatically add addresses to suppression. Sending to a suppressed address is silently dropped to protect your domain reputation.
Fix:
# Check the suppression list for your inbox
suppressions = client.delivery.suppressions(inbox_id=inbox.id)
suppressed_addresses = [s["email"] for s in suppressions]
if "user@example.com" in suppressed_addresses:
print("Address is suppressed — do not send")
# If suppression was a mistake, remove via dashboard or contact support
# Check delivery events for debug info
events = client.delivery.events(domain_id=domain.id, limit=20)
for event in events:
print(event["type"], event["recipient"], event.get("reason"))Other causes: the recipient's mail server rejected the message (check events for bounce details), or the email was caught by the recipient's spam filter (ensure DKIM/SPF are verified on your domain).
Symptom: Inbound webhook payloads don't include the extracted field, or it's always null, even though you set an extraction schema on the inbox.
Cause: Either the schema was set on a different inbox_id than the one receiving mail, the schema was not enabled after creation, or the domain_id / inbox_id pair was incorrect when calling set_extraction_schema.
Fix:
# Verify the schema was set on the correct inbox
inbox = client.inboxes.get(domain_id, inbox_id)
print(inbox) # check for extraction schema in the response
# Re-apply if needed — make sure domain_id and inbox_id match the receiving inbox
client.inboxes.set_extraction_schema(
domain_id=domain.id, # must match the domain the inbox belongs to
inbox_id=inbox.id, # must be the inbox that receives the webhook
name="my_schema",
schema={
"type": "object",
"properties": {
"intent": {"type": "string"},
"priority": {"type": "string"},
},
},
)After setting the schema, send a test email to the inbox and check the webhook payload. The extracted field should appear alongside content and thread_id.
Message: Attachment scan failed: connection refused or attachments stuck in processing
Cause: This only affects self-hosted Commune deployments. The CLAMAV_HOST environment variable is not set, or the ClamAV daemon (clamd) is not running at the configured host/port.
Fix:
# Option 1: Start ClamAV daemon (example for Ubuntu/Debian)
sudo apt-get install clamav-daemon
sudo freshclam # update virus definitions
sudo systemctl start clamav-daemon
# Option 2: Set the env var to point at your clamd instance
export CLAMAV_HOST=127.0.0.1
export CLAMAV_PORT=3310 # default clamd port
# Option 3: Disable ClamAV — fall back to heuristic scanning
# Simply don't set CLAMAV_HOST. Commune automatically falls back to
# heuristic scanning (extension, MIME, magic bytes, VBA macros).For cloud-hosted Commune (commune.email), attachment scanning is always active and does not require any configuration. This error only occurs when running the Commune server yourself.
Symptom: An await call on the AsyncCommuneClient never completes, or the script exits without sending the email.
Cause: Either you forgot to await an async call (in which case you'd get a coroutine object, not a result), or you used async with inside a function that wasn't declared async, or asyncio.run() was called from within an already-running event loop (common in Jupyter notebooks).
Fix:
import asyncio
from commune import AsyncCommuneClient
# Correct: use async with and await every call
async def send_email():
async with AsyncCommuneClient(api_key="comm_...") as client:
inbox = await client.inboxes.create(local_part="support") # don't forget await
await client.messages.send(
to="user@example.com",
subject="Hello",
text="Hi",
inbox_id=inbox.id,
)
asyncio.run(send_email())
# In Jupyter notebooks, use await directly (event loop already running):
# await send_email()
# Or install nest_asyncio: import nest_asyncio; nest_asyncio.apply()If you're inside a FastAPI route handler, the function is already async — just await the commune calls directly without wrapping in asyncio.run().