Skip to the documentation
CLI

Keys, members and roles

Manage API keys and read what they did, invite and manage members, write roles, check the credential you are using, and make disposable inboxes.

Overview

These commands decide who and what can reach the workspace. openemail keys manages API keys and reads what each one did, openemail members manages the people in the workspace and their invitations, and openemail roles defines what a member or a key may do. openemail me describes the key or sign-in you are calling with, and openemail languages lists the languages a translated send accepts. Disposable inboxes need no sign-in at all: openemail temp is the everyday way to use one, and openemail temp-mail is every call of the API behind it. openemail api reaches any endpoint the other commands do not.

  • A key command takes the key id, the 24 hex characters after oe_live_, as keys list shows it. A member command takes the account id, userId in members list, never an email address. A role command takes a role_ id from roles list, because roles have no lookup by name.
  • The namespaces answer to key, member, role, language and tempMail as well. The usual verb aliases work, such as ls, show, new, edit and rm. In members, whose verbs are add and remove, new and create lead to add, and rm, del and delete lead to remove.
  • openemail <command> --help lists every argument and flag with its type, the scope the call needs, its method and path, and what comes back. Add --json for the same page as data.

Every command

CommandWhat it does
openemail me getDescribe the API key or browser sign-in you are calling with: its scopes, the role that caps it, its workspace and who it may send as. Needs no scope
openemail me pingCheck that the credential authenticates, for a health check. Needs no scope
openemail me rotateGive the API key you are calling with a new secret, shown once. Asks you to confirm
openemail keys listList the workspace’s API keys, newest first, with status, scopes, role, send scope and last use. Never a secret
openemail keys get <id>Read one key, without its secret
openemail keys create --name <value>Mint a key and receive its secret once, in token
openemail keys update <id>Rename a key, replace its scopes or send scope, or switch it off and on with --no-enabled and --enabled
openemail keys delete <id>Remove a revoked key from the list, keeping its history. Asks you to confirm
openemail keys rotate <id>Give a key a new secret, shown once, and stop the old one at once. Asks you to confirm
openemail keys revoke <id>Revoke a key for good, with an optional --reason. Asks you to confirm
openemail keys list-requests <id>Read one key’s request log: method, path, status, error code, duration, IP and user agent
openemail keys list-activity <id>Read what happened to one key: created, changed, rotated, switched off and on, revoked, deleted, and every refused call
openemail keys list-workspace-requestsRead the request log of every key you can see, or of the ones --key-ids names
openemail keys list-workspace-activityRead what happened to every key you can see, or to the ones --key-ids names
openemail roles listList the workspace’s roles, seeded ones first, with how many members and keys hold each
openemail roles get <id>Read one role with its permissions and live usage counts
openemail roles create --name <value> --permissions <a,b>Create a custom role, with an optional --description
openemail roles update <id>Rename a role, change its description, or replace its whole permission list
openemail roles delete <id>Delete a role and move whoever holds it to the role in --reassign-to. Asks you to confirm
openemail roles list-permissionsList the permission vocabulary, with a label, a group and whether a key can hold each one
openemail members listList everybody with access, the owner first, with their role, permissions and the addresses and domains each may use
openemail members get <user-id>Read one member by account id
openemail members add --email <value> --role-id <value>Invite somebody with a role, and with addresses or whole domains through --address-ids, --domain-ids and --access
openemail members update <user-id> --role-id <value>Move a member to another role. Their address and domain grants stay as they are
openemail members remove <user-id>Take somebody out of the workspace with every address grant they hold. Asks you to confirm
openemail members grant-address <user-id> --address-id <value>Give a member one address, or change their --access to it
openemail members revoke-address <user-id> <address-id>Take one address back from a member. Asks you to confirm
openemail members list-invitationsList the invitations nobody has accepted yet, expired ones included
openemail members revoke-invitation <invitation-id>Withdraw an invitation, so its link stops working. Asks you to confirm
openemail members resend-invitation <invitation-id>Send an invitation again, with a new link and 14 more days
openemail languages listList every language a translated send accepts, in the order a picker should show them. Needs no scope
openemail temp new [--name <local-part>] [--domain <domain>] [--ttl <minutes>]Create a disposable inbox and print only its address. Needs no sign-in
openemail temp listList the disposable inboxes this CLI created, without reading the network
openemail temp read [inbox] [message-id]List the mail in an inbox, or print one message as readable text
openemail temp watch [inbox] [--first]Print each new message as it lands, checking every 3 seconds
openemail temp delete [inbox] [--yes]Delete an inbox and its mail now, and forget its token. Asks you to confirm
openemail temp-mail list-domainsList the domains a disposable inbox can be created on. Needs no credential
openemail temp-mail createCreate a disposable inbox and its inbox token, which the CLI saves. Needs no credential
openemail temp-mail get <inbox-id>Read an inbox’s expiry, extensions left and message count
openemail temp-mail extend <inbox-id>Push the expiry up to an hour further out, within 24 hours of the inbox being created
openemail temp-mail delete <inbox-id>Destroy an inbox and its mail now. Asks you to confirm
openemail temp-mail list-messages <inbox-id>List one page of the messages, newest first, each with a short plain-text snippet
openemail temp-mail get-message <inbox-id> <message-id>Read one message with its stored body, and mark it seen
openemail temp-mail delete-message <inbox-id> <message-id>Delete one message with its body and attachments. Asks you to confirm
openemail temp-mail list-attachments <inbox-id> <message-id>Read a message’s attachments, with their bytes as base64
openemail api <method> <path>Call any REST endpoint with your sign-in, its verification codes and its confirmations

Every flag is in the help of its command, for example openemail keys create --help, openemail members add --help or openemail temp new --help.

API keys

Reading keys needs keys:read, and every change needs keys:manage. A browser sign-in is never granted keys:write or keys:manage, so creating, changing, rotating, revoking and deleting keys takes an API key that holds keys:manage, or the web app (openemail open api-keys). A browser sign-in with keys:read reads keys only for the workspace owner, and a member’s sign-in is refused with 403 owner_only.

  • keys create, keys rotate and me rotate print the key’s secret, in token, once, and the CLI then warns that it is never shown again. Every read shows maskedKey instead.
  • Left out, a new key holds emails:send only, and takes the role, send scope and expiry of the key creating it. --domain-allowlist and --address-allowlist set who it may send as, and --expires-in-minutes takes 5 to 5,256,000, which is ten years.
  • A key never makes or reaches a key wider than itself. Scopes, role, expiry, mode and send scope all have to sit inside the calling key, or the call is refused with 403 beyond_caller_authority, and param names what was too wide. A key narrowed to some domains or addresses only sees the keys inside its own send scope, and any other is a 404.
  • keys update replaces what you send: --scopes, --address-allowlist and --domain-allowlist each take the whole new list, and a flag you leave out stays as it was. --no-enabled switches a key off, so every call with it is refused with inactive_api_key, and --enabled restores it exactly. That stops a key in a way you can undo.
  • keys revoke is for good: the key can never be switched back on, rotated or changed. keys delete only removes a revoked key, and any other is refused with 409 not_revoked. A deleted key’s request log and activity stay, under Deleted key.
  • keys rotate has no overlap window, so the old secret stops working the moment the new one comes back. When the key is the one your saved profile uses, the CLI saves the new secret to that profile, so it keeps working. A key from OPENEMAIL_API_KEY or --api-key cannot be saved, so the CLI tells you to store the new token wherever the old key was kept.

The request log records every call a key made: method, path, status, error code, duration, IP and user agent, never a body or a query string. Nothing is pruned, so it reaches back to a key’s first call, and calls made with a browser sign-in are not in it. The activity log records every change to a key, and every call that presented the key and was refused, as auth_failed, with who made each change in actor.

  • list-requests and list-activity read one key. list-workspace-requests and list-workspace-activity read every key you can see, or up to 50 that --key-ids names, deleted keys included.
  • --since and --until keep a window and take an ISO 8601 time such as 2026-09-01T00:00:00Z. --failed-only keeps the calls answered with a status of 400 or more.

Your credential, and languages

openemail me get is the first command to run when a call is refused. It needs no scope, so any valid key or sign-in can describe itself.

  • scopes is what the credential may do right now: the scopes it was created with, cut down by the role it was issued under, worked out on every request. grantedScopes is what it was created with, and roleId names the role. A scope in grantedScopes and missing from scopes was removed by the role. That is the usual reason for a 403 insufficient_scope on a key that seems to hold the scope, and the fix is to change the role rather than mint another key.
  • domainAllowlist and addressAllowlist say who it may send as. Both null means any address the workspace owns.
  • With a browser sign-in it describes the sign-in: object is oauth_token, clientId names this CLI’s connected app, and expiresAt is when your approval ends, or null when it never does.
  • me ping answers ok: true with the same scope detail but without the allowlists, which suits a health check. A revoked, expired, switched off or mistyped key fails with a 401 and exit code 3.
  • me rotate gives the key you are calling with a new secret. It needs keys:write, which a browser sign-in never holds, so it takes an API key. Everything else about the key stays, the old secret stops working at once, and a saved profile gets the new one, as with keys rotate. A lost answer can leave the key with a secret nobody saw, and it then needs a new one from the web app.
  • openemail whoami shows the same answer formatted for people.

openemail languages list prints the whole language table in one answer, around two hundred rows, with each language’s code, English name, own name, flag and whether it is written right to left. The code, the English name or the own name all work as the target of a translated send. It needs a sign-in but no scope. openemail ai languages prints the same table with a --search flag, and signed out it prints the table bundled with the CLI.

Members and roles

A member holds two things that are never merged. Their role says what they may do, and their address and domain grants say which mail they may do it to, each grant with its own access: member reads and sends, and viewer only reads. A send needs both, so a role with emails:send and a viewer grant on an address still cannot send from it. A whole domain covers every address on it, including ones made later.

  • members list puts the workspace owner first, marked isOwner, so leave that row out when counting seats. The owner holds every permission and cannot be invited, changed or removed, and neither can anybody already in the workspace be invited again: both are 422 member_is_owner.
  • People who hold address grants but were never given a role come back with implied: true, and their role is inferred from their grants. members update gives them a real one.
  • members add sends an invitation, even to somebody who already has an account. Nothing is granted until they accept, and then exactly the role, addresses and domains it carries. Inviting the same address again within ten minutes is 409 invitation_too_soon, and after that it refreshes the waiting invitation rather than sending a second.
  • resend-invitation sends a new link good for 14 more days and retires the old one, which also renews an expired invitation. revoke-invitation withdraws one, and an invitation that was already accepted is 409 invitation_accepted, so remove the member instead.
  • members update changes the role and nothing else. grant-address gives one address or changes the access to it, so running it again with another --access changes the grant rather than adding a second. revoke-address takes one address back and leaves the rest. Revoking the last grant of an implied member removes them from the workspace.
  • members remove ends somebody’s access to the workspace, their membership and every grant, and reports how many address grants went in addressesRevoked. Their account and the mail they sent are untouched.

A role is also a ceiling for the API keys issued under it. What a key may do is its own scopes cut down by its role’s permissions, worked out on every request.

  • roles list shows the seeded roles first, in the order Owner, Admin, Member, Viewer, Developer and Billing, then custom roles by name. A workspace holds up to 24 custom roles, and past that roles create is 422 role_limit_reached.
  • A role stores the permissions its permissions imply, so templates:write also stores templates:read, and roles:write brings roles:read and members:read. Read the list back from the answer rather than assuming it.
  • roles update --permissions replaces the whole list, so read the role, change the list and send all of it. --description null clears the note. A change is live on the next call of every member and key that holds the role.
  • Every role but Owner can be renamed, rewritten and deleted, seeded ones included, and a deleted seeded role does not come back. The owner role answers an edit with 409 role_immutable and a delete with 409 role_undeletable.
  • While any member, API key or waiting invitation holds a role, roles delete needs --reassign-to with the role that takes them over, or it is refused with 409 role_in_use. Revoked keys still point at their role, so a role whose apiKeys count is 0 can still need it. The answer reports reassigned people and keysReassigned keys.
  • roles list-permissions lists the whole vocabulary with a label and a group for each. A few, such as billing:write and workspace:manage, come back with scope: false: a role may hold them, but no key can.

A key holding roles:write can edit the role that caps it and widen itself on its next call, so keep that scope off keys that only need to read. With a browser sign-in, members:write and roles:write are granted only when the approval covers the whole workspace rather than some domains or addresses.

Disposable inboxes

A disposable inbox needs no account and no sign-in. It is reached with its own inbox token, which begins oe_inbox_ and comes back once, when the inbox is created. Use openemail temp day to day, and openemail temp-mail when you need a field or a step that temp does not show, such as the extensions left, an extension, or the bytes of an attachment.

  • Both keep the token in ~/.openemail/temp-mail.json, readable by you alone. temp new and temp-mail create save it, temp list shows inboxes made either way, and both deletes forget it. A saved inbox can be named by its address wherever a command asks for its id.
  • For an inbox this CLI did not create, pass the token with --inbox-token. With no saved or passed token, the command stops with exit code 3 before anything is sent.
  • The two create commands name their flags differently: temp new takes --name, --domain and --ttl, and temp-mail create takes --local-part, --domain and --ttl-minutes. A local part is 3 to 32 letters, digits, dots, dashes or underscores, starting and ending with a letter or digit, and names such as postmaster are refused. The lease is 1 to 1440 minutes, 60 by default.
  • Each IP address can create 6 inboxes an hour and 30 a day, and the next is 429 too_many_inboxes, exit code 8. Extending an inbox you already hold does not count, so temp-mail extend is the answer to that limit.
  • temp-mail extend adds up to an hour, never past 24 hours after the inbox was created, and at most 23 times. Read extensionsLeft from the answer. At 0, it is 409 extension_limit for good.
  • temp-mail list-messages reads 1 to 50 messages a page, 50 by default, each with a plain-text snippet of up to 400 characters that often holds a one-time code. Nothing past a page is dropped, and --all walks every page.
  • Reading a message with temp read, temp-mail get-message or temp-mail list-attachments marks it seen. A body over 2 MB is cut, which truncated says, and an attachment over 8 MB was never kept, so its content is null.
  • Deleting an inbox deletes its mail at once, but the address stays reserved until 7 days after its lease would have ended, and asking for it again before then is 409 address_taken.

Mail in a disposable inbox comes from strangers, to an address anyone could name. Its sender is never verified and nothing in it is scanned, so treat its links, HTML and attachments with care.

Any endpoint, and the security namespace

openemail api <method> <path> sends one request through the same transport as every other command, so your profile or key, token renewal, verification codes and confirmations all apply. A path on its own is a GET, and a JSON answer prints formatted. openemail api /keys/self is the call behind me get.

  • -d, --data takes the body as inline JSON, from a file with @path, or from stdin with -. -q, --query and -H, --header take key=value and can be repeated, and -o, --out saves the answer to a file as it came.
  • A DELETE, and any call a resource command would confirm, such as revoking or rotating a key, asks you to confirm first, and unattended it needs --yes.
  • A failed request prints the API error and exits with the matching code.

The security namespace is not listed in openemail --help, because openemail verify drives it. Its verbs step-up-status, begin-step-up and verify-step-up are the calls verify makes: verify --status reads the status, and verify asks for a code, prompts you for it and checks it. They exist for a browser sign-in. With an API key each is refused with 400 step_up_not_applicable, and openemail verify says a key never needs a code.

Examples

Mint a key for a script and sign it in
openemail keys create --name 'Billing sender' --scopes emails:send \  --domain-allowlist billing.acme.com --expires-in-minutes 129600 --json \  | jq -r .token | openemail login --with-token --profile billingopenemail whoami --profile billing

Run it with an API key that holds keys:manage, for example through OPENEMAIL_API_KEY. The secret goes from the answer straight into a new profile, so it never lands on screen or in a file. The key can send only from billing.acme.com, and it expires in 90 days.

Audit keys and their failed calls
openemail keys list --all | jq -r 'select(.status != "active") | [.name, .status, .lastUsedAt] | @tsv'openemail keys list-workspace-requests --failed-only --since 2026-09-26T00:00:00Z --all \  | jq -r '[.createdAt, .keyName, .status, .errorCode, .method, .path] | @tsv'
Retire a key
id=4c1b257a66287fd113bd89d0openemail keys update "$id" --no-enabledopenemail keys list-activity "$id" --since 2026-09-27T00:00:00Z --all | jq -r 'select(.type == "auth_failed") | .createdAt'openemail keys revoke "$id" --reason 'Contractor offboarded' --yesopenemail keys delete "$id" --yes

Switching the key off first can be undone with --enabled. Every call that still presents it is refused and shows up in its activity as auth_failed, which tells you what still depends on it. Revoking cannot be undone, and only a revoked key can be deleted.

Create a role and invite somebody with it
openemail roles list-permissions --json | jq -r '.[] | [.group, .id, .label] | @tsv'role=$(openemail roles create --name Support --permissions threads:write,emails:send,templates:read \  --description 'Answers help@ and nothing else.' --json | jq -r .id)openemail members add --email [email protected] --role-id "$role" \  --domain-ids 93542ff8-2baa-4f2f-841d-5ceaa074ab0d --access memberopenemail members list-invitations

The role comes back holding threads:read and emails:read as well, because the permissions it names imply them. Sam gets the role and the whole domain only once they accept. With a browser sign-in, members add asks for a verification code first.

Move a teammate, then delete their old role
old=role_8b1f4c2e9a7d3b60e5f1a2c4new=role_2c7e9a1f4b8d3e60c5a7f1b9user=$(openemail members list --all | jq -r 'select(.email == "[email protected]") | .userId')openemail members update "$user" --role-id "$new"openemail roles get "$old" --json | jq '{name, members, apiKeys}'openemail roles delete "$old" --reassign-to "$new" --dry-runopenemail roles delete "$old" --reassign-to "$new" --yes

members and apiKeys are counted when you ask, so they show what the delete will move. The dry run prints the DELETE with reassignTo in its query without sending it. With a browser sign-in, the update and the delete each ask for a verification code, so run openemail verify first when a script does this.

Check delivery with a disposable inbox
address=$(openemail temp new --ttl 15)openemail send --from [email protected] --to "$address" --subject 'Delivery check' --text 'Your code is 482913' --yesopenemail temp watch "$address" --first --json | jq -r .snippet | grep -oE '[0-9]{6}'openemail temp delete "$address" --yes

temp new prints only the address, so it fits in a shell variable, and temp watch --first stops at the first message. Point a sign-up form at the address instead of openemail send to catch its confirmation code the same way.

Scopes, confirmations and errors

ScopeCommands
keys:readkeys list, get, list-requests, list-activity, list-workspace-requests, list-workspace-activity
keys:managekeys create, update, delete, rotate, revoke
keys:writeme rotate
roles:readroles list, get, list-permissions
roles:writeroles create, update, delete
members:readmembers list, get, list-invitations
members:writemembers add, update, remove, grant-address, revoke-address, revoke-invitation, resend-invitation
None, with any key or sign-inme get, me ping, languages list
None, and no sign-intemp, temp-mail list-domains and create. The other temp-mail commands take the inbox token
  • A sign-in or key without the scope stops with exit code 4, names the missing scope and says how to get it.
  • These ask you to confirm: keys delete, rotate and revoke, me rotate, roles delete, members remove, revoke-address and revoke-invitation, temp delete, and temp-mail delete and delete-message. Answering no exits with code 10 and changes nothing. Unattended and without --yes, they stop with exit code 2 before anything is sent.
  • With a browser sign-in, roles update and roles delete, and members add, update, remove, grant-address and revoke-address, also ask for a verification code, unless this sign-in verified one in the last 60 minutes. --yes never skips it, and unattended nobody can type it, so the command stops with exit code 4. Run openemail verify first. An API key is never asked.
  • --dry-run prints the request a change would send, with its body, and exits with code 0 without sending it or asking you to confirm.
  • A list reads one page. --limit takes 1 to 100 and the server sends 25 when it is left out, except in temp-mail list-messages, which takes 1 to 50 and sends 50. --cursor takes the nextCursor of the page before. --all reads every page, --max <n> stops after that many items, and --ndjson, or --all in a pipe, prints one JSON object per line. With --json, a list prints one { items, hasMore, nextCursor } document.
  • roles list-permissions, languages list, temp-mail list-domains and temp-mail list-attachments return everything at once, as a plain array, with no pages.
  • A refusal exits with the code of its status: 3 for a 401, such as a revoked key, 4 for a 403, such as beyond_caller_authority or owner_only, 5 for a 404, 6 for a 409, such as not_revoked, role_in_use or invitation_too_soon, 7 for a 400 or a 422, such as member_is_owner or role_limit_reached, and 8 for a 429, such as too_many_inboxes.
  • A change that would do something twice is never retried after a network failure: keys create and rotate, me rotate, roles create and delete, members add, remove, revoke-address and resend-invitation, and temp-mail create, extend, delete and delete-message. Check before you run one again. Reads, and changes that land the same way twice, such as keys update, keys revoke, roles update, members update and grant-address, are retried on their own.

Where to go next

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.