Junkmail

Receive-only developer platform

Build deterministic email workflows without sending a message.

Use a scoped Pro token against your own mailbox. The try-it console below sends requests directly to https://junksink.com/api/v1; Junkmail never stores the token you enter here.

OpenAPI 3.1

Interactive reference

Create a token under Account → API tokens, choose Authorize, and paste it locally into Scalar. Every endpoint remains receive-only.

Every operation

Endpoint index

Generated at build time from the same openapi.yaml the reference above renders, so it can never drift from the contract. Paths are absolute on the API host.

62 operations. Mail only ever travels inward: there is no send, reply, or forward endpoint.
MethodPathSummary
GET/api/v1/desk-mail/{aliasId}Retained outgoing mail for an owned protected desk
POST/api/v1/desk-mail/{aliasId}Send plaintext from an owned protected desk
GET/api/v1/desk-mail/{aliasId}/reply/{messageId}Prefill a reply to retained mail in an owned protected desk
GET/api/v1/bootstrapBootstrap API-token account context
GET/api/v1/meFetch the authenticated account
GET/api/v1/storageFetch mailbox storage usage
GET/api/v1/tagsList tags attached to owned messages
POST/api/v1/tagsCreate an owned tag
PATCH/api/v1/tags/{id}Update an owned tag
DELETE/api/v1/tags/{id}Delete an owned tag
GET/api/v1/domainsList receiving domains available for alias generation
GET/api/v1/aliasesList owned aliases
POST/api/v1/aliasesClaim a reserved alias candidate
POST/api/v1/aliases/candidatesReserve a new candidate set
DELETE/api/v1/aliases/candidatesRelease the current candidate reservation
POST/api/v1/aliases/candidates/releaseRelease the current candidate reservation
PATCH/api/v1/aliases/{id}Update alias metadata or lifecycle state
DELETE/api/v1/aliases/{id}Irreversibly burn an owned alias
POST/api/v1/aliases/{id}/pausePause inbound delivery for an owned alias
PUT/api/v1/aliases/{id}/timerSet or extend an alias self-destruct timer
DELETE/api/v1/aliases/{id}/timerCancel an alias self-destruct timer
POST/api/v1/aliases/{id}/resumeResume inbound delivery for an owned alias
POST/api/v1/aliases/{id}/burnIrreversibly burn an owned alias
DELETE/api/v1/aliases/{id}/shareLeave a mailbox somebody shared with you
GET/api/v1/aliases/{id}/share-grantsList guest webhook and API token grants on an owned alias
POST/api/v1/aliases/{id}/share-grants/acknowledgeMark guest grants on an owned alias as seen
DELETE/api/v1/aliases/{id}/share-grants/{kind}/{subjectId}Revoke one guest webhook or API token on an owned alias
GET/api/v1/share-grants/revokedList your access to shared mailboxes that has stopped
GET/api/v1/messagesList or search owned messages
POST/api/v1/messages/bulkApply one mailbox action to up to 100 owned messages
POST/api/v1/messages/trash/emptyDestroy everything in the trash, of one mailbox or of all of them
GET/api/v1/messages/{id}Fetch owned message metadata and parsed body
PATCH/api/v1/messages/{id}Mark an owned message read or unread
DELETE/api/v1/messages/{id}Delete an owned message, to the trash or permanently
POST/api/v1/messages/{id}/restoreTake an owned message back out of the trash
GET/api/v1/messages/{id}/htmlFetch the sanitized HTML body
GET/api/v1/messages/{id}/rawDownload the original owned RFC 5322 message
PUT/api/v1/messages/{id}/remote-images/preferenceSet the sender-scoped remote-image preference
GET/api/v1/messages/{messageId}/attachments/{attachmentId}Download an attachment belonging to an owned message
GET/api/v1/messages/{messageId}/attachments/{attachmentId}/previewPreview a safe, supported attachment from an owned message
GET/api/v1/push/settingsFetch browser push settings and subscriptions
PUT/api/v1/push/settingsEnable or disable browser push globally
PUT/api/v1/mailbox/densitySet the message-list density for the account
DELETE/api/v1/push/nudgeDismiss the one-time browser-notification offer
POST/api/v1/push/subscriptionsRegister or refresh a browser PushSubscription
DELETE/api/v1/push/subscriptions/{id}Remove an owned browser push subscription
POST/api/v1/push/subscriptions/{id}/testQueue a harmless test notification for an owned subscription
PUT/api/v1/push/aliases/{id}Enable or disable push for an owned alias
GET/api/v1/exportsList retained mailbox exports
POST/api/v1/exportsQueue a whole-mailbox or per-alias EML export
GET/api/v1/exports/{id}Fetch an owned mailbox export status
GET/api/v1/exports/{id}/downloadDownload a ready owned mailbox export
POST/api/v1/test/messagesInject deterministic mail through the normal receive pipeline
GET/api/v1/webhooksList owned webhook endpoints
POST/api/v1/webhooksCreate a webhook; its signing secret is shown once
PATCH/api/v1/webhooks/{id}Update or pause an owned webhook
DELETE/api/v1/webhooks/{id}Delete an owned webhook
POST/api/v1/webhooks/{id}/rotate-secretRotate and show a webhook signing secret once
GET/api/v1/webhooks/{id}/deliveriesList an owned webhook's delivery attempts
POST/api/v1/webhooks/{id}/testQueue a harmless webhook.test event
POST/api/v1/webhooks/deliveries/{id}/redeliverRedeliver a retained owned event with the same webhook-id
GET/api/v1/webhooks/deliveriesList recent delivery attempts across owned webhooks

Practical guides

From token to verified event

1. Authentication and scopes

Send Authorization: Bearer jm_pat_… over HTTPS. Tokens are shown once, stored as keyed digests, limited to ten active tokens, and suspended—not deleted—if Pro access ends.

read
List and fetch aliases/messages, export status, and mark mail read.
manage-aliases
Reserve candidates, claim, pause, resume, and burn aliases.
delete
Permanently delete retained messages.
webhooks
Manage endpoints, delivery logs, tests, redelivery, and secret rotation.
test
Inject deterministic test mail into an owned active alias.
curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  'https://junksink.com/api/v1/messages?limit=50'

2. Pagination, ETags, and errors

Follow the opaque nextCursor exactly and do not construct cursors. Message resources return weak ETags; send If-None-Match to receive 304 Not Modified. Errors use one stable object with code, message, and optional field details. Rate responses include X-RateLimit-* and may include Retry-After.

3. Webhooks end to end

  1. Expose an HTTPS endpoint on port 443 with a publicly routable address.
  2. Calculate the receiver certificate public-key SPKI SHA-256 pin and save it with the endpoint for the pinned acceptance journey below.
  3. Store the endpoint secret shown once. Also cache the current Ed25519 public key from https://junksink.com/.well-known/junkmail-webhooks.json.
  4. Use Send test event, verify webhook-id, webhook-timestamp, and both values in webhook-signature, then return any 2xx.
  5. Reject timestamps more than five minutes old and deduplicate on the stable event id.

Calculate the pin independently from the receiver you control. Review the hostname and resulting certificate before saving it:

openssl s_client -connect receiver.example:443 -servername receiver.example </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl base64 -A
# Save the result as: sha256/BASE64_OUTPUT

Create the endpoint with that exact pin, save the one-time secret outside source control, then queue a signed test event:

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"CI receiver","url":"https://receiver.example/webhooks/junkmail","subscription":{"mode":"all"},"spki_pin":"sha256/BASE64_OUTPUT","include_text_body":false}' \
  https://junksink.com/api/v1/webhooks

curl --fail-with-body -X POST \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/webhooks/WEBHOOK_ID/test

Junkmail retries connection errors, timeouts, 429, and 5xx at roughly 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, and 24 hours. Delivery stops after eight attempts; endpoints with three continuous days of total failure are disabled and their owner is notified. Redirects and SPKI mismatches fail closed.

4. Verify both signatures

The exact signed bytes are {webhook-id}.{webhook-timestamp}.{raw request body}. Split the signature header on spaces: v1,BASE64_HMAC uses the endpoint secret; v1a,BASE64_ED25519 uses the service key whose kid is included in the published key set. Always compare decoded signatures in constant time.

JavaScript (Node.js)
import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto'

const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody])
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) throw new Error('stale webhook')
const expected = createHmac('sha256', endpointSecret).update(signed).digest()
const receivedHmac = Buffer.from(v1, 'base64')
const hmacOk = receivedHmac.length === expected.length && timingSafeEqual(expected, receivedHmac)
const rawPublicKey = Buffer.from(publicKeyBase64, 'base64')
if (rawPublicKey.length !== 32) throw new Error('invalid Ed25519 public key')
// RFC 8410 SubjectPublicKeyInfo prefix for a raw 32-byte Ed25519 key.
const spki = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), rawPublicKey])
const serviceKey = createPublicKey({ key: spki, format: 'der', type: 'spki' })
const ed25519Ok = verify(null, signed, serviceKey, Buffer.from(v1a, 'base64'))
Python
import base64, hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

signed = f"{event_id}.{timestamp}.".encode() + raw_body
hmac_ok = hmac.compare_digest(
    hmac.new(endpoint_secret.encode(), signed, hashlib.sha256).digest(),
    base64.b64decode(v1),
)
Ed25519PublicKey.from_public_bytes(public_key).verify(base64.b64decode(v1a), signed)
PHP
<?php
$signed = $id.'.'.$timestamp.'.'.$rawBody;
$hmacOk = hash_equals(hash_hmac('sha256', $signed, $endpointSecret, true), base64_decode($v1, true));
$ed25519Ok = sodium_crypto_sign_verify_detached(base64_decode($v1a, true), $signed, base64_decode($publicKey, true));
curl capture
curl --fail-with-body --raw \
  -D webhook-headers.txt -o webhook-body.json \
  -H 'Content-Type: application/json' \
  --data-binary @event.json https://receiver.example/webhooks/junkmail
# Verify the untouched bytes in webhook-body.json with one of the snippets above.

5. Shared mailboxes

Another Junkmail account can give you access to one of its aliases. The handshake itself is not on this API and never will be: an owner can only invite you against an invite-me token that you generated first, in the app under Account → Sharing, and nothing about it is ever emailed. Once you accept, the mailbox simply appears in your API responses. Note that API tokens and webhooks remain a Pro entitlement for the calling account: being a guest on somebody else's mailbox does not grant you the API.

A shared mailbox arrives in GET /bootstrap and GET /aliases in the ordinary Alias shape, with four fields describing the relationship:

{
  "id": 142,
  "address": "maple-river@aliasbin.com",
  "role": "collaborator",
  "shared": true,
  "ownerHandle": "amber-thicket",
  "guestCount": 0,
  "unreadCount": 3,
  "label": null,
  "note": null
}

role is owner, collaborator, or viewer. shared is true only when another account owns the mailbox. ownerHandle is that account's pseudonymous handle and is null on a mailbox you own; real email addresses never cross an account boundary. guestCount counts the people you have let into a mailbox you own and is always 0 on somebody else's — a guest is never told who else is in the room.

The Alias schema is fixed, so the owner's private fields are not omitted from a guest's response, they are emptied: label and note are null, receivedCount, rejectedCount, storageUsedBytes, and guestCount are 0, pushEnabled, planPaused, and selfDestructSuspended are false, and claimedAt, selfDestructAt, and expiringNotifiedAt are null. Your own unreadCount, and the address, domain, status, pausedAt, burnedAt, and lastReceivedAt of the mailbox, are real.

What your role lets a token do

Reading is identical for both guest roles: GET /messages, GET /messages/{id}, /html, /raw, attachments and previews, search, and extracted OTP codes and links all work on a shared mailbox exactly as they do on your own, subject to the usual read scope.

  • Message state is private per person. PATCH /messages/{id} writes your own read state and nobody else's; a bearer token may set only isRead. Starring, pinning, archiving, and tagging are browser-session operations and are equally private. A pin of yours protects the message in your view only — retention and quota eviction follow the owner's plan and the owner's pin.
  • collaborator may delete, viewer may not. DELETE /messages/{id} needs the delete scope and the collaborator role; a viewer's token is answered 404, the same answer a stranger gets. A delete is real for everyone: the message leaves the owner's mailbox too, and its storage is released from the owner's account.
  • Everything about the alias stays with the owner. PATCH /aliases/{id}, pause, resume, burn, the self-destruct timer, and POST /exports for a shared aliasId all answer 404 for a guest whatever scopes the token holds. Nothing distinguishes that refusal from one for an alias that does not exist.
  • Rate limits and quotas. Requests count against the calling account's own per-token plan limits. Stored mail always counts against the owner's storage and retention, and a shared mailbox never counts against your own active-alias cap.

To leave a mailbox yourself, call DELETE /aliases/{id}/share with the manage-aliases scope. Access stops immediately and nothing you did there is kept: your read, starred, pinned, and archived state on its mail and your own tags on it are deleted. Use GET /messages?excludeShared=true to leave shared mail out of a list or search entirely — the page, the cursor, and the total all describe the same narrowed set.

Webhooks on a shared alias

You may point a webhook at a mailbox shared with you, with {"mode":"aliases","alias_ids":[142]}. Its deliveries carry the same headers and the same dual signature, in an envelope with two extra fields:

{
  "id": "evt_01J6N8XQ8G7K2M3P4R5S6T7V8W",
  "type": "message.received",
  "time": "2026-09-03T14:02:19Z",
  "api_version": "2026-08-31",
  "shared_mailbox": true,
  "owner_handle": "amber-thicket",
  "data": { "alias": { "id": 142, "address": "maple-river@aliasbin.com", "label": null } }
}

shared_mailbox and owner_handle are present on every event, including your own mail, where they are false and null. They are additive within api_version: 2026-08-31. Two details follow from the owner's label never crossing the boundary: data.alias.label is null for a guest, and a {"mode":"label"} subscription therefore never matches a shared mailbox. A {"mode":"tag"} subscription matches on your own tag ids, so it works on shared mail you tagged yourself. Alias lifecycle events — alias.paused, alias.burned, alias.expiring and friends — are delivered to the owner's endpoints only; as a guest you learn about them over the realtime channel in the app instead.

The owner can revoke your grants at any moment

This is the part to design your integration around. The owner of a shared alias sees every guest webhook aimed at it — by endpoint host, never the full URL, which can carry a secret in its path — and every guest API token that reaches it, and may end any single one without removing you from the mailbox. A revoked webhook stops receiving events immediately and any delivery still queued or retrying for that mailbox is abandoned; a revoked API token stops resolving that mailbox on its very next request, while your other tokens and your browser session keep working. Revocation is permanent for that endpoint or token: re-registering the same endpoint or reusing the same token does not restore it, so recover by creating a new one and asking the owner. Removing you from the mailbox, burning the alias, or leaving it yourself ends all of your grants on it at once.

Poll GET /share-grants/revoked (scope read) to find out why your access stopped. It reports three things, each with a reason and a reversible flag, for the last thirty days:

  • owner_revoked — the owner ended one endpoint or token and you keep the mailbox. kind is webhook or api_token, and the item carries the alias address, the owner's handle and the endpoint host, because you still reach the mailbox. Not reversible.
  • share_suspended — the share is not granting anything at the moment and may start again on its own. Everything you pointed at that mailbox is inert until it does. Keep retrying: this is the one reversible case, and it is the commonest reason a working integration goes quiet.
  • share_ended, or guest_left when you gave the mailbox up yourself — your access is over and will not return without a fresh invitation.

A share_suspended or ended item has kind: "share": every grant you held on that mailbox stopped at the same instant, so it is one item rather than one per endpoint. It carries aliasId and no address, handle or revoker — access ended and nothing about the mailbox is retained, and how a share ended is deliberately one answer. Branch on reversible rather than on reason, so a value added later still behaves.

From the other side, on a mailbox you own: GET /aliases/{id}/share-grants (scope read) lists every guest grant; DELETE /aliases/{id}/share-grants/{webhooks|api-tokens}/{subjectId} (scope manage-aliases) ends one; and POST /aliases/{id}/share-grants/acknowledge (scope manage-aliases) clears the isNewToOwner flag raised the first time a guest registers a webhook against your alias. A guest calling any of the three gets a 404.

6. A shared mailbox end to end

The order a real integration goes in, with two accounts: you, who will read somebody else's mailbox from CI, and the owner, who holds the alias. Steps 1 to 3 happen in the browser because that is the only place the sharing handshake exists.

1. You generate an invite-me token. In the app, Account → Sharing → Create an invite-me token. You get four comma-separated words, shown once:

quiet, thicket, falcon, ember

It is single use, valid 24 hours, and you may hold five live at a time. Junkmail stores only a keyed digest of it, so nobody — including the operator — can read it back to you.

2. You hand the token to the owner over whatever channel you already trust. Junkmail is deliberately not part of that step, and it never emails an invitation.

3. The owner sends the invitation and you accept. They paste the token on their own Sharing page, pick Viewer or Collaborator, and send. The invitation appears in your app — never in your inbox — showing their handle, the alias address, and the offered role, and it expires after 72 hours. Accept it and the mailbox is yours to read. Neither of you ever sees the other's email address.

4. Find the mailbox from your token. Everything from here is ordinary API work. Look for shared: true:

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/aliases

# {"items":[{"id":142,"address":"maple-river@aliasbin.com","role":"collaborator",
#            "shared":true,"ownerHandle":"amber-thicket","guestCount":0,
#            "unreadCount":3,"label":null,"note":null, ...}]}

5. Read it. Scope the list to that alias, then fetch the message and the code you came for:

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  'https://junksink.com/api/v1/messages?alias=142&limit=20'

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/messages/98211

6. Mark it read, for yourself only. The owner's own unread count does not move:

curl --fail-with-body -X PATCH \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"isRead":true}' \
  https://junksink.com/api/v1/messages/98211

7. Subscribe a webhook to it. Same endpoint creation as any other, filtered to the shared alias. Deliveries arrive with shared_mailbox: true and owner_handle, and you verify both signatures exactly as in section 4:

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"name":"CI receiver","url":"https://receiver.example/webhooks/junkmail",
       "subscription":{"mode":"aliases","alias_ids":[142]},
       "spki_pin":"sha256/BASE64_OUTPUT","include_text_body":false}' \
  https://junksink.com/api/v1/webhooks

The owner is shown, in their app, that a guest endpoint on receiver.example now reaches their mailbox. They see the host, not your URL, and not the endpoint's name.

8. Check why your access stopped. Worth doing when deliveries or reads stop, before assuming an outage:

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/share-grants/revoked

# {"items":[{"aliasId":142,"aliasAddress":"maple-river@aliasbin.com",
#            "ownerHandle":"amber-thicket","reason":"owner_revoked","reversible":false,
#            "kind":"webhook","webhookId":77,"apiTokenId":null,
#            "endpointHost":"receiver.example","endedAt":"2026-09-04T09:31:00Z",
#            "revokedAt":"2026-09-04T09:31:00Z","revokedBy":"owner"},
#           {"aliasId":198,"aliasAddress":null,"ownerHandle":null,
#            "reason":"share_suspended","reversible":true,"kind":"share",
#            "webhookId":null,"apiTokenId":null,"endpointHost":null,
#            "endedAt":"2026-09-04T08:02:00Z","revokedAt":null,"revokedBy":null}]}

Alias 198 is suspended, not gone: back off and keep polling, because it can start working again with nothing further from you. Had it read share_ended, the mailbox would be over — and Junkmail deliberately does not say which of removal, a burned alias, a self-destruct timer or an erased account ended it.

9. Leave when you are done. Your state and your tags on that mailbox are deleted with the share:

curl --fail-with-body -X DELETE \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/aliases/142/share

7. Deterministic test mail

POST /test/messages accepts JSON or raw message/rfc822, never touches SMTP, and follows the normal parse → rules → index → realtime → webhook pipeline. Test messages count toward storage, carry source: test, and expire after seven days.

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"aliasId":42,"from":"ci@example.test","subject":"Login code","text":"Code: 482913","otp":"482913"}' \
  https://junksink.com/api/v1/test/messages

In Playwright, Cypress, or pytest: inject; poll the returned message URL until processingStatus is processed; assert the extracted code; then delete the fixture. Use a unique subject per test and respect the 500/day plan limit.

8. Export retained mail

Queue a whole-mailbox export or pass an aliasId for one alias. Poll the returned Location until the status is ready, then download the ZIP within 24 hours. It contains each retained raw .eml plus manifest.json.

curl --fail-with-body -i -X POST \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{}' https://junksink.com/api/v1/exports

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/exports/EXPORT_ID

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  -o junkmail-mailbox.zip \
  https://junksink.com/api/v1/exports/EXPORT_ID/download

9. Delete, recover, and empty the trash

DELETE /messages/{id} does one of two things, and which one depends on the mailbox owner's plan, not yours: with a trash window the message moves to the trash and stays recoverable until its purge date, without one it and its stored content are destroyed. Never predict that from plan.trashRetentionDays in GET /bootstrap — that is your own plan and it says nothing about a mailbox somebody shared with you.

Read it off the mailbox instead. Every alias carries the owner's mode as trash: {"enabled": true, "retentionDays": 30}, on a shared mailbox as much as on your own, so you know before you delete whether an Undo is honest. Single DELETE answers 204 as it always has; when you need the purge deadline, compute it as the earlier of the message's expiresAt and now plus retentionDays, or read trashPurgeAt back from the message or from a trashed=true listing.

Bulk delete, which has always returned a body, carries an outcomes array naming every identifier and where it now is — the only way to undo a bulk request spanning mailboxes with different owners, because the counts do not say which identifiers were destroyed. outcome is where the message is now, not what the call did to it, so a repeated identifier answers trashed with the purge date it already had.

Repeating the plain DELETE on a message already in the trash is idempotent and does not destroy it — a retry after a lost response must never be the difference between recoverable and gone. Ask for destruction explicitly with ?permanent=1.

Trashed mail is left out of every list, count and search unless you ask for it with trashed=true. Each message carries trashed, trashedAt and trashPurgeAt; the purge date is the earlier of the message's own expiresAt and the plan window, fixed when it was deleted, so a later downgrade never brings it forward. A pin does not hold a trashed message back.

Searching the trash is narrower than searching the mailbox. A trashed message leaves the search index entirely — its document is removed exactly as a permanent delete removes it — so once that removal is projected, however much is in the trash does not affect a mailbox search. Removal is asynchronous, so the one exception is a backlog: while more than 10,000 matching messages you have just deleted are still waiting to be projected, they can briefly hide an older mailbox result, and it corrects itself as the projection catches up. The trash is searched from the database instead, which means q combined with trashed=true matches the subject and the sender and not the body. The set it searches is small by construction: a trash holds at most one retention window, and it is the first thing quota eviction takes. Restoring a message puts its document back on the next projection sweep, so for a few seconds after an Undo the message is in the mailbox and its listings but not yet findable by full text — the same short delay a newly arrived message has.

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/aliases   # each item carries trash.enabled

curl --fail-with-body -X DELETE \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/messages/731   # -> 204, recoverable when trash.enabled

curl --fail-with-body \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  'https://junksink.com/api/v1/messages?trashed=true'

curl --fail-with-body -X POST \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/messages/731/restore

curl --fail-with-body -X POST \
  -H 'Authorization: Bearer YOUR_PRO_TOKEN' \
  https://junksink.com/api/v1/messages/trash/empty

Restoring returns the message with its pins, tags and every reader's private marks intact, because trashing never cleared them. Emptying is bounded per request: repeat it while remaining is above zero. Set include_message_restored on a webhook endpoint to be told when a message comes back; deletion itself has never produced a webhook event and trashing keeps that unchanged.

10. API changelog

2026-08-31 · api_version: 2026-08-31. Initial Pro API: scoped tokens, cursor pagination and ETags, Standard Webhooks dual signatures and SPKI pins, test-mail injection, and mailbox export. Additive fields may appear within this version; incompatible changes require a new dated version and overlap notice.

2026-09-03 · additive within api_version: 2026-08-31. Shared mailboxes: role, shared, ownerHandle, and guestCount on the alias schema; shared_mailbox and owner_handle on every webhook envelope; the owner-oversight operations under /aliases/{id}/share-grants and the guest's GET /share-grants/revoked; DELETE /aliases/{id}/share to leave a mailbox; and the excludeShared query parameter on GET /messages.

2026-09-04 · additive within api_version: 2026-08-31. Trash. DELETE /messages/{id} moves a message to the trash on a plan that has one and destroys it on a plan that does not; ?permanent=1 always destroys it, and repeating the plain form on a message already in the trash is idempotent and does not destroy it. Single DELETE keeps answering 204. Every alias gains trash: {enabled, retentionDays} carrying the mailbox owner's delete mode, on shared mailboxes too, which is how a caller knows before deleting whether an Undo is honest; the bulk result gains an outcomes array naming where each identifier ended up, alongside new trashed and restored counts. New POST /messages/{id}/restore and POST /messages/trash/empty, a trashed query parameter on GET /messages, trashed, trashedAt and trashPurgeAt on every message, and restore and delete_forever bulk actions. q with trashed=true matches the subject and the sender only, because trashed mail is not in the search index. A new message.restored webhook event type is delivered only to endpoints that set include_message_restored, so an integration that switches on type receives exactly what it received before until its owner asks for more.

2026-09-04 · additive within api_version: 2026-08-31. GET /share-grants/revoked now also reports the share itself stopping, not only a grant the owner revoked one at a time: aliasId, reason, reversible and endedAt are new on every item, and kind gains share, on which aliasAddress, ownerHandle and revokedBy are null. A client that switches on kind should treat an unfamiliar value as a stop and read reversible.