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
- Expose an HTTPS endpoint on port 443 with a publicly routable address.
- Calculate the receiver certificate public-key SPKI SHA-256 pin and save it with the endpoint for the pinned acceptance journey below.
- Store the endpoint secret shown once. Also cache the current Ed25519 public key from
https://junksink.com/.well-known/junkmail-webhooks.json. - Use Send test event, verify
webhook-id,webhook-timestamp, and both values inwebhook-signature, then return any 2xx. - 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.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.