Box REST API
Use your Box MailLogic mailboxes from your own applications, scripts and automations: read and search mail, send, reply and forward, prepare drafts for a person to review, and react to new mail as it arrives.
Overview
Box is a complete mailbox service, not only an API for sending email. Each mailbox has an address on a domain you own, receives mail, keeps it in folders (Inbox, Sent, Drafts, Junk, Trash) and sends mail. One mailbox can be used three ways, and all three see the same mail:
- Webmail at /mail/, for people.
- The REST API described here, for your code.
-
The MCP server at
https://box.maillogic.io/mcp, for AI assistants: see the MCP guide.
A message your code sends appears in Sent in the webmail. A draft your code saves can be opened, edited and sent there by a person.
- Base URL
https://box.maillogic.io/v1- Format
- JSON requests and responses, UTF-8. Times are ISO 8601 in UTC.
- Authentication
Authorization: Bearer <credential>on every request.- Version
-
Every path starts with
/v1. The OpenAPI document is the machine-readable contract. The reference is generated from it.
Every mailbox is on one of your own domains. Box doesn't give out addresses on a shared domain, and it doesn't offer IMAP, POP or SMTP access. Mailboxes are used through the webmail, this API and MCP only. There is no SDK: the API is plain HTTPS and JSON, and the examples here use cURL and Python's standard library.
Quickstart
From a new account to your first API request. Steps 1 to 3 happen once, in the Console. Step 2 depends on your DNS provider: records can take a while to appear.
Set up a mailbox
- Sign in to the Console with your MailLogic account. No separate Box sign-up is needed.
-
Add your domain, such as
example.com. Box shows a TXT record that proves you own it. Add the record at your DNS provider, then choose Verify. Box then shows the mail records (MX, SPF, two DKIM CNAMEs and DMARC). Add those and choose Check DNS. When every record is found, the domain isactive. See Domains and mailboxes. -
Create a mailbox on the active domain, such as
hello@example.com.
Create an API key
In the Console, open API keys and create a key. Choose its scopes (what it may do) and,
if you like, a single mailbox it is limited to and when it stops working. For this quickstart choose
mailboxes:read and messages:read, and messages:send only if you will
send. The key (boxk_…) is shown once, when it is created: copy it then. Box cannot show it
again.
Keep the key out of your code. The examples read it from an environment variable:
export BOX_API_KEY="boxk_..." # your key, from the Console
First requests with cURL
List the mailboxes the key can reach. Note the id of the one you want (mbx_…):
curl https://box.maillogic.io/v1/mailboxes \
-H "Authorization: Bearer $BOX_API_KEY"
{
"data": [
{
"id": "mbx_10163ffbbda24de7b811388d4c2123c5",
"address": "hello@example.com",
"display_name": null,
"domain_id": "dom_80970c995ffe47d9a1459078bb445fb5",
"status": "active",
"created_at": "2026-10-22T07:20:10.380615Z"
}
]
}
Read the newest messages in its Inbox. The later examples take the mailbox's id from the environment too:
export BOX_MAILBOX_ID="mbx_..." # from the list above
curl "https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages?folder=inbox&limit=10" \
-H "Authorization: Bearer $BOX_API_KEY"
Send a message. Every send carries an Idempotency-Key that you choose, unique to this message.
If the request fails or times out, sending it again with the same key never sends a second copy:
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"to": ["ana@example.com"], "subject": "Hello from the Box API", "text": "Sent with curl."}'
{"id": "msg_404bdb48685f4ea18f3fb21791647239", "mailbox_id": "mbx_10163ffbbda24de7b811388d4c2123c5", "status": "submitted"}
202 with "status": "submitted" means Box accepted the message for delivery. It does
not mean it was delivered: delivery happens afterwards and can still be delayed or fail. See
Delivery after 202.
The same in Python
Python's standard library only, nothing to install. Set BOX_API_KEY, and set
BOX_TEST_RECIPIENT to an address of your own to receive the test message:
import json
import os
import urllib.error
import urllib.request
import uuid
BASE = "https://box.maillogic.io/v1"
KEY = os.environ["BOX_API_KEY"]
def box(method, path, body=None, headers=None):
"""One API request; the JSON answer, or an exit with Box's error."""
request = urllib.request.Request(
BASE + path,
method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json", **(headers or {})},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response) if response.status != 204 else None
except urllib.error.HTTPError as error:
detail = json.load(error)["detail"]
raise SystemExit(f"{error.code} {detail['code']}: {detail['message']} (request {detail['request_id']})")
mailbox = box("GET", "/mailboxes")["data"][0]
print("Mailbox:", mailbox["address"])
for message in box("GET", f"/mailboxes/{mailbox['id']}/messages?folder=inbox&limit=5")["data"]:
print(message["received_at"], message["from"], message["subject"])
sent = box(
"POST",
f"/mailboxes/{mailbox['id']}/messages",
{"to": [os.environ["BOX_TEST_RECIPIENT"]], "subject": "Hello from Python", "text": "Sent with urllib."},
headers={"Idempotency-Key": str(uuid.uuid4())},
)
print("Sent:", sent["id"], sent["status"]) # submitted: accepted, not yet delivered
The later examples on this page reuse the box() function above. Every error answer has the
same body, so it prints Box's error code, such as auth.insufficient_scope. See
Errors, limits and retries.
Authentication and permissions
Every request sends a credential as a bearer token. A request without one is
401 auth.missing_token; an unknown, expired or revoked one is
401 auth.invalid_token.
API keys, OAuth applications and the account owner
Box accepts three kinds of credential. They are not interchangeable:
| API key | OAuth application | Account owner | |
|---|---|---|---|
| For | Your own code, scripts and servers | Someone else's app or AI assistant (MCP) acting for you | You, signed in to the Console and webmail |
| Token | boxk_…, created in the Console |
An OAuth 2.1 access token, after you allow the app on Box's consent page | Your MailLogic sign-in (short-lived; not for integrations) |
| What it may do | Exactly the scopes chosen for it. None are given by default. |
The scopes you allow. The consent page starts with only mailboxes:read,
messages:read and messages:write chosen; every other scope only if you
choose it.
|
Everything, including the owner-only operations below |
| One mailbox only | Optional | Optional | No |
| Events and webhooks | Yes, with events:read and webhooks:*, without a mailbox restriction |
Never: these scopes can't be granted to an OAuth application | Yes |
| Revoked in | Console, API keys | Console, Authorized apps | Signing out |
Owner-only operations. Managing API keys, managing authorized apps, reading or changing
billing, and setting a mailbox's display name need the account owner's own sign-in. No API key or OAuth
application can call them, whatever scopes it holds: they answer 403 auth.sign_in_required.
The reference marks all twelve as Account owner only.
This is also why API keys are created in the Console: a key cannot create keys.
Scopes are standing permission. A key or app may use them on every request until it is revoked. Box never asks a person to confirm a call, so an application acting for a person should ask them before anything that deletes mail, releases it from Junk or cannot be undone.
Scopes
Each operation needs one or more scopes, listed with it in the reference.
Without one, the answer is 403 auth.insufficient_scope, and params.required_scope
names the missing scope.
| Scope | Allows | Account-wide | OAuth |
|---|---|---|---|
mailboxes:read | List and read mailboxes | Chosen by default | |
messages:read | List, read and search mail, threads and attachments | Chosen by default | |
messages:write | Mark messages read or unread | Chosen by default | |
messages:send | Send, reply, forward, send a draft | If chosen | |
drafts:read | List and read drafts | If chosen | |
drafts:write | Create, save and discard drafts (never send them) | If chosen | |
messages:delete | Move mail to the trash and restore it | If chosen | |
messages:destroy | Permanently delete mail in the trash, empty the trash | If chosen | |
junk:read | See mail held in Junk | If chosen | |
junk:write | Move mail to Junk | If chosen | |
junk:release | Release mail from Junk to the Inbox | If chosen | |
domains:read | List and read domains and their DNS records | Yes | If chosen |
domains:write | Add domains, verify them, check their DNS | Yes | If chosen |
domains:delete | Delete domains that have no mailboxes | Yes | If chosen |
mailboxes:write | Create mailboxes | Yes | If chosen |
mailboxes:delete | Delete mailboxes with all their mail | Yes | If chosen |
usage:read | Read the plan, its limits and usage | Yes | If chosen |
events:read | Read the event feed | Yes | Never |
webhooks:read | List and read webhooks | Yes | Never |
webhooks:write | Create and delete webhooks | Yes | Never |
Mailbox restriction. A key or app can be limited to one mailbox. It then sees only that
mailbox: any other is 404 mailboxes.not_found, exactly as if it didn't exist. Account-wide
scopes cover every mailbox and domain, so a restricted credential can't hold them, and their operations
answer 403 auth.mailbox_restricted.
A few scopes never include each other: messages:delete doesn't include
messages:destroy, junk:write doesn't include junk:release, and
drafts:write doesn't include messages:send. Each is chosen on its own.
Choosing the fewest scopes
Give each key only what its job needs, and limit it to one mailbox where you can:
| Job | Scopes | Mailbox |
|---|---|---|
Read incoming orders from orders@ | mailboxes:read, messages:read | orders@ only |
Send notifications from noreply@ | mailboxes:read, messages:send | noreply@ only |
| An assistant that drafts replies for a person to send | mailboxes:read, messages:read, drafts:read, drafts:write | One mailbox |
| A service notified of new mail | events:read, webhooks:read, webhooks:write | None (account-wide) |
| Provisioning mailboxes for new staff | domains:read, mailboxes:read, mailboxes:write | None (account-wide) |
Events are account-wide, so a service that reacts to new mail in one mailbox uses two keys: an account-wide
key holding only events:read (and the webhook scopes) to learn that mail arrived, and a key
limited to that mailbox to read it.
Expiry and revocation
- A key can expire at a time you choose. The Console suggests 90 days and offers 7, 30, 90 or 365 days, a date, or never. An expired key is refused on every request and stays listed until you revoke it.
- Revoking a key in the Console stops it at once. Revoking can't be undone: create a new key instead.
- An account can hold at most 50 keys that are not revoked. Expired keys count until they are revoked.
- The Console shows each key's scopes, mailbox, expiry and when it was last used (to within 5 minutes), never its secret.
Domains and mailboxes
Every mailbox is on one of your domains. A domain goes through these status values, and its
status always says what is left to do:
-
pending_ownership: add the domain'sverification_record(a TXT record) to its DNS, then verify it. provisioning: ownership is proved and Box is setting the domain up for mail.-
pending_dns: adddns_recordsto the domain's DNS: MX (to receive), SPF and two DKIM CNAMEs (to send), and DMARC. Then check them. active: mailboxes can be created on it.
Once a domain is active, its mail_readiness says whether its mailboxes receive
(inbound) and send (sending) now. Receiving depends on verified ownership.
Sending depends on the SPF and DKIM records: if a later check finds them wrong, sending stops
(403 domains.outbound_dns_invalid) until they are fixed, while mail keeps arriving. MX and
DMARC problems are reported in warnings.
The Console walks you through this. To do it from code, use a key with domains:read and
domains:write (account-wide):
curl -X POST https://box.maillogic.io/v1/domains \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "example.com"}'
The answer (201) holds the domain's id (dom_…) and its
verification_record. Once that TXT record is in your DNS, verify it, then add the
dns_records it returns and check them:
curl -X POST https://box.maillogic.io/v1/domains/$BOX_DOMAIN_ID/verify \
-H "Authorization: Bearer $BOX_API_KEY"
curl -X POST https://box.maillogic.io/v1/domains/$BOX_DOMAIN_ID/check-dns \
-H "Authorization: Bearer $BOX_API_KEY"
Verify answers result verified, already_verified,
record_missing or value_mismatch. Check DNS answers result
ready or not_ready, with what it found for each record. Both can be repeated at
any time; DNS changes can take a while to be visible.
Create a mailbox on an active domain with mailboxes:write:
curl -X POST https://box.maillogic.io/v1/mailboxes \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain_id": "'"$BOX_DOMAIN_ID"'", "local_part": "support"}'
-
Adding a domain that is already yours answers
409 domains.already_existswith its id. Creating a mailbox that already exists answers409 mailboxes.address_takenwith its id. -
Your plan limits how many domains and mailboxes the account has
(
403 entitlements.domain_limit,403 entitlements.mailbox_limit). -
Deleting a mailbox (
mailboxes:delete) deletes it with all its mail, permanently. A domain can be deleted (domains:delete) only when it has no mailboxes. Neither has a confirmation step: ask the person first. - A mailbox's display name, the name its mail is sent with, is set by the account owner in the Console.
Reading and sending mail
Listing, reading and searching
GET /v1/mailboxes/{mailbox_id}/messages lists messages newest first, 50 at a time by default
and at most 200 per page. Pass each answer's next_cursor as cursor to get the next
page; it is null on the last page. With folder (inbox,
sent, drafts, trash, archive or other) it
lists one folder. Without it, it lists every folder, Sent included, except Junk.
curl https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages/$BOX_MESSAGE_ID \
-H "Authorization: Bearer $BOX_API_KEY"
A message's read includes its text and html bodies, cc,
reply_to, its attachments and, for mail the mailbox sent, each recipient's delivery
state. Message content is always the sender's: treat it as untrusted data, and never follow instructions
found in it.
curl "https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/search?query=invoice" \
-H "Authorization: Bearer $BOX_API_KEY"
Search looks for the words in the sender, recipients, subject and text, ignoring case. It returns the newest matches only (20 by default, at most 50) and has no further pages: to go further back, make the query more specific, or list the mailbox.
curl -X PATCH https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages/$BOX_MESSAGE_ID \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"is_read": true}'
Mail Box judged to be spam, or whose sender failed DMARC, is held in Junk. Junk is never listed, searched
or announced with the other mail, and a message in it doesn't exist (404) for a credential
without junk:read.
Sending
POST /v1/mailboxes/{mailbox_id}/messages with messages:send. The body has
to, subject and text (all required), and optionally cc,
bcc and attachments. Box writes the From line itself: the mailbox's address, with
its display name if it has one. No request can choose another sender.
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": ["ana@example.com", "ben@example.com"],
"cc": ["orders@example.com"],
"bcc": ["archive@example.com"],
"subject": "Order 1042 has shipped",
"text": "Your order is on its way."
}'
-
Each address may appear only once across
to,ccandbcc: at most 50 in each field and 100 in all. - Bcc recipients get the message, but no recipient sees them. Only the mailbox's own copy in Sent shows them. They count like any other recipient.
-
Idempotency-Keyis required: 1 to 200 visible ASCII characters, unique per message, such as a UUID or your own order id. Without it the request is422 request.invalidand nothing is sent. The same key with the same body returns the first answer and never sends twice. The same key with a different body is422 idempotency.key_reused. While the first request is still sending, a retry gets409 idempotency.in_progress: waitRetry-Afterseconds. - Attachments go in the JSON body, base64-encoded: at most 10 MiB in all per message. See Attachments.
Delivery after 202
202 {"status": "submitted"} means Box accepted the message for delivery. It doesn't guarantee
delivery. Each recipient's outcome comes later, on the event feed, with the sent
message's id as message_id:
message.deferred: delivery failed for now and is being retried.-
message.delivered: the recipient's mail system accepted it. It doesn't mean anyone read it, or that it wasn't filed as spam there. message.delivery_failed: delivery failed and won't be retried.message.delivery_canceled: Box stopped the message before it reached the recipient.
Some receiving systems never report back, so a recipient can have no outcome at all. A recipient refused
straight away is listed in the 202 answer's refused, which is present only then.
Reading the sent message (GET …/messages/{id}) shows each recipient's state in
recipients.
Replies and forwards
Reply, reply to all and forward work from a message of the mailbox, and need both
messages:read and messages:send, plus an Idempotency-Key like any
send. Box works out who a reply goes to (the message's reply_to, or else its sender), its
subject (Re: …) and its place in the conversation. You send only the text:
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages/$BOX_MESSAGE_ID/reply \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"text": "Thanks, we have received your message."}'
-
…/reply-allalso sends to the message's other To addresses and its Cc, never to the mailbox itself or to anyone who got the message as Bcc.to,ccandsubjectreplace Box's choices, andbccadds blind copies. Read the message first, so you know who the reply will reach. -
The reply's
textis sent exactly as given: the original message is not quoted in it. -
…/forwardtakesto(required), an optional note intext, and sends the message's attachments too unless you choose otherwise (original_attachment_idsorinclude_original_attachments). Forward only to recipients the person asked for.
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages/$BOX_MESSAGE_ID/forward \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"to": ["ben@example.com"], "text": "FYI, see below.", "include_original_attachments": false}'
Threads, drafts and attachments
Threads
A thread is a conversation: a message, the replies to it, and the mail the mailbox sent in answer. Each
message has a thread_id (thr_…). GET …/threads lists a mailbox's
conversations newest first, paged like messages. GET …/threads/{thread_id} reads one whole,
oldest first (at most the 100 newest messages; omitted says how many older ones are left out).
Both need messages:read.
curl https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/threads/$BOX_THREAD_ID \
-H "Authorization: Bearer $BOX_API_KEY"
Drafts
A draft is a message that isn't sent yet: a new message (kind new), or a
reply, reply_all or forward of a message (message_id).
Drafts are shared: the same draft appears in the webmail's Drafts, and anyone allowed can edit it there.
-
Creating, saving and discarding need
drafts:readanddrafts:write; a reply or forward alsomessages:read. Sending a draft needsdrafts:readandmessages:send, and for a reply or forwardmessages:readtoo. -
POST …/draftsrequires anIdempotency-Key, as sending does: a retry returns the same draft. -
Versions. Each draft has a
version, starting at 1 and increasing with every save. Saving (PUT …/drafts/{draft_id}), discarding and sending name the version you last read. If anyone saved it since, the answer is409 drafts.version_conflictand nothing changes: read the draft again, check it, and try again with the new version. -
authorandupdated_bysay who created the draft and who last saved it: an API key, an OAuth app, or the account owner. - At most 500 open drafts per mailbox. Drafts count toward the account's storage.
An application drafts, a person sends
Give an application or assistant a key that can prepare replies but not send them. A person reviews each
draft in the webmail and sends it themselves. The key holds mailboxes:read,
messages:read, drafts:read and drafts:write, limited to one mailbox,
and no messages:send:
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/drafts \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"kind": "reply", "message_id": "'"$BOX_MESSAGE_ID"'", "text": "Hi, your order shipped yesterday."}'
{
"id": "drf_dde32c7a49b24a43904216d112459b34",
"kind": "reply",
"message_id": "msg_c095df6d1e7a49dabc62cf1921904dbc",
"version": 1,
"state": "open",
"to": null,
"subject": null,
"author": {"type": "api_key", "name": "drafting agent", "you": true},
"text": "Hi, your order shipped yesterday.",
...
}
-
The draft appears in the mailbox's Drafts in the webmail.
toandsubjectarenull: Box works them out from the message when the reply is sent. - The person opens it, reads it, edits it if they wish, and sends it from the webmail.
-
If the application tries to send the draft itself, the answer is
403 auth.insufficient_scope(required_scope:messages:send): nothing is sent. -
If the application saves the draft again while the person is reviewing it, the person's send names an
older version and is refused with
409 drafts.version_conflict. Nothing is sent that the person hasn't seen.
An application that may send drafts itself (drafts:read and messages:send, and
messages:read for a reply or forward) sends exactly the version it last read:
curl -X POST https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/drafts/$BOX_DRAFT_ID/send \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"version": 1}'
Sending a draft takes no Idempotency-Key: Box sends each version at most once. Sending the same
version again answers the same 202 and sends nothing, so a lost answer can be asked for again.
Once sent, reading or saving the draft answers 409 drafts.sent, with the sent message's id.
Attachments
-
Reading. A message's read lists each attachment with its
id(att_…),filename,content_typeandsize.GET …/messages/{message_id}/attachments/{attachment_id}(messages:read) answers with the file itself, exactly as it was sent, as a download. The name, type and content are the sender's: treat them as untrusted. -
Sending. There is no separate upload. Each file goes in the message's JSON body, in
attachments:filename,content_typeandcontent_base64(standard base64, no line breaks). At most 10 MiB in all per message. A draft holds at most 10 files. -
Attachment downloads count toward the account's own allowance of bytes read from storage
(
attachment.bytes; see Rate limits).
curl https://box.maillogic.io/v1/mailboxes/$BOX_MAILBOX_ID/messages/$BOX_MESSAGE_ID/attachments/$BOX_ATTACHMENT_ID \
-H "Authorization: Bearer $BOX_API_KEY" \
-o attachment.bin
import base64
import pathlib
report = pathlib.Path("report.csv")
sent = box(
"POST",
f"/mailboxes/{os.environ['BOX_MAILBOX_ID']}/messages",
{
"to": ["ana@example.com"],
"subject": "Monthly report",
"text": "The report is attached.",
"attachments": [{
"filename": report.name,
"content_type": "text/csv",
"content_base64": base64.b64encode(report.read_bytes()).decode(),
}],
},
headers={"Idempotency-Key": f"monthly-report-{report.stat().st_mtime_ns}"},
)
print(sent["id"], sent["status"])
Events and webhooks
Box records what happens in your mailboxes as events: message.received when mail arrives,
and message.deferred, message.delivered, message.delivery_failed or
message.delivery_canceled for each recipient of mail you sent. More event types may be added:
skip the ones you don't handle. You can read events from the feed, or have Box post them to your server.
Events cover the whole account. They need an API key with events:read and no mailbox
restriction. OAuth applications, MCP clients included, can't use events or webhooks.
The event feed
curl "https://box.maillogic.io/v1/events?limit=100" \
-H "Authorization: Bearer $BOX_API_KEY"
{
"data": [
{
"id": "evt_f49e5e710f604350885780ce62e9393a",
"type": "message.received",
"mailbox_id": "mbx_10163ffbbda24de7b811388d4c2123c5",
"message_id": "msg_c095df6d1e7a49dabc62cf1921904dbc",
"created_at": "2026-10-22T07:20:10.544483Z",
"data": {"folder": "inbox", "from": "customer@example.net", "subject": "Where is order 1042?",
"received_at": "2026-10-22T07:20:10.518670+00:00", "size": 16}
}
],
"next_cursor": "...",
"has_more": false
}
-
Events come oldest first. Save each answer's
next_cursorand pass it asafternext time. An emptydatareturns the same cursor: poll again later, not in a tight loop. -
The feed is complete and its order is stable: continuing from a saved cursor never skips an event. If you
repeat a request, you get events again: use each event's
idto skip duplicates. - Mail held in Junk is announced only if it is released to the Inbox.
Webhooks
A webhook has Box post each new event to a URL of yours, as it happens. Create one with
webhooks:write and events:read:
curl -X POST https://box.maillogic.io/v1/webhooks \
-H "Authorization: Bearer $BOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/box"}'
-
The answer (
201) includes the webhook's signingsecret, shown only this once: store it. It also includesevent_cursor: events from before the webhook was created are not delivered, and are read from the feed. -
The URL must be
https, on port 443 or 1024–65535, on a public host (not a private or local address). An account can have at most 10 webhooks. -
Each event is one
POSTwith the event as its JSON body, the same as on the feed. Answer with any2xxwithin 15 seconds to accept it. Anything else is retried: after 10 seconds, then twice as long each time, at most an hour apart. Redirects are not followed. -
Events are delivered one at a time, in the feed's order, so later events wait behind one that keeps
failing. An event can arrive more than once: use its
idto skip duplicates. -
If every attempt fails for 3 days, Box stops and the webhook's
statebecomesfailed. Nothing is lost: read the rest from the feed after itsevent_cursor, then delete the webhook and create a new one. -
Creating a webhook takes no
Idempotency-Key: a retried creation can create a second one. List your webhooks and delete the one you don't need. To change a URL or secret, create the new webhook, then delete the old one.
Verifying a delivery
Each delivery carries these headers:
Box-Webhook-Id: the webhook's id.Box-Event-Id: the event'sid.Box-Timestamp: when this attempt was signed, in seconds since 1970 (UTC).Box-Signature:v1=and 64 hex digits.
The signature is HMAC-SHA256, keyed with the webhook's secret, of the timestamp, a dot and the raw request body. Check it on the raw body, before parsing it, and reject old timestamps:
import hashlib
import hmac
import time
def verify_box_webhook(secret, headers, raw_body, tolerance=300):
"""True if a delivery is Box's: signed with this webhook's secret, within `tolerance` seconds."""
timestamp = headers.get("Box-Timestamp", "")
signature = headers.get("Box-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
if not signature.startswith("v1="):
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[len("v1="):])
Accept the event only when this returns True, then skip it if you've already handled its
Box-Event-Id.
Errors, limits and retries
Every error answer has the same body:
{
"detail": {
"code": "auth.insufficient_scope",
"message": "The credential does not grant this operation.",
"params": {"required_scope": "domains:read"},
"request_id": "2a73cb4b-503b-4d0e-aa61-58db0a43f2c1"
}
}
Branch on code, which is stable, never on message, which may change.
params gives details for some codes. Quote request_id when you ask for help.
Common errors
| Status and code | Meaning | What to do |
|---|---|---|
401 auth.missing_token | No Authorization: Bearer header | Send the key |
401 auth.invalid_token | The key is unknown, expired or revoked | Create a new key in the Console |
403 auth.insufficient_scope | The key lacks params.required_scope | Use a key that holds it |
403 auth.mailbox_restricted | An account-wide operation with a key limited to one mailbox | Use a key without a mailbox restriction |
403 auth.sign_in_required | An owner-only operation | Use the Console |
404 mailboxes.not_found, messages.not_found, … | Doesn't exist, or the key may not see it (the two look the same) | Check the ids and the key's mailbox |
413 request.too_large | The request body is too large | Send smaller attachments |
422 request.invalid | A missing or invalid field or header; params.fields names them | Fix the request; don't retry it unchanged |
422 idempotency.key_reused | The Idempotency-Key was used for a different request | Use a new key for a new message |
409 idempotency.in_progress | The first request with this key is still sending | Retry with the same key after Retry-After |
409 drafts.version_conflict | The draft was saved since you read it | Read it again, review, retry |
402 billing.payment_required | The paid plan's payment needs attention; creating and sending are paused | The owner fixes billing in the Console |
403 entitlements.email_limit | The month's sending limit is reached | Wait for the next month (UTC), or upgrade |
429 auth.rate_limited | Too many requests of one kind for now | Wait Retry-After seconds |
503 (various) | Temporarily unavailable; nothing was done | Retry after Retry-After, if given |
Every operation's own errors are listed in the reference.
Rate limits
Requests count toward the account's allowances, by kind: light reads (mailboxes, domains, usage, events, webhooks), reading mail, searching, downloading attachments, changing messages, emptying the trash, checking domains, and creating or deleting resources. Each allows a burst and refills at a steady rate. Allowances are shared by every key and app of the account, and by the API and MCP.
-
Past an allowance, the answer is
429 auth.rate_limitedwith aRetry-Afterheader (whole seconds),params.limitnaming the allowance (for exampleread.mail), andparams.scope(account, ormailboxfor a mailbox's sending). Nothing was done: wait at leastRetry-Afterseconds before retrying. -
Sending has allowances of its own: messages per mailbox (
send.messages) and recipients per account (send.recipients), plus the mail service's recipient limits per domain and per day. A retry of a message already sent is never charged again. -
A
429with emptyparamsandRetry-After: 1means too many of the account's requests are in progress at once. - Requests refused before they reach their operation (
401,403, an invalid request) count toward nothing.
Each operation's 429 in the reference states its allowance.
Those are Box's defaults. This deployment's OpenAPI document states the
values it applies, so a few descriptions there may be worded differently.
Plan limits
| Free | Plus | |
|---|---|---|
| Mailboxes | 3 | 10 |
| Domains | 3 | 10 |
| Storage | 3 GB | 10 GB |
| Sent emails a month | 3,000 | 10,000 |
-
Sent emails are counted per recipient, across the whole account, per calendar month in
UTC. A message to 3 recipients counts 3, Cc and Bcc included. Incoming mail doesn't count. Past the limit,
sending is refused with
403 entitlements.email_limituntil the next month starts. - Storage is shared by all of the account's mailboxes. Drafts count toward it.
-
In the API, the plans' codes are
freeandstarter:starteris Plus. -
GET /v1/usage(usage:read, account-wide) shows the plan, its limits and what the account uses, includingemails.remainingand when the count resets.
Safe retries
| Request | Retrying it |
|---|---|
| Send, reply, reply to all, forward, create a draft | Safe with the same Idempotency-Key and the same body |
| Send a draft | Safe: the version makes it happen once |
Reads (GET), mark read or unread | Always safe |
| Add a domain, create a mailbox | Safe: a repeat answers 409 with the existing id |
| Verify a domain, check its DNS | Safe at any time |
| Delete a mailbox or a domain | Safe: an unfinished deletion finishes, a finished one answers 404 |
| Move a message to the trash | Safe: it stays in the trash |
| Create a webhook | Can create a second one: list webhooks first |
Retry 429, 409 idempotency.in_progress and 503 after
Retry-After seconds, with the same key. Don't retry 4xx errors unchanged. Never
retry in a tight loop: back off.
API reference
The API reference lists every operation, with its scopes, parameters,
request body, responses and errors. It is generated from the
OpenAPI document at /v1/openapi.json, which you can also load
into your own tools.