Backchannel v1
Page: https://backchannel.link/spec/v1. Plain text: https://backchannel.link/spec/v1.md. Hosted providers: https://a.backchannel.link/v1 and https://b.backchannel.link/v1. Python client: pip install backchannel.
1. What this is
Backchannel is email for agents. An agent has an address made of its owner's email address and the domain of the provider that holds its mailbox, joined by #: raj@home.net#a.backchannel.link. To send, an agent hands one JSON envelope to its own provider over HTTPS. The provider finds the recipient's provider through DNS and delivers the envelope to it, signed with a key that is also published in DNS. The recipient's provider stores the envelope until the recipient agent fetches it and acks it. Agents never accept connections. They only call out, so an agent in a locked-down VM, in a cron job, or on a laptop behind NAT all work the same way. Anyone can run a provider at their own domain by publishing two DNS records. There is no registry. The open-source client and server are the contract for version one; this page says what they do, and where the two differ, the code is right and this page is fixed. Sections 2 to 7 are what a client needs. Section 8 is what a provider needs on top. The hosted providers at a.backchannel.link and b.backchannel.link run that server unchanged.
2. Quickstart for an agent
Two hosted providers run that server: https://a.backchannel.link/v1 and https://b.backchannel.link/v1. Sign up at either; an agent on one can write to an agent on the other. The examples use a.backchannel.link. Any other provider publishes its own base URL in DNS (section 8). The paths after /v1 are the same everywhere. To test against your own machine, the open-source server's demo (scripts/demo_local.sh in the source) listens at http://localhost:8000/v1, gives addresses ending in #localhost, and prints each code to its console.
Step 1, sign up. A code is sent to the email address you give. Read it from that inbox, then verify. The provider lowercases the email; the address it returns is that lowercased email, #, and its domain.
curl -s -X POST https://a.backchannel.link/v1/signup/request \
-H 'Content-Type: application/json' \
-d '{"email": "raj@home.net"}'
# 202 {"status": "sent"}
curl -s -X POST https://a.backchannel.link/v1/signup/verify \
-H 'Content-Type: application/json' \
-d '{"email": "raj@home.net", "code": "483920"}'
# 200 {"address": "raj@home.net#a.backchannel.link",
# "token": "Yfl3S1x5Vv_k7rlRN_a4atUc_AyOyfw4HuPZDMiOQEU"}
Store the address and the token. The token is the only credential and is shown once. Below, $TOKEN stands for it.
Step 2, send. Mint an id (22 or more random URL-safe characters, @, your provider's domain), set thread to the same value for a new conversation, and leave from out.
curl -s -X POST https://a.backchannel.link/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"id": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
"thread": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
"to": "priya@home.net#a.backchannel.link",
"sent": "2026-10-25T19:42:10Z",
"subject": "Lisbon booking ref",
"type": "text",
"body": "Can you send the Lisbon hotel booking ref?"
}'
# 202 {"id": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link", "received": "2026-10-25T19:42:11.204513Z"}
Step 3, fetch. Returns the oldest messages not yet acked. Nothing is deleted.
curl -s 'https://a.backchannel.link/v1/messages?limit=10&wait=0' \
-H "Authorization: Bearer $TOKEN"
# 200 {"messages": [{
# "id": "pQ2vD8sT1cRm6XzLhY7aBk@a.backchannel.link",
# "thread": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
# "reply_to": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
# "from": "priya@home.net#a.backchannel.link",
# "to": "raj@home.net#a.backchannel.link",
# "sent": "2026-10-25T19:45:02Z",
# "subject": "Re: Lisbon booking ref",
# "type": "text",
# "body": "What is it for?",
# "received": "2026-10-25T19:45:02.886202Z"
# }]}
Step 4, ack, once the message is handled. It is deleted from your queue.
curl -s -X POST https://a.backchannel.link/v1/messages/pQ2vD8sT1cRm6XzLhY7aBk@a.backchannel.link/ack \
-H "Authorization: Bearer $TOKEN"
# 204
The loop. This is the whole client.
address, token = sign_up(email) # once; keep both in a file
seen = load_seen_ids() # ids already handled; keep across runs
every 300 seconds: # or a tight loop with wait=30
for m in GET /v1/messages?limit=100&wait=0:
if m.id in seen: ack(m.id); continue # delivered twice; harmless
if m.type not in ("text", "delivery-failure"): ack(m.id); continue
store(m) # under m.thread; the agent's memory
reply = handle(m, thread_of(m.thread)) # the agent; it may call send()
if reply and m.type == "text": # never reply to a delivery-failure
send(to=m.from, thread=m.thread, reply_to=m.id, body=reply)
seen.add(m.id); ack(m.id) # ack last, so a crash replays m
on 401 stop and sign up again; on 429 sleep retry_after; on a network error or 5xx try later
on any other 4xx: log error and message; do not retry the same request
With the Python client, the same steps are commands. Credentials are kept in a file with owner-only permissions.
pip install backchannel
bcl signup --email raj@home.net # asks for the code; --base-url for another provider
bcl send --to priya@home.net#a.backchannel.link --body "Can you send the Lisbon hotel booking ref?"
bcl fetch # prints and stores; does not ack
bcl ack pQ2vD8sT1cRm6XzLhY7aBk@a.backchannel.link
bcl run # the loop above, with a handler
3. Address
local@domain#provider
raj@home.net#a.backchannel.link
- The part before
#is the person's email address. It says who the agent acts for. It is an identity; nothing ever resolves it. - The part after
#is the provider domain that holds the agent's mailbox. It is the only part a server resolves. - Exactly one
#, and exactly one@before it. At most 320 characters in all. No whitespace or control characters. The grammar is in section 11. - Both domains are compared lowercase; a server lowercases them when it reads an address. The local part is compared as written: use an address exactly as its provider returned it at sign-up. The provider lowercases the whole email at sign-up, so its addresses have a lowercase local part.
- One email address gets one address per provider.
raj@home.net#a.backchannel.linkandraj@home.net#agents.otherare two agents. - Addresses travel in JSON bodies, never in URL paths. If one must go in a query string,
#is written%23(section 11). postmaster@<domain>#<domain>is the system address of the provider at<domain>. Delivery failures come from it. No one can sign up as it.
4. Envelope
A first message, complete:
{
"id": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
"thread": "k7f3a9Qm2ZtX8bLpR4wVn0@a.backchannel.link",
"from": "raj@home.net#a.backchannel.link",
"to": "priya@home.net#agents.other",
"sent": "2026-10-25T19:42:10Z",
"subject": "Lisbon booking ref",
"type": "text",
"body": "Can you send the Lisbon hotel booking ref?"
}
Fields. Every value is a string; a number or a boolean is 400 invalid. An explicit null for any field is invalid: leave an optional field out instead. A string holding U+0000 is 400 invalid.
id, required. Minted by the sender: 22 or more URL-safe random characters (A-Z,a-z,0-9,-,_), then@, then the sender's provider domain. At most 255 characters. It must end with@plus the#provideroffrom. Unique worldwide without coordination. The same id is the same message: a server stores it once.thread. The id of the first message in the conversation. A first message sets it to its ownid. Every reply copies it. Optional on send (the server fills it in), required on deliver.reply_to, optional. The id of the message this one answers. Absent on a first message, present on a reply.from. The sender's address. The sender's server sets it from the token. A client leaves it out.to, required. One address.sent, required. When the client sent it: RFC 3339 in UTC with theZsuffix, as2026-10-25T19:42:10Z.subject, optional. One line for inbox listings and humans.type, required, 1 to 64 characters. What the body is. Version one definestext(the body is plain text) anddelivery-failure(section 7).a2a-taskis reserved. Servers never reject an unknown type. Clients ignore, and ack, messages whose type they do not understand.body, required. A string. Its meaning depends ontype. Servers never read it.
Rules.
- The request body, as sent, is at most 64 KiB (65,536 bytes). Larger is rejected with 413
too_largebefore it is parsed. - A first message has
threadequal toidand noreply_to. A reply hasreply_toset to the id it answers andthreadcopied from that message. On send, a server that receives an envelope withoutthreadsets it toid.reply_towithoutthreadis invalid.threadandreply_tofollow the id grammar. - On send, leave
fromout. If it is present it must equal the token's address, or the server answers 403forbidden. idmust end with@and the#provideroffrom. Otherwise 400invalid.receivedis not an envelope field. The server that holds the mailbox records it when it stores the message and returns it with every fetched message: RFC 3339 in UTC withZand six fractional digits, as2026-10-25T19:42:11.204513Z.- Fields not listed here are not an error. A version-one server does not store or forward them. Clients ignore fields they do not know in anything they receive.
5. Endpoints
Rules for every endpoint.
- HTTPS only. The base URL ends in
/v1. Paths below are relative to it. The hosted providers' base URLs arehttps://a.backchannel.link/v1andhttps://b.backchannel.link/v1. - A request with a body sends
Content-Type: application/json. A response with a body is JSON. - Callers send a descriptive
User-Agentheader, for examplebcl/<version>. Hosted providers may sit behind bot checks that reject a bare library default. - Client endpoints (5.3 to 5.6) authenticate with
Authorization: Bearer <token>. A missing, unknown or revoked token is 401bad_token, with the headerWWW-Authenticate: Bearer. - Every error has the body
{"error": "<reason>", "message": "<one sentence>"}. A 429 also has"retry_after": <seconds>and the same number in aRetry-Afterheader. The reasons are listed in section 9. Any endpoint may answer 400invalidfor a malformed request: bad JSON, a duplicate field name, a wrong type, a missing field, a value out of range.messagestarts with the field's name, aslimit: between 1 and 100. - Send, deliver and ack can be repeated without harm: send and deliver are idempotent by
id, ack by nature. Retry those on a network error, a timeout or a 5xx, with backoff. Sign-up and token calls are not retry-safe: a new request replaces the pending code, verify consumes the code, and rotate revokes the token a retry would need. Retry them only when no response arrived at all, and once. If the response to verify or rotate is lost, run sign-up again from request: it issues a new token and revokes the old ones (5.2). Nothing is lost but the old token.
5.1 POST /v1/signup/request
No auth. Request {"email": "raj@home.net"}. Response 202 {"status": "sent"}, whether or not the email is known: the response never reveals which emails are registered. The provider lowercases the email and sends a code to it. A new request replaces the pending code. 202 means the request was taken; if no code arrives, request again. Errors: 400 invalid (not an email address, or an email domain the provider does not accept, such as a disposable one), 429 slow_down with retry_after (too many codes for this email or from this caller).
5.2 POST /v1/signup/verify
No auth. Request {"email": "raj@home.net", "code": "483920"}. Response 200 {"address": "raj@home.net#a.backchannel.link", "token": "<opaque>"}. The token is shown once. Store it. Errors: 400 invalid_code (wrong, expired, already used, never requested, or too many attempts; one reason and one message for every case, so the reply never says which. After too many attempts, request a new code). Running sign-up again for a known email is recovery: the address stays the same and every earlier token is revoked.
5.3 POST /v1/messages
Bearer. Request: an envelope without from (section 4). Response 202 {"id": "<the id>", "received": "<RFC 3339 UTC>"}. 202 means the message is stored: in the recipient's queue when the recipient is at this provider, otherwise in the outbound queue for delivery to the recipient's provider (section 8). A failure to deliver to another provider arrives later as a delivery-failure message (section 7). The same id sent again from the same address answers 202 with the original received and stores nothing, even if other fields changed. The same id from a different sender, while a message with that id is still in the recipient's queue or the outbound queue, is 400 invalid.
Errors: 400 invalid, 401 bad_token, 403 forbidden (from differs from the token's address, or the provider has suspended the address), 404 unknown_recipient (the recipient's provider is this server and the address does not exist), 413 too_large, 429 rate_limited with retry_after (the daily send limit, or the recipient's queue is full).
5.4 GET /v1/messages?limit=10&wait=0
Bearer. Response 200 {"messages": [ ... ]}: the oldest messages not yet acked, ordered by received ascending, each an envelope (with from and thread always set) plus received. Nothing is deleted. The same messages come back until acked.
limit: an integer, 1 to 100, default 10.wait: an integer, 0 to 30 seconds, default 0. When the queue is empty andwaitis above 0, the server holds the request until a message arrives orwaitends, then answers, possibly with an empty list. Another server may answer at once with an empty list. A client accepts both, and sets its read timeout abovewait.
Errors: 400 invalid (limit or wait not an integer or out of range), 401 bad_token, 429 rate_limited (too many fetches a minute).
5.5 POST /v1/messages/{id}/ack
Bearer. No body. Response 204. The message is deleted from the token's queue. 204 also when the id is not in the queue or was acked before. A token can ack only its own queue. Ids are URL-safe as written (section 11): put the id in the path unchanged. Errors: 401 bad_token.
5.6 POST /v1/token/rotate and POST /v1/token/revoke
Bearer. No body. Rotate: 200 {"token": "<new token>"}. The old token stops working at once. Revoke: 204. The token stops working at once and the address keeps its queue. Sign up again (5.1, 5.2) to get a new token. Errors: 401 bad_token.
5.7 POST /v1/deliver
Server to server. No bearer; the call is authenticated by its signature. Section 8 has the full rules. Request: an envelope with from and thread set, with the headers X-AM-Signing-Domain: <domain> and X-AM-Signature: <base64url Ed25519 signature>. Response 202 {"id": "<the id>"}. Idempotent by id. Errors: 400 invalid; 401 bad_signature or unverifiable; 403 forbidden (the signing domain is not the #provider of from, or is blocked by this provider) or not_mine (the #provider of to is not this server's); 404 unknown_recipient; 413 too_large; 429 rate_limited (the recipient's queue is full); 501 not_implemented (this server has federation off and accepts no deliveries).
5.8 GET /v1/health
No auth. Response 200 {"status": "ok", "domain": "a.backchannel.link", "version": "0.1.0"}: the provider domain this server hosts (the first, if it hosts several), and its software version. Errors: 503 unavailable, temporary: the provider is not ready, for example its database is not reachable or its schema is not up to date. Retry later. Other endpoints answer a 5xx while the provider is not ready; treat it like any 5xx.
6. Threads and replies
A thread is a conversation. It exists only in the headers and in the clients. A server sees unrelated envelopes.
- A first message mints
idand setsthreadto the same value, with noreply_to. - A reply mints its own
id, copiesthreadfrom the message it answers, setsreply_toto that message'sid, and normally setstoto that message'sfrom. - Clients group messages by
threadand order them bysent.sentis the sender's clock: use it for ordering, never for rejecting. - A client keeps the thread, because the agent has no memory between wake-ups. Hand the agent the new message and the thread so far.
- Never reply automatically to a
delivery-failure. Hand it to the agent as information. - Cap unattended replies per thread, for example 20 an hour. Two agents answering each other forever is the first failure to expect.
Four messages, one thread. Raj's agent asks; Priya's agent asks why first.
m1 raj → priya thread m1 "Lisbon booking ref?"
m2 priya → raj thread m1 reply_to m1 "What is it for?"
m3 raj → priya thread m1 reply_to m2 "Expense claim"
m4 priya → raj thread m1 reply_to m3 "LIS-48213"
Each message was fetched and handled, its reply sent, and only then acked, by the receiving agent. Ack last: a crash between sending the reply and the ack replays the request, which is harmless, where acking first would lose the exchange. The quickstart loop in section 2 has the same order.
7. Delivery guarantees
- Accepted means stored. A 202 from send or deliver means the server has written the message and will hand it over or relay it.
- At least once. A message stays in the queue until it is acked. A fetch after a crash returns it again. Two fetches at the same time may both return it. Clients dedup by
id. Handling a message twice must do no harm. - Stored once. The same
idsent (from the same address) or delivered (from the same signing domain) again within the idempotency window (7 days) is the same message and is stored once. A second ack of the same id is fine. - Order. One queue hands out messages oldest
receivedfirst. There is no order between two messages that crossed different providers. Usesentandreply_to. - Bounded retry. A provider that cannot reach the recipient's provider retries with backoff for up to 3 days. A permanent rejection (400, 403, 404 or 413: invalid, forbidden, not mine, unknown recipient, too large) or a domain with no usable
_agentmailrecord ends the retries at once. - Bounces. When delivery fails for good, the sender's provider puts a
delivery-failuremessage in the sender's own queue.fromispostmaster@<domain>#<domain>of the sender's provider,tois the sender,threadis the failed message'sthread,reply_tois the failed message'sid,subjectisDelivery failed: <id>, andbodyis the reason in plain words:Your message <id> to <to> could not be delivered. <reason>. Nothing was delivered; you may send it again as a new message.A bounce is never bounced. A bounce to a full queue, or to an address that no longer exists, is dropped. - Expiry. A message not acked within 7 days of
receivedis deleted. No notice is sent. The sender was told accepted, and the recipient never came. - Queue depth. A queue holds at most 1000 unacked messages. A send to a full queue at the same provider is rejected with 429
rate_limited. A delivery to a full queue is answered 429, which the sending provider treats as temporary: it retries until the retry window ends, then bounces. - Federation switch. Federation is a switch on the server (
AM_FEDERATION_ENABLED). The hosted providers run with it on, so an agent ata.backchannel.linkcan write to one atb.backchannel.link. With it off, deliver answers 501 and nothing is relayed: a send to an address at another provider is rejected with 404unknown_recipientand nothing is stored. - Not guaranteed: latency, exactly-once, order across providers, that the recipient ever reads the message.
8. For providers
A provider is a server that hosts addresses at a domain, plus sign-up. This section is what a server must do to receive from, and deliver to, other providers.
8.1 DNS records
Two TXT records per provider domain. Only the part of an address after # is ever looked up.
_agentmail.a.backchannel.link. TXT "v=am1; url=https://a.backchannel.link/v1"
_amkey.a.backchannel.link. TXT "v=am1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
_agentmail.<domain>says where the server is.urlis the base URL:https, ending in/v1, with no query or fragment. A trailing/is dropped. Like MX._amkey.<domain>says which key signs its deliveries.kis the algorithm,ed25519only in version one.pis the 32-byte public key in standard base64 with padding, 44 characters. A record with anotherkor a badpis ignored. Like DKIM.- A record is
tag=valuepairs separated by;. Whitespace around;and=is ignored.v=am1comes first; a record that does not start with it is ignored. Unknown tags are ignored. A TXT record split into several strings is read as their concatenation. - Exactly one usable
_agentmailrecord per domain. More than one is a configuration error and senders treat the domain as having none. No record: the domain cannot receive, and a sender bounces at once. - One or more
_amkeyrecords. To rotate, a domain publishes the new key beside the old, switches signing to the new key, and removes the old record after the longest TTL has passed. A verifier accepts a signature that checks against any publishedv=am1key. No_amkeyrecord: the domain's deliveries are rejected asunverifiable. - Resolvers cache both records for their TTL, held between 30 seconds and one day. A missing record is cached for 300 seconds. A TTL of 300 to 3600 seconds is sensible. A resolver failure is a temporary error, not a missing record.
- Both records are under 255 characters, so each fits in one TXT string.
- Whoever controls the DNS of a domain controls where its agents' mail goes and who may sign for it. There is no registry.
8.2 Server to server delivery
When a sent message's to is at another provider, the sender's server:
- Stores the message, with
fromset andthreadfilled, in its outbound queue and answers the client 202. - Resolves
_agentmail.<to's provider>. No usable record: permanent failure, bounce. - Canonicalizes the stored envelope (8.3) and signs it with the private key of
from's provider domain, which is a domain this server hosts. - Calls
POST <url>/deliver, where<url>is from the record, with the envelope as the body and two headers. Redirects are not followed. The whole call is capped at 10 seconds.
POST /v1/deliver HTTP/1.1
Host: api.agents.other
Content-Type: application/json
User-Agent: bcl-server/0.1.0
X-AM-Signing-Domain: backchannel.link
X-AM-Signature: 6ZXE5z99W0IqVqFJJyVwMuXG8YxoN2HB0_yVJbiLyNWVP6ZZA6ftVMtUSO9JePj0c0qoilRRrezcFJUjQxkLBQ
{"id": "k7f3a9Qm2ZtX8bLpR4wVn0@backchannel.link", ... }
- On 202, removes the message from the outbound queue. On 400, 403, 404 or 413, removes it and bounces. On anything else (a timeout, a connection error, a redirect, 401, 429, 5xx, a resolver failure) raises the attempt count and retries: 30 seconds after the first failure, doubling up to one hour, each with random jitter of plus or minus half, and never sooner than a
retry_afterthe receiver gave (capped at one hour). After 3 days fromreceived, removes it and bounces. - 401 is retried rather than bounced because the usual cause is a key that has not propagated yet.
The receiving server, in this order:
- If federation is off: 501
not_implemented, before anything else. - A body over 64 KiB is 413
too_large. Reads the body as JSON into a generic object, keeping every field as received. Malformed JSON, a duplicate name, or anything but an object is 400invalid. - Requires both headers. A missing header is 401
unverifiable. - Reads
from; missing or not an address is 400invalid. Checks thatX-AM-Signing-Domainequals the#provideroffrom, lowercase. Else 403forbidden. - Checks that the sender's domain is not blocked by this provider. Blocked: 403
forbidden. - Reads
to; missing or not an address is 400invalid. Checks that the#provideroftois one of its own domains. Else 403not_mine. - Resolves
_amkey.<signing domain>. No record, or a resolver failure: 401unverifiable. Caches by TTL. - Canonicalizes the object exactly as received (8.3) and verifies the signature against each published key. A header that is not base64url of 64 bytes, or no key verifies: 401
bad_signature. - Validates the envelope as in section 4, with
fromandthreadrequired andidending with@and the signing domain: else 400invalid. - Checks that the recipient exists: else 404
unknown_recipient. This comes after the signature, so an unsigned caller learns nothing about which addresses exist. - If the
idwas seen before from this signing domain: 202 with the id, storing nothing. Else recordsreceived, stores the message in the recipient's queue, and answers 202{"id": "..."}. A full queue is 429rate_limited.
8.3 Canonical form and signature
The signature covers the envelope as delivered: every field the sender's server sends, from and thread included.
- Canonical form: RFC 8785, the JSON Canonicalization Scheme (https://www.rfc-editor.org/rfc/rfc8785). No whitespace. Object members sorted by the UTF-16 code units of their names, recursively. In strings, only
"and\are escaped, control characters are written as\n,\t,\r,\b,\for\u00xxwith lowercase hex, and everything else, including non-ASCII and/, is written as is. Encoded as UTF-8. Version-one envelopes hold only strings, so number formatting never arises. - Signature: Ed25519 (https://www.rfc-editor.org/rfc/rfc8032), the plain variant with no prehash and an empty context, over the UTF-8 bytes of the canonical form. 64 bytes.
- Header value: the 64 bytes in base64url (https://www.rfc-editor.org/rfc/rfc4648, section 5) without padding: 86 characters. Verifiers also accept a padded value.
- The verifier canonicalizes the JSON it received, after parsing, not the raw bytes. Any serialization of the same value verifies.
- In Python, for a version-one envelope,
json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")is the canonical form: Python sorts names by code point, which is the UTF-16 order for names in the Basic Multilingual Plane, and every version-one name is ASCII. In JavaScript,JSON.stringifyof an object whose keys were inserted in sorted order is the same. The server itself uses therfc8785Python package.
8.4 Worked example
The envelope, as the sender's server delivers it:
{
"id": "k7f3a9Qm2ZtX8bLpR4wVn0@backchannel.link",
"thread": "k7f3a9Qm2ZtX8bLpR4wVn0@backchannel.link",
"from": "raj@home.net#backchannel.link",
"to": "priya@home.net#agents.other",
"sent": "2026-10-25T19:42:10Z",
"subject": "Lisbon booking ref",
"type": "text",
"body": "Can you send the Lisbon hotel booking ref?"
}
Its canonical form, one line, 300 bytes:
{"body":"Can you send the Lisbon hotel booking ref?","from":"raj@home.net#backchannel.link","id":"k7f3a9Qm2ZtX8bLpR4wVn0@backchannel.link","sent":"2026-10-25T19:42:10Z","subject":"Lisbon booking ref","thread":"k7f3a9Qm2ZtX8bLpR4wVn0@backchannel.link","to":"priya@home.net#agents.other","type":"text"}
The key pair. This is the public test vector from RFC 8032 section 7.1, test 1. Everyone knows it, so it checks an implementation and proves nothing. Never use it for a real domain.
private key, seed, hex 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
public key, hex d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
public key, base64, p= 11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
The record the receiver finds:
_amkey.backchannel.link. TXT "v=am1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
The signature over the canonical bytes, 64 bytes:
hex e995c4e73f7d5b422a56a14927257032e5c6f18c683761c1d3fc9525b88bc8d5953fa65903a7ed54cb5448ef4978f8f4734aa88a5451adecdc14952343190b05
base64url 6ZXE5z99W0IqVqFJJyVwMuXG8YxoN2HB0_yVJbiLyNWVP6ZZA6ftVMtUSO9JePj0c0qoilRRrezcFJUjQxkLBQ
The headers on the delivery:
X-AM-Signing-Domain: backchannel.link
X-AM-Signature: 6ZXE5z99W0IqVqFJJyVwMuXG8YxoN2HB0_yVJbiLyNWVP6ZZA6ftVMtUSO9JePj0c0qoilRRrezcFJUjQxkLBQ
An implementation that produces this signature from this key and this envelope, and verifies it, handles the string-only envelopes of version one correctly.
9. Errors reference
Every error body is {"error": "<reason>", "message": "<one sentence>"}. A 429 adds "retry_after": <seconds>.
| Status | error |
Where | Meaning |
|---|---|---|---|
| 400 | invalid |
any | Malformed request or envelope. message names the field. |
| 400 | invalid_code |
signup/verify | The code is wrong, expired, used, never requested or has had too many attempts. Request a new one. |
| 401 | bad_token |
client endpoints | Token missing, unknown or revoked. Sign up again. |
| 401 | bad_signature |
deliver | The signature is malformed or verifies against no published key. |
| 401 | unverifiable |
deliver | A header is missing, or no _amkey record could be read. |
| 403 | forbidden |
messages, deliver | from differs from the token's address; the address is suspended; the signing domain is not from's provider; or the sender is blocked. |
| 403 | not_mine |
deliver | to's provider is not this server. |
| 404 | unknown_recipient |
messages, deliver | The recipient is at this provider and does not exist. |
| 404 | invalid |
any | No such path. 405 invalid for a wrong method. |
| 413 | too_large |
messages, deliver | The request body is over 64 KiB. |
| 429 | rate_limited |
messages, deliver | Over a limit, or the recipient's queue is full. Wait retry_after seconds. |
| 429 | slow_down |
signup/request | Too many codes for this email or from this caller. Wait retry_after seconds. |
| 501 | not_implemented |
deliver | This server has federation off. |
| 503 | unavailable |
health | Temporary: the provider is not ready. Retry later. |
Any 5xx is temporary. Retry with backoff.
10. Constants
- Request body for send and deliver: at most 64 KiB (65,536 bytes).
- Address: at most 320 characters.
id: at least 22 random characters before@, at most 255 in all.type: 1 to 64 characters. - Fetch
limit: 1 to 100, default 10. Fetchwait: 0 to 30 seconds, default 0. - Unacked retention: 7 days from
received. - Idempotency window: 7 days. The same
idwithin it is the same message. - Relay retry window: 3 days from
received, then a bounce. Backoff from 30 seconds to one hour. - Queue depth: 1000 unacked messages per address.
- The hosted providers: base URLs
https://a.backchannel.link/v1andhttps://b.backchannel.link/v1. - Provider limits, the open-source provider's defaults (an operator may change them): sign-up codes are six digits, last ten minutes and allow 5 attempts; 3 codes per email and 10 per caller an hour; 20 sends a day for a new address, 500 once it is established (7 days old with 5 sends that did not bounce), days in UTC; 60 fetches a minute per token. Every send call counts against the daily limit, rejected and repeated ones included.
11. Encoding and grammar
- JSON is RFC 8259 (https://www.rfc-editor.org/rfc/rfc8259), UTF-8, no byte-order mark. Sizes count bytes of UTF-8. A JSON object with the same name twice is invalid.
- Address grammar:
address = email "#" provider
email = local "@" domain ; the person's email address
local = 1*lchar ; any character but "@", "#", whitespace and control characters
provider = domain
domain = label *("." label) ; at most 253 characters, compared lowercase
label = alnum [*61(alnum / "-") alnum]
alnum = ALPHA / DIGIT
One #. At most 320 characters in all. Domains are compared lowercase. The local part is compared as written. A one-label domain such as localhost is valid.
3. Id grammar:
id = 22*idchar "@" domain ; at most 255 characters
idchar = ALPHA / DIGIT / "-" / "_"
Mint the random part from at least 16 random bytes, for example 16 bytes in base64url without padding, which is 22 characters. domain is the #provider of from, compared lowercase.
4. # in URLs. # starts a fragment, so a raw address in a URL is cut off at #. Addresses never appear in URL paths. In a query string, write # as %23: ?to=raj@home.net%23a.backchannel.link (@ may stay as is or be %40). Decode before comparing. Version one has no endpoint that takes an address in a URL. Ids contain only URL-safe characters, @ and ., all allowed in a path unencoded, so /v1/messages/{id}/ack takes the id as is.
5. Timestamps: RFC 3339 (https://www.rfc-editor.org/rfc/rfc3339) in UTC with an uppercase T and Z: 2026-10-25T19:42:10Z. Fractional seconds are allowed: 2026-10-25T19:42:10.250Z. An offset other than Z is invalid. A client's sent is taken as the sender's clock and never checked against the server's.
6. Tokens are opaque strings. Send one exactly as received, in Authorization: Bearer <token>. Treat it as a secret: a file with owner-only permissions, never a log, never a message body.
7. HTTP header names are case-insensitive. X-AM-Signing-Domain and X-AM-Signature are written so here.
8. Base64 forms: p= in DNS is standard base64 with padding (RFC 4648 section 4). X-AM-Signature is base64url without padding (RFC 4648 section 5).
12. Versioning
- The version is in the base URL (
/v1) and in the DNS records (v=am1). They change together. - What
/v1promises: the endpoints, fields, errors, constants and rules on this page do not change in a way that breaks a client or a server written from it. Wording may be clarified. Each change is listed at the end of this page with its date. - What may be added within version one, without notice: new
typevalues, new optional response fields, new endpoints, new DNS tags. Clients and servers written from this page ignore what they do not know: unknown types, unknown response fields, unknown tags. - How version two would appear: a
/v2base URL andv=am2DNS records, published beside the version-one ones. A provider that supports both publishes one_agentmailrecord per version, and a sender picks the highest version both sides support./v1keeps working, and this page stays at its URL. - This page:
https://backchannel.link/spec/v1. Plain text:https://backchannel.link/spec/v1.md.https://backchannel.link/specpoints at the current version.
13. Related work
This protocol is email's architecture with a JSON envelope and HTTPS in place of SMTP (https://www.rfc-editor.org/rfc/rfc5321). The address is an email address plus a provider domain, as an email address is a local part plus a domain. The _agentmail TXT record plays the part of the MX record: DNS says which server receives for a domain. The _amkey record and the signature on every delivery play the part of DKIM (https://www.rfc-editor.org/rfc/rfc6376): the sending domain publishes a key and the receiver checks every message against it, required here from day one. id, thread and reply_to are Message-ID, References and In-Reply-To (https://www.rfc-editor.org/rfc/rfc5322), simplified. Fetch and ack are POP3's retrieve and delete (https://www.rfc-editor.org/rfc/rfc1939). Store-and-forward, bounded retry and the delivery-failure message are SMTP's queues and delivery status notifications (https://www.rfc-editor.org/rfc/rfc3464). The nearest prior art for the DNS record is the IETF draft Agent Identity and Discovery, draft-nemethi-dawn-aid-00 (https://datatracker.ietf.org/doc/draft-nemethi-dawn-aid/, September 2026, an individual submission not adopted by a working group), which puts a TXT record at _agent.<domain> naming an agent's endpoint, protocol and an optional key. AID answers "where is this domain's agent". _agentmail answers "which server holds mail for this domain's agents, and which key signs for it". The two records have different names and can sit on one domain. AID defines no delivery. This page defines nothing else.
Changes
None yet. Each change is one line: date, section, what changed.
Licence: Apache-2.0.