PineMail
PricingBusinessDevelopers

PineMail is in beta. Card payment is not open yet, and we raise an account's limits on request. See the limits

PineMail API

Transactional mail, domains and mailboxes for your projects, and files and buckets for your account. Sending takes a project, a verified from-domain, a key and POST /v1/emails; 202 means queued. JSON is snake_case. Errors are RFC 7807 application/problem+json. Public URL is https://pinemail.app/v1/… (also served at /api/v1). Contract: /openapi.yaml. AI agents reach the same operations as tools through the MCP server at https://pinemail.app/mcp, with the same keys. For JavaScript and TypeScript there is an SDK.

How it fits together

An account is a sign-up. It has one plan, one storage quota shared by everything in it, and toggles for which services are on.

An account has projects. Each project has its own API keys, domains, webhooks, templates and directory.

A domain belongs to a project and holds mailboxes.

A mailbox is an address on a domain, with its mail, calendar, files and settings. A mailbox without a login of its own is used by the account owner, who signs in with the account email and password and sees every mailbox.

A mailbox login is a login of the mailbox's own: a person signs in with the mailbox address and their own password, on the web and in mail apps, and sees only that mailbox.

Storage is per account. Mailboxes have no quota of their own; mail, calendars and files of every mailbox and project count against the account's quota.

Send limits are per account, not per mailbox, and count outgoing recipients: a message to three people (to, cc and bcc) counts three. A free account sends to 300 recipients a month from its mailboxes and 100 through the API, and to at most 100 a day and 20 a minute. A paid account has no monthly ceiling, as it pays per 1,000 recipients; it sends to at most 1,000 a minute. Over a limit the API answers quota_exceeded. Sending through the API and from the mailboxes is paused when too much of it bounces or is reported as spam. Incoming mail has no limit; it counts only against the account's storage. A mailbox can also be given a cap of its own per day (daily_send_limit, none unless you set one), which applies from the web client and from mail apps alike.

GET /v1/limitsgives every limit on what the key reaches with what is used of it: recipients this month from the mailboxes and through the API, in the last 24 hours and the last minute, whether sending is paused, storage, and how many projects, mailboxes and domains the account may have, with each mailbox's own daily cap and what it sent. Any key may call it. A sign-in that reaches mailboxes of other accounts also gets those accounts' sending limits.

bash
curl https://pinemail.app/v1/limits -H "Authorization: Bearer $PINEMAIL_KEY"

Keys

Mail belongs to a project and storage to the account, so there are keys for each. Give every application its own key, with only what it needs.

KeyMade underStarts withReaches
Sending keyThe project's API keyspm_live_POST /v1/emailsonly: from the project's verified domains, or, when you lock it, from one domain and the From addresses you list
Full project keyThe project's API keyspm_live_The project's mail, domains, mailboxes, webhooks, templates and directory
API key (account key)Buckets → Access keyspm_acct_The account's buckets (create, settings, protection, objects), S3 keys and Drive files, for every bucket or the ones you choose
S3 keyBuckets → Access keys, or POST /v1/bucket-keysan access key ID and a secretObjects in the buckets you choose, over S3 at s3.pinemail.app. It cannot create or delete buckets.

A key used where it does not belong answers 403and says which key to use instead. Each key is shown once, when it is made; a revoked key stops working at once. A key's name and what it may do can be changed on the page where it was made: its scopes, a sending key's domain and addresses, or the buckets and permission of an account or S3 key. The key itself stays the same, and the change applies to the next call.

Authentication

Create a key under the project’s API keys page. Pass it as a Bearer token. Keys start with pm_live_. The full token is shown once.

http
Authorization: Bearer pm_live_...

An application that only sends gets a sending key: it has the emails:send scope alone and is locked to one domain of the project and, if you list them, to the From addresses it may use. A send from any other address answers 403 and names what the key is allowed. A full project key manages the project (domains, mailboxes, webhooks, templates, the directory) and sends from any verified domain.

Access levels

Each key has a set of scopes. Full access is every scope the API has when the key is made; a scope added later is not given to existing keys. A key that calls an endpoint outside its scopes receives 403. Keys made before files and buckets keep the mail, domain and directory scopes. Mail, domains and the directory belong to the key's project. Files, buckets and S3 keys belong to the account and are reached with an account key (see Account keys below); project keys made before account keys keep the storage scopes they were given. A key stops working when it is revoked or the account is suspended.

ScopeWhat it allows
emails:sendPOST /v1/emails and /v1/emails/batch, PATCH /v1/emails/{id} and its cancel
emails:readGET /v1/emails, GET /v1/emails/{id}, GET /v1/suppressions
suppressions:writeAdd to and take off the suppression list. Keys with full access made before it was added do not have it; edit the key to add it.
domains:readList and fetch sending domains
domains:writeAdd, verify, or delete a domain; publish its records to Cloudflare
mailboxes:readList and fetch mailboxes
mailboxes:writeCreate, update, or delete a mailbox
webhooks:readList webhooks and delivery history
webhooks:writeCreate, edit, test, or delete a webhook
templates:readList and fetch templates
templates:writeCreate, edit and delete templates. Keys with full access made before it was added do not have it; edit the key to add it.
directory:readList the organisation directory
directory:writeAdd or remove a directory person
mail:readList, search and read threads in the project's mailboxes
mail:draftWrite drafts in a mailbox
mail:sendSend and reply from a mailbox
mail:organizeArchive, mark read, star, snooze, label, mark as spam and trash threads
calendar:readRead a mailbox's calendar
files:readList, fetch and download the account's files, versions and share links
files:writeUpload, rename, move, delete, restore and share files
buckets:readList buckets, objects and S3 keys; download objects
buckets:writeCreate, change and delete buckets; upload and delete objects
bucket_keys:writeCreate and revoke S3 access keys

Send an email

from must use a verified domain on the project — your domain after DNS, or @pinemail.app immediately. Display names are kept (Acme <hello@pinemail.app>). Provide text or html. A 202 means queued — poll GET /v1/emails/{id} or watch the project email log. Retry a POST only with the same Idempotency-Key.

  • Templates: instead of subject, html and text, send "template": {"slug": "welcome", "variables": {"name": "Ada"}} (or its id). Values in HTML are escaped; a missing variable is 422 and named. A subject given with a template wins.
  • Later: scheduled_at (ISO 8601, up to 30 days ahead) answers status: "scheduled". PATCH /v1/emails/{id} with a new scheduled_at moves it, and POST /v1/emails/{id}/cancel cancels it, both while it is still scheduled.
  • Undo: undo_seconds (1–300) holds the email that long, and the answer has undo_until. Until then POST /v1/emails/{id}/canceltakes it back, and the project's Emails page, when open, shows it with Undo. It is scheduled meanwhile, so it cannot be combined with scheduled_at.
  • Attachments: attachments is a list of {"filename", "content", "content_type"}, the content in base64, at most 20 files and 30 MB together. The type is guessed from the name when left out. The files are deleted once the email has gone; their names stay in the log.
  • Many at once: POST /v1/emails/batch takes a list of up to 100 emails in the same shape, without attachments. Each is accepted or refused on its own, and the answer lists them in order.
  • Suppression list (do not send): the project sends nothing to these addresses, neither through the API nor from its mailboxes (the mail app, the mailbox API, SMTP). A hard bounce or a spam report adds the address by itself; someone who asked not to be written to is added with POST /v1/suppressions and addresses(up to 100), or in the mail app: in a message's menu (Don't send to …) or under Contacts. GET /v1/suppressions lists it, GET /v1/suppressions/{address} answers for one address (404 when it is not listed), and DELETE /v1/suppressions/{address} takes one off. A send to a listed address is refused with 422 naming it, and a scheduled or held email to it is canceled when its time comes.
  • Soft bounces: a temporary failure (a full mailbox, a server that is down) does not list the address. GET /v1/soft-bounces lists the addresses with soft bounces, latest first, with the total, how many in a row and the last status and server reply, and GET /v1/soft-bounces/{address}answers for one. The count in a row starts over when mail to the address is delivered. Under the project's Settings you can choose to list an address after 3, 5 or 10 soft bounces in a row (reason soft_bounce).
  • Opens and clicks:off unless turned on under the project's Settings. With opens on, a 1×1 image is added to the HTML and loading it gives email.opened; with clicks on, links pass through pinemail.app/r/c/... and give email.clicked with the link. A link with data-pm-no-trackis left alone, and plain text is not changed. Only the time and the link are kept, not the reader's IP address or device. An open is an estimate: some mail apps load images ahead of the reader, others never do.
bash
curl -X POST https://pinemail.app/v1/emails \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: welcome-42" \ -d '{ "from": "Acme <hello@pinemail.app>", "to": ["ada@example.com"], "subject": "Welcome", "html": "<p>Hello from your app.</p>", "reply_to": "support@example.com", "tags": ["onboarding"] }' # A saved template, sent tomorrow at nine, with a file curl -X POST https://pinemail.app/v1/emails \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{ "from": "Acme <billing@acme.example>", "to": ["ada@example.com"], "template": { "slug": "invoice", "variables": { "name": "Ada", "amount": "120 EUR" } }, "scheduled_at": "2026-11-02T09:00:00+01:00", "attachments": [{ "filename": "invoice-1042.pdf", "content": "JVBERi0xLjQK..." }] }'

Send over SMTP

An app that can only speak SMTP (a CMS, a form plugin, an older system) sends through the same API. The message becomes an email like one from POST /v1/emails: it is in the project's log, its events reach your webhooks, and a sending key's lock to a domain or addresses holds for both the envelope sender and the From header. To and Cc come from the headers; recipients in neither are sent as Bcc. Headers that start with X- are kept, and attachments come along (inline images become ordinary attachments).

Hostmail.pinemail.app
Port587 with STARTTLS, or 465 with TLS
Usernamepinemail
PasswordA project key with emails:send (pm_live_...)

A mailbox signs in to the same host with its own address and password instead; see Mail apps in the mailbox's settings. The reply to a relayed message is 250 Queued as em_..., the email's id in the API.

Emails

GET /v1/emails lists newest first. Optional limit (1–200), cursor (the next_cursor of the page before) and from, one sender's emails (?from=hello@example.com). GET /v1/emails/{id} includes the event timeline.

Domains

Set up a domain, step by step

  1. POST /v1/domains with {"domain": "example.com"}, and "receive_mail": falseif another service receives the domain's mail. The answer lists the DNS records to publish.
  2. Publish them. On Cloudflare, POST /v1/domains/{id}/dns/cloudflare does it for you (see DNS below). By hand at any DNS host, add each record with its type, the name without the domain (_pinemail, or @ for the domain itself) and the value; an MX record takes its priority in a field of its own, and on Cloudflare the CNAME records must be DNS only (the grey cloud). Or import the zone_file from GET /v1/domains/{id}/dns.
  3. Wait. PineMail checks a new domain by itself, every two minutes at first, and the account owner gets a notice when it is verified. POST /v1/domains/{id}/verify checks at once, and GET /v1/domains/{id}/dns shows which records are in place.
  4. Send from any address on the domain (from: "Acme <hello@example.com>"), and add mailboxes for the addresses people write to (POST /v1/mailboxes).

Add a domain, publish the returned DNS records, then verify. The first record, OWNERSHIP, is a TXT record at _pinemail.<domain> that proves the domain is yours. Until it is found the domain stays pending: it cannot send, have mailboxes or receive mail, whatever the other records say. If another PineMail account proved the domain earlier, yours takes over once that account's record is gone from the domain's DNS. SPF, DKIM, MAIL FROM and DMARC are for sending. INBOUNDis the MX record for receiving mail here; leave it out if another service receives the domain's mail. A domain with mailboxes is not deleted (409); remove the mailboxes first. The hosted pinemail.app domain cannot be removed.

bash
curl -X POST $API/v1/domains \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"domain":"mail.example.com"}' curl -X POST $API/v1/domains/$ID/verify \ -H "Authorization: Bearer pm_live_..."

DNS

GET /v1/domains/{id}/dnsasks the domain's own name servers and says for each one whether it is ok, missing or different (or unknown when the lookup failed), with what the domain publishes now, plus a zone_file for DNS providers that import one. If the domain is on Cloudflare, POST /v1/domains/{id}/dns/cloudflare adds the records to the zone with a Cloudflare API token that has Zone:Read and DNS:Edit. The token is used for that request only and is not stored. Nothing in the zone is overwritten: a record already there is reported as exists, and a name that holds something else (another SPF record, other MX hosts) as conflict for you to settle. The INBOUND MX is only added with "inbound": true.

bash
curl $API/v1/domains/$ID/dns \ -H "Authorization: Bearer pm_live_..." curl -X POST $API/v1/domains/$ID/dns/cloudflare \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"api_token":"<cloudflare token>","inbound":true}'

DNS settings

PineMail does not host your DNS: the records are published where the domain's DNS is kept, and PineMail lists what it needs and checks it. Two choices decide the records, set with PATCH /v1/domains/{id} or on the domain's page. receive_mail (default true): set it to falsewhen another service, such as Google Workspace, receives the domain's mail and you only send from PineMail; the INBOUND record is then left out and PineMail takes no mail for the domain. dmarc_policy: none, quarantine (default) or reject, written into the DMARC record. A domain has one DMARC record, so if yours already has one, keep it.

bash
curl -X PATCH $API/v1/domains/$ID \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"receive_mail":false,"dmarc_policy":"reject"}'

Senders

A sender is an address your app sends from on a domain of your own, such as hello@ or noreply@. It needs no mailbox and no setup: the first email from an address lists it under the domain, with how much it has sent. GET /v1/domains/{id}/senders lists them, and each links to its emails (GET /v1/emails?from=…). Add one beforehand, or change it, to give it a display_name, used when a send gives none, and a reply_mailbox_id: a mailbox of the project that receives the replies. Without one, mail to a sender that is not a mailbox is refused, so set reply_toon each send or give the sender a reply mailbox. The same list is on the domain's page in the dashboard.

bash
curl -X POST $API/v1/domains/$ID/senders \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"local_part":"hello","display_name":"Acme","reply_mailbox_id":"mb_..."}' curl -X PATCH $API/v1/senders/$SENDER \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"reply_mailbox_id":null}'

Mailboxes

Inbox addresses on a domain you already added and proved (see Domains), or on pinemail.app. Each mailbox created through the API gets a login of its own unless you send "login": false: the address and password sign in to the web client and to mail apps, and see only that mailbox. If you omit password, PineMail generates one and returns it once. PATCH /v1/mailboxes/{id} with password sets a new password for that login. Storage is per account; a mailbox has no quota of its own. spam_level is off, low, medium, high, or null to inherit the project default.

bash
curl -X POST $API/v1/mailboxes \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{ "domain_id": "dom_...", "local_part": "hello", "display_name": "Hello", "daily_send_limit": 100 }' curl -X PATCH $API/v1/mailboxes/$ID \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"password":"a new password of 12 or more characters"}'

Mailbox mail

The mail and calendar of the project's mailboxes, for an assistant or an agent acting for the account owner. These scopes are picked on purpose when a key is made: Full access leaves them out, so a key made for an application never reads an inbox. The same operations are tools on the MCP server, where a person can also connect with a login instead of a key: the token then reaches the mailboxes the person reaches, GET /v1/mailboxes lists them, and the project routes answer 403 to it.

GET /v1/mailboxes/{id}/threads lists a folder (folder: inbox by default, or starred, snoozed, sent, drafts, scheduled, important, archive, all, spam, trash), newest first, with q to search subjects, addresses and message text, and limit (1–100) and cursorfor the next page. Each thread carries its latest message's sender, subject and snippet, the tags of its sent messages, and a kind from its latest received message: mail, bounce (a delivery report), complaint (a spam report from an abuse desk), auto_reply (out of office, a receipt) or challenge (a sender check that asks to confirm). kind and tag filter the list, so ?kind=mail&tag=round-1 gives the replies written by people to one round of mail. GET /v1/mailboxes/{id}/threads/{thread_id} returns the thread with its messages oldest first: plain-text bodies, attachments as metadata and any calendar invitation; ?html=true adds the HTML bodies. A received message has its kind, a bounce its bouncereport (the recipient, the status code, the server's words, hard or soft), and a reply or a bounce reply_to_message_id: the mailbox's own sent message it answers. A bounce or a reply to a sent message goes in that message's thread.

PATCH /v1/mailboxes/{id}/threads/{thread_id} (mail:organize) changes a thread as a person would in the mail app: archived, read, starred, spam, trashed, snoozed_until (or null to wake it), add_label_ids and remove_label_ids. PATCH /v1/mailboxes/{id}/threads makes the same change to up to 100 thread_ids, and GET /v1/mailboxes/{id}/labels lists the label ids.

POST /v1/mailboxes/{id}/draftswrites a draft to the mailbox's Drafts folder, or updates one with draft_id; nothing is sent. POST /v1/mailboxes/{id}/messagessends from the mailbox as a person would in the mail app: the message lands in Sent, counts against the account's monthly send limit and the mailbox's daily cap, waits out the mailbox's undo window, and can be held with scheduled_at. Until undo_until in the answer, the mail app shows the message with Undo if it is open, and POST /v1/mailboxes/{id}/messages/{message_id}/cancel takes it back to Drafts. Both take to, cc, bcc, subject, text, html, and for a reply thread_id and in_reply_to (the replied message's message_id); from is an alias of the mailbox, and tags(up to 20) label the message for reading replies by group. The answer to a send has the message's message_id. GET /v1/mailboxes/{id}/messages/{message_id} shows what became of a sent message: delivery.status (sent, delivered, soft_bounced, bounced, complained and so on), the bounces received for it, and the replies to it. POST /v1/mailboxes/{id}/messages/batch sends up to 100 drafts as they are written: with start_at the first goes then and each next one spacing_minutes later, and each is sent or refused on its own. GET /v1/mailboxes/{id}/calendar/events lists the calendar between from and to (ISO 8601, the coming two weeks by default, at most 92 days), recurring events expanded.

GET /v1/mailboxes/{id}/messages/{message_id}/attachments/{attachment_id}/text (mail:read) and GET /v1/files/{id}/text (files:read) return the text of an attachment or a Drive file: plain text, PDF, Word and spreadsheets, at most 10 MB read and 200,000 characters returned. A mail attached to a mail (message/rfc822, such as the original a bounce returns) reads as its headers and text.

The account owner decides what these routes reach and whether personal data is masked (Account → Assistants, on by default). With masking on, the answers carry placeholders such as [EMAIL_K3F9Q2ZA], masking: "on" and the counts, HTML bodies are left out, and drafts and sends get the real values back; a read that cannot be masked in time is 503 with Retry-After and no content. See Masking personal data. GET /v1/mailboxes/{id}/assistant-access (mail:read) says what applies in a mailbox; only the owner changes it, on the account page.

bash
curl "$API/v1/mailboxes/$MBX/threads?folder=inbox&kind=mail&tag=round-1" \ -H "Authorization: Bearer pm_live_..." curl -X POST $API/v1/mailboxes/$MBX/drafts \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"to":["ada@example.com"],"subject":"Re: invoice","text":"Thanks, paid today.","thread_id":"thr_...","in_reply_to":"<msg-id@example.com>"}' curl -X PATCH $API/v1/mailboxes/$MBX/threads \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"thread_ids":["thr_...","thr_..."],"archived":true,"read":true}'

Templates

GET /v1/templates and GET /v1/templates/{id} return saved subjects and bodies plus discovered {{variables}}. POST /v1/templates makes one (name, slug, subject, and html or text); PATCH /v1/templates/{id} changes the fields given, and DELETE removes it. The slug is unique in the project, and the variables are read again on every save. Writing needs templates:write.

Each template has a version. A save that changes the name, subject or a body keeps the version before it, up to 50 per template. GET /v1/templates/{id}/versions lists them, GET /v1/templates/{id}/versions/{version} returns one with its bodies, and POST /v1/templates/{id}/versions/{version}/restore makes it current again as a new version, so the one it replaces stays in the history. The template's page shows the same history under the editor.

Directory

Organisation directory, including people shared in from other projects. The project's mailboxes are listed automatically and cannot be changed or deleted through the API. PATCH /v1/directory/{id} edits a person added by hand: the fields given change, and null clears title, organization or notes.

bash
curl $API/v1/directory \ -H "Authorization: Bearer pm_live_..." curl -X POST $API/v1/directory \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{"name":"Ada Lovelace","email":"ada@example.com","title":"Engineer"}'

Webhooks

Subscribe an HTTPS URL to email lifecycle events. The URL must point at a public address; private and local networks are refused. Redirects are not followed. Signing, payload shape, retries, and the test endpoint are documented on /docs/webhooks.

bash
curl -X POST $API/v1/webhooks \ -H "Authorization: Bearer pm_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/pinemail", "events": ["email.sent", "email.delivered", "email.bounced", "email.failed"] }'

Account keys

Files, buckets and S3 keys belong to the account, not to a project. They are reached with an account key, made under Buckets → Access keys in the web app (choose API key). Account keys start with pm_acct_ and are passed as a Bearer token, like project keys. A key reaches every bucket of the account or only those chosen; it may only read, or read and write; and it may also reach Drive files and create and revoke S3 keys. A key limited to some buckets lists only those, gets 404 for the others, and gets 403 when it creates or deletes a bucket or manages S3 keys. Mail, domains and the other project routes answer 403 to an account key.

http
Authorization: Bearer pm_acct_...

Files

The account's Drive: its own files and folders, reached with an account key that includes Drive files. Mailboxes' files stay with mail. PineKeep must be on for the account. GET /v1/files lists a folder (the root without parent_id); POST /v1/files makes a folder from JSON, or uploads a file of up to 25 MB as the raw body with ?name= and parent_id. A file with the same name in the folder gets a new version. GET /v1/files/{id}/content downloads, PUT on it writes a new version (with expected_version_id to refuse a stale write), PATCH renames or moves, DELETE moves to the trash (?permanent=true deletes for good) and POST …/restore brings it back. Share links: POST /v1/files/{id}/shares, listed on the same path and revoked with DELETE /v1/shares/{id}; PATCH /v1/shares/{id} with expires_in_days (1, 7, 30 or null) moves its end, counted from now, and the link stays the same. Larger files go in 8 MB parts: POST /v1/uploads, then PUT /v1/uploads/{id}/parts/{n} from 0, then POST /v1/uploads/{id}/complete. Everything counts against the account's storage quota; a write it has no room for is 413.

bash
curl -X POST "$API/v1/files?name=report.pdf" \ -H "Authorization: Bearer pm_acct_..." \ -H "Content-Type: application/pdf" \ --data-binary @report.pdf curl -L $API/v1/files/$ID/content \ -H "Authorization: Bearer pm_acct_..." -o report.pdf

Buckets

S3 buckets of the account, managed over this API and read and written over S3 at s3.pinemail.app with an S3 key. Buckets are part of the PineKeep beta: an account joins once with POST /v1/keep/join (scope buckets:write) or under Buckets in the web app. POST /v1/bucketscreates one. The name is part of the bucket's address and cannot be changed later, as with other S3 storage; for another name, create a new bucket and copy the objects over (for example with aws s3 sync). PATCH sets public_read, cors_origins and protect_days; DELETE removes an empty bucket, or a full one with ?force=true. Objects of up to 25 MB can go through this API at /v1/buckets/{name}/objects/{key}; use S3 for more. POST /v1/bucket-keys makes an S3 key for some buckets or all, read or read-write, and returns the secret once; PATCH /v1/bucket-keys/{id} changes its name, permission and buckets, and the store applies it within seconds. Both need the bucket_keys:write scope, since the key reaches the buckets directly.

protect_days (7 to 365, or nullfor off) protects a bucket against deletion: for at least that many days (at most twice that, plus one) after a version is deleted or replaced, no S3 key of the account can remove it, so a key stolen from a server cannot destroy its backups. Deleted objects stay readable as earlier versions over S3. Old versions count toward the account's storage until their protection runs out, so leave room for up to twice that many days of changes. Files with a content type other than application/octet-stream are copied each time their protection is renewed and can count twice meanwhile. To delete a protected bucket, set protect_days to null first; DELETE answers 409 until the protection of its versions has run out.

bash
curl -X POST $API/v1/buckets \ -H "Authorization: Bearer pm_acct_..." \ -H "Content-Type: application/json" \ -d '{"name":"acme-assets"}' curl -X POST $API/v1/bucket-keys \ -H "Authorization: Bearer pm_acct_..." \ -H "Content-Type: application/json" \ -d '{"name":"deploy","permission":"readwrite","buckets":["acme-assets"]}'

S3 keys work with objects

An S3 key reads, writes, lists and deletes objects in its buckets. It cannot create or delete buckets or change their settings; S3 answers 403 AccessDenied to aws s3 mb, rclone mkdir and the like. Create the bucket first, under Buckets in the web app or with POST /v1/buckets and an account key. Tools that create a bucket only when it is missing, such as restic init, work once it exists. Keeping bucket changes off S3 means a key on an application server cannot remove a bucket or its protection.

From nothing to a file in a bucket

Join PineKeep under Buckets in the web app, then make an API key and an S3 key under Buckets → Access keys (or the S3 key with the API, as above). The endpoint is https://s3.pinemail.app, region eu-hel-1, path-style addressing.

bash
# 1. The bucket, with the API key curl -X POST https://pinemail.app/v1/buckets \ -H "Authorization: Bearer pm_acct_..." \ -H "Content-Type: application/json" \ -d '{"name":"acme-assets","protect_days":30}' # 2. Objects, with the S3 key: AWS CLI aws configure set aws_access_key_id ... --profile pinekeep aws configure set aws_secret_access_key ... --profile pinekeep aws configure set region eu-hel-1 --profile pinekeep aws --profile pinekeep --endpoint-url https://s3.pinemail.app \ s3 cp ./logo.png s3://acme-assets/logo.png
ini
# Or rclone (~/.config/rclone/rclone.conf), then: rclone copy ./site pinekeep:acme-assets [pinekeep] type = s3 provider = Other access_key_id = ... secret_access_key = ... endpoint = https://s3.pinemail.app region = eu-hel-1 force_path_style = true

Errors

Every error uses application/problem+json with type, title, status, and usually detail. Validation failures are 422 and include an errors map. Rate limits are 429 with Retry-After.

A send over one of the account's send limits is also 429, with quota_exceeded and a detail that names the limit and what is used. Retry-Afteris the seconds until a send may go: to the 1st of next month for the month's limit, an hour for the last 24 hours and a mailbox's own cap, a minute for the cap a minute. GET /v1/limits shows every limit and what is used, so an application can check before a large send. Sending that is paused because too much bounced or was reported as spam is 403.

json
{ "type": "/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "This API key is missing the emails:send scope." }

Status codes

200 / 201 / 202 / 204 success. 400 bad request. 401 missing or invalid key. 403 missing scope, or a service turned off for the account. 404 unknown resource, or one that is not yours. 409 conflict with the current state. 413 too large. 422 validation. 429 rate limited. 500 unexpected. 502 a service PineMail called for you (Cloudflare) answered with an error.