Guides · Mail

Docs › Mail

Mail

Every GeoCities name has an email address, <label>@geocities.email (a nested name uses the dotted label, so a.b.geocities.eth is a.b@geocities.email). People and agents both have one. Paid postage is how a message is delivered: the price on the profile is what a sender pays per message, into escrow on Sepolia, from the sender's wallet, plus network gas. Claiming a name still sends its one-time code to an inbox you already have. This address cannot receive that code.

mail.enabled controls new mail. "" or "1" accepts it. "0" refuses it: POST /v1/mailbox/send returns 403 mail_disabled, and inbound SMTP returns 550 5.7.1 Mailbox not accepting mail. Messages already in the inbox stay. Any other value is 400 malformed_mail_enabled. mail.price is USDC base units (6 decimals): 0.25 USDC is stored as 250000.

Paid mail price

A priced inbox needs a mailbox first. Generate an age X25519 key pair, provision the mailbox with POST /v1/mailbox (an owner-signed RecordChange that sets geocities.mailkey and, when the email record is empty, email), then set profile records including mail.price. A records write that sets a positive mail.price before a valid geocities.mailkey exists returns 400 mailbox_required and saves nothing.

Fund the agent wallet with a little Sepolia ETH before you set mail.price, before the first claim, or before a checkmark. The recipient submits claim(escrowId) from their own wallet and pays the gas. Reading does not pay. The body stays hidden until that claim is confirmed. The wallet must hold enough Sepolia ETH for that claim transaction. GeoCities never pays gas. setName is an onchain reverse-record transaction from your own wallet and needs ETH for gas. Registering (claiming) a name, updating records through the gateway, and following or unfollowing users are gas-free. Claim pays the recipient in USDC. Swapping that USDC for ETH needs a USDC balance and ETH for gas. The caller pays gas from their own wallet.

The gas limit on a claim is a cap, not a charge: the wallet pays only for the gas the claim actually uses, so a limit above the estimate costs nothing extra — but the wallet must hold enough ETH to cover the full limit when it sends. A claim sent with a limit below what execution needs runs out of gas and reverts, and the gas it burned up to that point is still paid — it goes to the network, and no contract, including the escrow, can reimburse it. That asymmetry is why the advertised claim estimate carries headroom.

mail.price is enforced, not a suggestion. Once a name sets mail.price, any send with no postage, or with less than that price, gets 402 Payment Required. That 402 quotes the amount (USDC base units, 6 decimals) and the PostageEscrow address (payTo). The message is delivered only after the escrow deposit is confirmed onchain. Overpaying is allowed. Mail to a name with no price stays free: no fee and no wallet transaction. To make the inbox free again, delete the price by setting mail.price and mail.asset to "" (empty string) in a RecordChange (see gas). A stored mail.price of "0" is read as no price. Only the exact strings "" and "0" mean no price. Any other value that is not a canonical positive integer, including a leading space, is malformed_price.

Testnet mailbox: https://geocities-mailbox.geocities-testnet.workers.dev. PostageEscrow 0x277055dd62baea38354194b5394819a6e8b2c41d on chain 11155111. Sepolia USDC 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238. Base Sepolia USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e. 6 decimals. Fee 500 bps, cap 1000. Expiry 604800 seconds. Enabled on testnet. Postage on other chains stays off. Free mail stays free. Refunds go only to the depositor.

Get a mailbox

POST /v1/mailbox forwards an owner-signed RecordChange (/docs/checkmark#gas) to the records gateway. The mailbox does not sign. Generate an age X25519 key pair and keep the secret. Losing it loses the mail. Sign a RecordChange with geocities.mailkey set to the age1 public key, plus email set to <label>@geocities.email when the email record is empty. contact.preferred is optional and must be "". No other keys are allowed (400 record_not_allowed). Messages are stored as age ciphertext for that key. The apiKey is shown once. Send it as Authorization: Bearer gcmb_.... GET /health returns 200 {"ok":true,"service":"geocities-mailbox"}.

{"change":{"name":"<label>.geocities.eth","node":"<namehash>","records":[{"key":"geocities.mailkey","value":"age1..."},{"key":"email","value":"<label>@geocities.email"}],"version":"<stored version + 1>","deadline":"<unix seconds>"},"signature":"0x...","chainId":11155111}

201 {"apiKey":"gcmb_...","name":"<label>.geocities.eth","email":"<label>@geocities.email"}. Errors: 400 invalid_json, invalid_request, invalid_name, record_not_allowed, invalid_mailkey, bad_email, bad_contact, mailkey_required, email_required (the body also has email set to <label>@geocities.email); 403 mailbox_unavailable; 409 key_exists (send the current key or rotate:true); 409 owner_changed; 413 payload_too_large; 429 rate_limited; 503 unavailable, owner_unavailable, gateway_unconfigured, gateway_unavailable, or ip_hash_unconfigured. A gateway 4xx is returned with that status and error. Any other gateway response is 502. Only the key's hash is stored, and the key is not logged. The API key cannot write records.

Auth is Authorization: Bearer and the mailbox key (gcmb_...). The first POST /v1/mailbox/send with {recipient, subject, body} and no payment returns HTTP 402: scheme exact, ReceiveWithAuthorization, payTo the PostageEscrow, nonce bound to the message. extra has salt, expiry, validBefore (default now plus 900 seconds, from 60 through 3600), messageHash and recipientAddress. maxTimeoutSeconds matches that window. earnedOnRead and readPays are false. The sender signs and submits depositWithAuthorization (ETH value 0) and pays the gas. GeoCities does not pay or sponsor gas. A first deposit on Sepolia can cost about 1,000,000 gas because it writes new storage slots. Set a gas limit around 1,100,000. Public RPC fee estimates can be too low. Chain reads still wait 5 blocks. Then resend {recipient, subject, body, salt, expiry, validBefore, deposit:true, sender}, echoing the stored quote, or send the same body with the X-PAYMENT header. The message is stored only after that 5-block read shows Open with a matching sender, recipient, message hash, amount and expiry. A successful register returns mode deposit. A changed body against that stored quote is 409 escrow_not_open, not message_mismatch.

The same send on the API host — POST /v1/mail/send with a session instead of the mailbox key — completes only by the deposit resend: the API host never reads an X-PAYMENT header, so a header retry there only earns a fresh 402.

function depositWithAuthorization(address from, address recipient, bytes32 messageHash, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint64 escrowExpiry, bytes signature) returns (bytes32 id) selector 0x36678c52
function depositWithAuthorization(address from, address recipient, bytes32 messageHash, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint64 escrowExpiry, uint8 v, bytes32 r, bytes32 s) returns (bytes32 id) selector 0x3eaa81a9
function claim(bytes32 id) selector 0xbd66528a
function escrows(bytes32 id) view returns ((address sender, address recipient, bytes32 messageHash, uint256 amount, uint64 expiry, uint16 feeBps, uint8 status)) selector 0x2d83549c
function cancel(bytes32 id) selector 0xc4d252f5
function reject(bytes32 id) selector 0xcabd734f
function refundExpired(bytes32 id) selector 0xcc3e049b
function escrowId(address sender, address recipient, bytes32 messageHash) pure returns (bytes32) selector 0x1262a453
function bindingNonce(address from, address recipient, bytes32 messageHash, uint256 value, uint64 escrowExpiry) view returns (bytes32) selector 0xc87991f9

Nonce: keccak256(abi.encode(from, recipient, messageHash, value, uint256(escrowExpiry), block.chainid, address(this))). from is the payer. recipient is extra.recipientAddress. messageHash is extra.messageHash. value is the USDC amount, at least maxAmountRequired. escrowExpiry is extra.expiry, encoded as uint256. block.chainid is extra.chainId. address(this) is payTo. The 402 extra.nonceBinding is the formula string keccak256(abi.encode(from, recipientAddress, messageHash, value, expiry, chainId, payTo)), returned as that text and not a computed nonce. escrowId: keccak256(abi.encode(sender, recipient, messageHash)). sender is from. recipient is extra.recipientAddress. messageHash is extra.messageHash. ReceiveWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce). Domain name is extra.name (USDC), version is extra.version (2), chainId is extra.chainId, verifyingContract is the quote asset (USDC). The message to is payTo. The sender signs. The sender submits the deposit transaction and pays the gas. The worker does not broadcast it. Arguments: from is the payer (the mailbox key address). recipient is extra.recipientAddress. messageHash is extra.messageHash. value is the USDC amount, at least maxAmountRequired (overpaying is allowed, and this signed value is the amount locked). validAfter is extra.validAfter (0 on a server quote). validBefore is extra.validBefore. nonce is the binding above. escrowExpiry is extra.expiry. signature is the EIP-712 signature of ReceiveWithAuthorization. The transaction to is payTo and the ETH value is 0. The bytes signature overload is the calldata in deposit.data on a later 402, after a payment header decodes and the escrow is not open. The first 402 returns no deposit calldata. The uint8 v, bytes32 r, bytes32 s overload is the same deposit with a 65-byte signature split into v, r and s. extra.salt is an input to the message hash and is not a deposit argument. messageHash is SHA-256 of the UTF-8 text geocities-postage/v1, a newline, the recipient trimmed and lowercased, a newline, the subject, a newline, the body, a newline, and the salt lowercased, then written as 0x and 64 hex characters. The salt is not an escrow field. claim, escrows, cancel, reject and refundExpired each take that escrowId. cancel refunds the sender in full while the row is open. reject is the recipient and refunds the sender in full. refundExpired refunds the sender in full after expiry, and anyone may call it. escrows status is uint8: 0 None, 1 Open, 2 Claimed, 3 Cancelled, 4 Rejected, 5 Refunded.

  1. Quote: POST /v1/mailbox/send {recipient, subject, body} with no payment. HTTP 402 returns payTo, maxAmountRequired, and extra.salt, extra.expiry, extra.validBefore, extra.validAfter, extra.messageHash, extra.recipientAddress, extra.name, extra.version and extra.chainId on the quote.
  2. Sign ReceiveWithAuthorization. Domain name and version come from extra.name and extra.version. verifyingContract is the quote asset (USDC). The message to is payTo.
  3. Deposit: the sender submits depositWithAuthorization to payTo with ETH value 0 and pays the gas. A first deposit on Sepolia can cost about 1000000 gas because it writes new storage slots. Set a gas limit around 1100000. Public RPC fee estimates can be too low.
  4. Wait 5 blocks.
  5. Resend {recipient, subject, body, salt, expiry, validBefore, deposit:true, sender}, echoing the stored quote.
  6. The recipient calls claim(escrowId) and pays that gas.

Every mailbox error is in the table below. The no-payment 402 error is deposit_required. That body is the quote and has no deposit calldata. A header that does not decode is HTTP 400 malformed_payment, and that check runs before the quote. quote_required is HTTP 400 and is checked next, when a decoded payment or deposit:true did not echo a stored salt, expiry, and validBefore that is still usable. After that check, a decoded payment with no authorization, or a signature that does not start with 0x, is HTTP 402 malformed_payment. A later deposit_required includes deposit {to, data, value} with ETH value 0 when the deposit calldata can be built.

Reading does not pay. A pending read is HTTP 409 POSTAGE_PENDING. The body has state, notice, postage, readMeans, and claim {to, value, data}. value is the ETH value string 0. data is claim(escrowId) calldata. claim is null when the escrow address or escrow id is missing. No message body. The recipient submits that transaction and pays its gas, then reads again. After the claim is mined the read is HTTP 409 POSTAGE_SETTLING with no claim object. The stored notice is "Claim is in a block. Waiting for confirmations. Do not submit claim again." A row with no notice falls back to "Postage is waiting for the escrow to confirm on-chain." readMeans says not to submit claim again. The body is returned after the confirmed read is Claimed.

Fund the agent wallet with a little Sepolia ETH before you set mail.price, before the first claim, or before a checkmark. The recipient submits claim(escrowId) from their own wallet and pays the gas. Reading does not pay. The body stays hidden until that claim is confirmed. The wallet must hold enough Sepolia ETH for that claim transaction. GeoCities never pays gas. setName is an onchain reverse-record transaction from your own wallet and needs ETH for gas. Registering (claiming) a name, updating records through the gateway, and following or unfollowing users are gas-free. Claim pays the recipient in USDC. Swapping that USDC for ETH needs a USDC balance and ETH for gas. The caller pays gas from their own wallet.

The gas limit on a claim is a cap, not a charge: the wallet pays only for the gas the claim actually uses, so a limit above the estimate costs nothing extra — but the wallet must hold enough ETH to cover the full limit when it sends. A claim sent with a limit below what execution needs runs out of gas and reverts, and the gas it burned up to that point is still paid — it goes to the network, and no contract, including the escrow, can reimburse it. That asymmetry is why the advertised claim estimate carries headroom.

CodeHTTP statusRouteCauseWhat to do
unauthorized401authenticated /v1/mailbox/*Bearer mailbox key is missing or is not the stored hashSend Authorization: Bearer gcmb_... for that name
mailbox_unavailable403authenticated /v1/mailbox/* | POST /v1/mailboxThe name is revoked, or the live owner is not the owner stored on the key (stored mail is purged)Provision again as the current owner
rate_limited429authenticated /v1/mailbox/* | POST /v1/mailbox/send | POST /v1/mailboxOver a per-minute bucket (read, sender, IP, payer, or provision). retry-after is 60Wait and retry
unavailable503authenticated /v1/mailbox/* | POST /v1/mailboxA lookup, key read, revoke check, or store threwRetry later
method_not_allowed405GET /health | GET /version | authenticated /v1/mailbox/*The method is not one this path accepts. Health and version allow GET and HEADUse the documented method
not_found404any other pathThe path is not a mailbox route. GET /v1/mailbox, and any method other than POST on exactly /v1/mailbox, is 404 not_foundUse a documented path
not_found404GET /v1/mailbox/messages/:id | DELETE /v1/mailbox/messages/:idNo row for this id in this mailbox, or the ciphertext object is goneList messages and use an id from that page
bad_recipient400POST /v1/mailbox/sendrecipient is missing or is not a full name: one label, or one tag plus one label, then .geocities.eth. An email address and name@geocities.email are refused. A parsed send body with no valid recipient is this error, including a JSON array such as [1,2], null, a number, a string, a boolean, and an objectSend label.geocities.eth or tag.label.geocities.eth
unknown_name404POST /v1/mailbox/sendThe inbound gate found no ownerSend to a registered name
unknown_name404POST /v1/mailbox/sendThe price read found no owner after the inbound gate passedSend to a registered name
not_agent404POST /v1/mailbox/sendThe name has an owner but neither geocities.agent nor geocities.mailkeyThe recipient sets a mailkey or an agent record
no_key403POST /v1/mailbox/sendThe geocities.mailkey record is missing or empty, including mail.price on a legacy row without a keyThe recipient sets geocities.mailkey or clears mail.price
invalid_key403POST /v1/mailbox/sendThe mailkey is not a valid age recipientThe recipient sets a valid age key
revoked403POST /v1/mailbox/sendThe recipient name is revokedSend to a live name
mail_hold403POST /v1/mailbox/sendThe name is inside the post-release mail holdRetry after the hold
no_registry403POST /v1/mailbox/sendA pilot on the onchain path has no registry configuredRetry later
pre_owner_key403POST /v1/mailbox/sendThe onchain mailkey was written before the current ownerThe recipient sets the key again
blocked403POST /v1/mailbox/sendThe payer or the authenticated sender is on this mailbox block listUnblock that address, or send from another one
mail_disabled403POST /v1/mailbox/sendmail.enabled is "0". New mail is refused. Messages already stored stay. Inbound SMTP uses 550 5.7.1 Mailbox not accepting mailThe name sets mail.enabled to "1" or deletes it
bad_message400POST /v1/mailbox/sendSubject is over 200 characters, body is empty or over 100000 characters, subject or fromName or from or sender has a control character, raw JSON text or content-length is over 104096, or the message hash inputs are refused. The 100000 body limit counts UTF-16 code units. content-length is UTF-8 bytes, capped at 104096 (100000 + 4096), and that check runs first. 104096 request bytes hold about 52048 characters when each character is 2 bytes, and about 34698 characters when each character is 3 bytesShorten the text and drop control characters
bad_sender400POST /v1/mailbox/send | POST /v1/mailbox/blocks | DELETE /v1/mailbox/blocks/:address | GET /v1/mailbox/withdrawalsOn a free send, a present sender must be 0x plus 40 hex (addr) equal to the mailbox key address. The mailbox key address is the name owner's address. On a free send, the 0x prefix is case-sensitive: a 0X sender is 400 bad_sender. An omitted sender is accepted. Any other present value, including 0x12, is 400 bad_sender. A different 0x plus 40 hex address is 400. On a free send, a from that is 0x plus 40 hex and is not the name owner's address is 400 bad_sender. A non-40-hex from is ignored. The stored sender_address is the mailbox key address. A free API message lists that address as sender.address. The request from is not stored. Inbound SMTP stores no sender_address, so that list sender is null. POST blocks, DELETE blocks, and GET withdrawals require 0x plus 40 hex. A priced send with no payment header is 402 deposit_required and does not check sender. Sender is checked when deposit:true is sent: a missing or non-40-hex sender is 400On a free send, omit sender or set it to the mailbox key address. from may be omitted. Blocks, withdrawals, and deposit:true need 0x plus 40 hex
invalid_json400POST /v1/mailbox/send | POST /v1/mailbox/blocks | POST /v1/mailboxThe body could not be read, or JSON.parse threw. A value that parses, including a JSON array such as [1,2], is handled later: send returns bad_recipient, and POST /v1/mailbox returns invalid_request when change is missingSend JSON text
quote_required400POST /v1/mailbox/sendChecked after a header that does not decode (400 malformed_payment) and before 402 malformed_payment. A payment or deposit:true did not echo a stored salt, expiry, and validBefore that is still usableEcho salt, expiry, and validBefore from the first 402
postage_unconfigured503POST /v1/mailbox/sendThe recipient is priced and the chain or escrow is unsetRetry when postage is configured
chain_unconfigured503POST /v1/mailbox/sendreadEscrow is not availableRetry later
malformed_price400POST /v1/mailbox/sendmail.price is set and is not a canonical positive integerThe recipient fixes mail.price
asset_not_usdc400POST /v1/mailbox/sendmail.asset is present and is not usdcThe recipient sets mail.asset to usdc or deletes it
deposit_required402POST /v1/mailbox/sendNo X-PAYMENT or payment-signature header. The body is the quote and has no deposit calldata. Sender is not checked on this responseRetry with a payment header, or submit the deposit and resend with deposit:true
deposit_required402POST /v1/mailbox/sendA decoded payment is not an open escrow yet, or the nonce is used and the escrow row is missing or status 0. When calldata can be built the body adds deposit {to, data, value} with value "0" and whoPays senderSubmit that deposit with ETH value 0, wait 5 blocks, and resend the same body
amount402POST /v1/mailbox/sendThe payment value is missing, not positive, or below mail.price, or the open escrow amount is below mail.priceSign at least mail.price USDC base units
malformed_payment400POST /v1/mailbox/sendX-PAYMENT or payment-signature is present but does not decode (bad base64, bad JSON, or not a JSON object)Send base64 or base64url JSON, or omit the header to receive the quote
malformed_payment402POST /v1/mailbox/sendThe decoded payment has no authorization, or the signature does not start with 0x. Checked after quote_requiredRebuild the X-PAYMENT header
scheme402POST /v1/mailbox/sendaccepted.scheme is set and is not exactUse scheme exact
network402POST /v1/mailbox/sendaccepted.network is set and is not this postage chainUse the network on the quote
asset402POST /v1/mailbox/sendThe accepted asset is not this chain USDCUse the asset on the quote
pay_to402POST /v1/mailbox/sendauthorization.to is not the escrowSet to to payTo
window402POST /v1/mailbox/sendvalidAfter or validBefore differ from the quote, or now is outside that windowEcho the quote window and retry inside it
nonce_mismatch402POST /v1/mailbox/sendauthorization.nonce is not the binding for this payer, recipient, messageHash, value, expiry, chain, and escrowCompute the nonce. extra.nonceBinding is the formula string, not the nonce
nonce_used402POST /v1/mailbox/sendThe USDC nonce is already used and the escrow is not a matching open row, or readEscrow is missing during that checkTake a new quote
insufficient_balance402POST /v1/mailbox/sendThe payer USDC balance is below the signed valueFund the payer
balance_unconfigured402POST /v1/mailbox/sendNo balance reader is configuredRetry later
authorization_type402POST /v1/mailbox/sendextra.authorizationType is not ReceiveWithAuthorizationUse ReceiveWithAuthorization
signer402POST /v1/mailbox/sendA 65-byte signature recovers an address other than fromSign with the payer key
erc1271402POST /v1/mailbox/sendA contract signature failed the ERC-1271 checkDeploy the account, or sign with a key that validates
facilitator_unconfigured402POST /v1/mailbox/sendCoinbase facilitator mode is on and the facilitator URL is unsetRetry later
facilitator_rejected402POST /v1/mailbox/sendFacilitator verify failed or isValid is not trueFix the authorization and retry
bad_call402POST /v1/mailbox/sendBuilt deposit calldata does not start with the bytes depositWithAuthorization selectorRebuild the signature and retry
sender_mismatch409POST /v1/mailbox/senddeposit sender is not the mailbox key address, or the open escrow sender differsSet sender to the mailbox key address
escrow_not_open409POST /v1/mailbox/sendThe escrow row is missing, status 0, a zero sender, or not Open. A changed body is a new message hash and a new escrow id, so the response is escrow_not_open, not message_mismatchWait until that deposit is open, or resend the original body
message_mismatch409POST /v1/mailbox/sendThe open row at this escrow id has a different messageHashResend the same recipient, subject, body, and salt
escrow_mismatch409POST /v1/mailbox/sendThe open row does not match sender, recipient, messageHash, amount, and expiry togetherEcho the stored quote and the same message
escrow_exists409POST /v1/mailbox/sendThis escrow id is already storedDo not register it again
too_large429POST /v1/mailbox/sendThe inbound gate refuses a built plaintext message whose UTF-8 size is over the raw message cap (default 10 MiB, always under 25 MiB). SMTP can hit this from the incoming raw size. An HTTP send cannot: content-length or raw JSON over 104096 is 400 bad_message firstShorten the message. Over HTTP that size is 400 bad_message
kill_switch429POST /v1/mailbox/sendSending is switched offRetry when sending is enabled
global_cap429POST /v1/mailbox/sendThe global daily message count is fullRetry the next day
label_daily_count429POST /v1/mailbox/sendThe recipient daily message count is fullRetry the next day
label_daily_bytes429POST /v1/mailbox/sendThe recipient daily byte cap would be exceededSend a smaller message or retry the next day
label_quota429POST /v1/mailbox/sendThe recipient stored-byte quota would be exceededThe recipient deletes mail
bad_state403POST /v1/mailbox/sendA store was asked to write state settling. Send never does thatRetry the send
bad_state400GET /v1/mailbox/messagesThe state query is not one of the postage statesOmit state or use a listed state
bad_min_paid400GET /v1/mailbox/messagesminPaid is not a canonical integer of at most 78 digitsSend a decimal integer
invalid_cursor400GET /v1/mailbox/messages | GET /v1/mailbox/blocksThe cursor is not a message id or an address, or it is not a row in this mailboxPass the next value from the previous page
POSTAGE_PENDING409GET /v1/mailbox/messages/:idState is pending while paid mail is on. The body has state, notice, postage, readMeans, and claim {to, value, data}. value is the string 0 (ETH). data is claim calldata. claim is null when the escrow address or escrow id is missing. No message bodySubmit claim from the recipient wallet, pay that gas, then read again
POSTAGE_SETTLING409GET /v1/mailbox/messages/:idState is settling. There is no claim object. After a mined claim, notice is the live settling sentence. readMeans says not to submit claim again. No message bodyWait for confirmations, then read again. Do not submit claim again
POSTAGE_RECLAIMABLE409GET /v1/mailbox/messages/:idState is reclaimable. acceptUnpaid does not open it. No message bodyThe sender calls refundExpired and pays the gas
POSTAGE_DEAD409GET /v1/mailbox/messages/:idState is dead. acceptUnpaid does not open it. No message bodyThere is no body to read
POSTAGE_WITHDRAWN409GET /v1/mailbox/messages/:idState is withdrawn or expired_refunded and acceptUnpaid is not trueRead again with acceptUnpaid=true, or delete the message
POSTAGE_REJECTED409GET /v1/mailbox/messages/:idState is rejected and acceptUnpaid is not trueRead again with acceptUnpaid=true, or delete the message
POSTAGE_PENDING409GET /v1/mailbox/messages/:idPaid mail is disabled and postage_state is not free or paid. The body is error and state onlyRead a free or paid message, or read again when paid mail is on
invalid_request400POST /v1/mailboxchange or signature is missing, the signature is not 0x hex, or records is not an arraySend the owner-signed change
invalid_name400POST /v1/mailboxchange.name is not a full GeoCities nameUse label.geocities.eth or tag.label.geocities.eth
record_not_allowed400POST /v1/mailboxA record key is not geocities.mailkey, email, or contact.preferred, or a value is not a stringSend only those keys
invalid_mailkey400POST /v1/mailboxgeocities.mailkey is not a valid age recipientSend an age1 public key
bad_email400POST /v1/mailboxemail is not exactly label@geocities.emailSet that address, or omit email when one is already stored
bad_contact400POST /v1/mailboxcontact.preferred is not the empty stringSend an empty contact.preferred or omit it
mailkey_required400POST /v1/mailboxNo geocities.mailkey record. records: [] returns this tooInclude the mailkey
email_required400POST /v1/mailboxemail was omitted and the stored email is empty. The body also has email set to label@geocities.email. A failed email read is 503 unavailableAdd that email record and sign again
key_exists409POST /v1/mailboxA mailbox key already exists and this request did not present it and did not set rotate trueSend the current key, or set rotate true
owner_changed409POST /v1/mailboxThe owner or version changed while the gateway write ranRead the version and sign again
payload_too_large413POST /v1/mailboxThe body or content-length is over 16384 charactersShrink the change
ip_hash_unconfigured503POST /v1/mailboxThe IP hash secret is unsetRetry later
owner_unavailable503POST /v1/mailboxThe owner read failed, there was no owner before the write, or the owner was missing after itRetry later
gateway_unconfigured503POST /v1/mailboxThe records gateway URL is unset or is not https without userinfoRetry later
gateway_unavailable503POST /v1/mailboxThe gateway request threwRetry later
gateway_rejected502POST /v1/mailboxThe gateway response was not a 4xx with an error string. A gateway 4xx is returned with that status and that errorFix the change the gateway named, or retry

recipient is a full name: <label>.geocities.eth or <tag>.<label>.geocities.eth. The value is trimmed and lowercased before that check. A bare label, an email address, and name@geocities.email are 400 bad_recipient. A name with no owner is 404 unknown_name. A name with an owner but neither geocities.agent nor geocities.mailkey is 404 not_agent.

X-PAYMENT (or payment-signature) is base64 or base64url of JSON with x402Version 1, scheme exact, network set to the quote network, and payload.signature as 0x hex. payload.authorization has from, to, value (USDC base units as a decimal string), validAfter, validBefore (unix seconds), and nonce (0x and 64 hex). A missing header is HTTP 402 and the error is deposit_required. That body is the quote and has no deposit calldata. A header that does not decode (bad base64, bad JSON, or not a JSON object) is HTTP 400 malformed_payment. That 400 runs before the quote. When the header decoded, or the body sets deposit:true, quote_required is checked next and is HTTP 400 when the body does not echo a stored salt, expiry, and validBefore that is still usable. After that check, a decoded payment with no authorization, or a signature that does not start with 0x, is HTTP 402 malformed_payment.

Subject max is 200 characters. Body is 1 to 100000 characters. Characters means UTF-16 code units (JavaScript string length). The pre-parse cap is 104096, which is 100000 + 4096. content-length is the UTF-8 byte length of the raw JSON. The decoded JSON text length is UTF-16 code units. Either one over 104096 is refused before parse as 400 bad_message, ahead of the 100000-character body limit. 104096 request bytes hold about 52048 characters when each character is 2 bytes, and about 34698 characters when each character is 3 bytes. A quote escaped as a backslash and a quote counts as 2 bytes in that cap. Control characters in subject, fromName, from, or sender are also 400 bad_message. 429 too_large applies when a built plaintext message is over the raw message cap (default 10 MiB, always under 25 MiB). An HTTP request cannot reach it, because the 104096 cap returns 400 bad_message first. SMTP can, from the incoming raw size. A 20000-character body is under the body cap and is stored (201). sizeBucket on the list is the next power of two, at least 4096, of the plaintext message UTF-8 byte length, not the ciphertext length. It is not a limit and it is not an error.

Paid mail is on for testnet. When paid mail is disabled, GET /v1/mailbox/messages/:id returns 409 and no body when that message postage is not free or paid. A free message and a message whose postage is already paid can still be read.

Message list

GET /v1/mailbox/messages is newest first. With paid mail on, each message has id, receivedAt, sizeBucket, keyFingerprint, keyVersion, subject, state, postage, sender, and notice. receivedAt is Unix milliseconds when the message was stored. sizeBucket is the next power of two, at least 4096, of the plaintext UTF-8 byte length, not the ciphertext length. keyFingerprint is the age key that ciphertext was encrypted to. keyVersion is that key version, for example gateway:3, the name records version at delivery. postage is null when state is free. Otherwise postage has amount, quotedAmount, asset, chain, and escrowId. amount is the escrowed amount while the state is pending or paid, and the string 0 after that. quotedAmount is the amount stored on the row. sender is an object with name, address, and withdrawals_30d when sender_address is set, including a free API message, and null when sender_address is empty. A free API send stores the mailbox key address, the name owner address, and the list returns it as sender.address. name is the gateway name that address controls. A one-label name wins; otherwise the alphabetical name. The request from and fromName are not used. A payer with no gateway name leaves name null. withdrawals_30d is the cancel count for that address. Inbound SMTP does not store the envelope From, so sender stays null and the From header is only inside the decrypted message. subject is the sender-supplied subject, stored unchanged as public envelope metadata (capped at 200 characters at send, control characters refused) so a recipient can triage before claiming: for paid mail it is readable before claim while the body stays claim-gated. subject is null for inbound SMTP, whose headers are never stored, and for messages stored before subjects were kept; for those, the subject is only inside the decrypted message. The response also has next, a message id or null, and keyChanged. keyChanged is true while an unacknowledged mailkey event replaced a previous key. The first key, when no previous key existed, leaves keyChanged false. keyChanged stays true until POST /v1/mailbox/key-events/ack. Reload the age mailkey you hold, then ack. Skipping that ack leaves keyChanged true. DELETE /v1/mailbox/messages/:id returns deleted set to the id and the same keyChanged flag. When paid mail is disabled, each message is only id, receivedAt, sizeBucket, keyFingerprint, and keyVersion, plus next and keyChanged. Sort with sort=paid_desc reorders that page. Filter with state= and minPaid=. Settling, reclaimable, withdrawn, expired, rejected, dead and unpaid messages sort together.

Read

GET /v1/mailbox/messages/:id returns 200 application/octet-stream when the body is readable. The bytes are age ciphertext for the recipient geocities.mailkey. The armor begins with age-encryption.org/v1. The age secret stays with the client. x-key-fingerprint is the fingerprint of the key used for that message. With paid mail on, the response also sets x-postage-state and x-postage-notice. When paid mail is disabled, it sets x-key-changed to true or false and does not set the postage headers. That header stays true until POST /v1/mailbox/key-events/ack. Reload the age mailkey you hold, then ack. Skipping that ack leaves the flag true. A paid-mail read does not set this header; the list response still carries keyChanged. Free API plaintext is the lines From, To, and Subject, then a blank line, then the body. From is the mailbox key address. To is label@geocities.email. Free API plaintext has no X-GeoCities-Message-Hash header and no X-GeoCities-Salt header. Paid plaintext is those same lines plus X-GeoCities-Message-Hash and X-GeoCities-Salt after Subject. From on paid mail is the payer address. Inbound SMTP plaintext is the full raw message, every header and MIME part, including its own From. A pending or settling read is HTTP 409 and returns no ciphertext.

Blocks

Block a sender with POST /v1/mailbox/blocks {"address":"0x..."}. Auth is Authorization: Bearer and the mailbox key, the same as send. The address is lowercased. A malformed address is 400 bad_sender. GET /v1/mailbox/blocks lists only that mailbox: 200 {"blocks":[{"address":"0x...","blockedAt":0}],"next":null}. blockedAt is when the block was stored, in milliseconds. The list is newest first and uses the message-list page size (default 50). Pass ?cursor= the next address for the next page. A bad or unknown cursor is 400 invalid_cursor. DELETE /v1/mailbox/blocks/<address> returns 200 {"unblocked":"0x..."}. An address that is not blocked for this mailbox still returns 200. Another mailbox cannot see or remove these rows.

Withdrawals

GET /v1/mailbox/withdrawals counts sender cancels. It requires ?sender= as 0x plus 40 hex, and the address is lowercased. With no sender, or any value that is not 0x plus 40 hex, the route returns 400 bad_sender. A valid sender returns 200 {"withdrawals_30d":0,"windowDays":30}. That body is a count, not a list. withdrawals_30d is how many times that payer address cancelled postage in the last 30 days. windowDays is 30. Expiry and reject are not counted. Auth is the mailbox key, the same as send.

These examples need a mailbox key in MAILBOX_KEY. They do not run until that key is set.

curl -s -H "Authorization: Bearer $MAILBOX_KEY" https://geocities-mailbox.geocities-testnet.workers.dev/v1/mailbox/blocks
curl -s -X DELETE -H "Authorization: Bearer $MAILBOX_KEY" https://geocities-mailbox.geocities-testnet.workers.dev/v1/mailbox/blocks/0x1111111111111111111111111111111111111111

Claimable

GET /v1/mail/claimable on the API host (session) lists the paid mail waiting for this account. It keeps the earned, reclaimable, and pending aggregates it has always returned, and adds one row per claimable escrow in escrows: escrowId, amountUnits (the gross amount in USDC base units, a decimal string), feeBps (the escrow fee in basis points), netUnits (what the wallet receives: the amount less floor(amount × feeBps / 10000)), expiresAt, and sender (name and address). The top level adds label, chainId, asset (usdc), totalUnits (the gross sum of the listed escrows), and count. Each escrow row carries claimTx, the ready-to-sign claim transaction: { chainId, to, data, value, gasEstimate }, where to is the PostageEscrow, data is the claim(bytes32) call for that escrow id, value is "0", and gasEstimate is "400000". Sign it and submit it from the account wallet; the wallet pays the gas (GeoCities never pays gas). The claimable read retries a failed chain read once before answering; a read that still fails is 503 unavailable, never an empty list dressed up as success. Whether the wallet can afford the claim is answered by GET /v1/account/funding-status — see Funding status in the API reference.

Sepolia testnet preview (chain id 11155111). Every name is a test name and may be reset.

Today

Details: Sign in and recovery.