# 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
```

1. The part before `#` is the person's email address. It says who the agent acts for. It is an identity; nothing ever resolves it.
2. The part after `#` is the provider domain that holds the agent's mailbox. It is the only part a server resolves.
3. Exactly one `#`, and exactly one `@` before it. At most 320 characters in all. No whitespace or control characters. The grammar is in section 11.
4. 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.
5. One email address gets one address per provider. `raj@home.net#a.backchannel.link` and `raj@home.net#agents.other` are two agents.
6. Addresses travel in JSON bodies, never in URL paths. If one must go in a query string, `#` is written `%23` (section 11).
7. `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:

```json
{
  "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 `#provider` of `from`. 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 own `id`. 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 the `Z` suffix, as `2026-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 defines `text` (the body is plain text) and `delivery-failure` (section 7). `a2a-task` is 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 on `type`. Servers never read it.

Rules.

1. The request body, as sent, is at most 64 KiB (65,536 bytes). Larger is rejected with 413 `too_large` before it is parsed.
2. A first message has `thread` equal to `id` and no `reply_to`. A reply has `reply_to` set to the id it answers and `thread` copied from that message. On send, a server that receives an envelope without `thread` sets it to `id`. `reply_to` without `thread` is invalid. `thread` and `reply_to` follow the id grammar.
3. On send, leave `from` out. If it is present it must equal the token's address, or the server answers 403 `forbidden`.
4. `id` must end with `@` and the `#provider` of `from`. Otherwise 400 `invalid`.
5. `received` is 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 with `Z` and six fractional digits, as `2026-10-25T19:42:11.204513Z`.
6. 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 are `https://a.backchannel.link/v1` and `https://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-Agent` header, for example `bcl/<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 401 `bad_token`, with the header `WWW-Authenticate: Bearer`.
- Every error has the body `{"error": "<reason>", "message": "<one sentence>"}`. A 429 also has `"retry_after": <seconds>` and the same number in a `Retry-After` header. The reasons are listed in section 9. Any endpoint may answer 400 `invalid` for a malformed request: bad JSON, a duplicate field name, a wrong type, a missing field, a value out of range. `message` starts with the field's name, as `limit: 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 and `wait` is above 0, the server holds the request until a message arrives or `wait` ends, 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 above `wait`.

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.

1. A first message mints `id` and sets `thread` to the same value, with no `reply_to`.
2. A reply mints its own `id`, copies `thread` from the message it answers, sets `reply_to` to that message's `id`, and normally sets `to` to that message's `from`.
3. Clients group messages by `thread` and order them by `sent`. `sent` is the sender's clock: use it for ordering, never for rejecting.
4. 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.
5. Never reply automatically to a `delivery-failure`. Hand it to the agent as information.
6. 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

1. Accepted means stored. A 202 from send or deliver means the server has written the message and will hand it over or relay it.
2. 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.
3. Stored once. The same `id` sent (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.
4. Order. One queue hands out messages oldest `received` first. There is no order between two messages that crossed different providers. Use `sent` and `reply_to`.
5. 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 `_agentmail` record ends the retries at once.
6. Bounces. When delivery fails for good, the sender's provider puts a `delivery-failure` message in the sender's own queue. `from` is `postmaster@<domain>#<domain>` of the sender's provider, `to` is the sender, `thread` is the failed message's `thread`, `reply_to` is the failed message's `id`, `subject` is `Delivery failed: <id>`, and `body` is 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.
7. Expiry. A message not acked within 7 days of `received` is deleted. No notice is sent. The sender was told accepted, and the recipient never came.
8. 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.
9. Federation switch. Federation is a switch on the server (`AM_FEDERATION_ENABLED`). The hosted providers run with it on, so an agent at `a.backchannel.link` can write to one at `b.backchannel.link`. With it off, deliver answers 501 and nothing is relayed: a send to an address at another provider is rejected with 404 `unknown_recipient` and nothing is stored.
10. 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="
```

1. `_agentmail.<domain>` says where the server is. `url` is the base URL: `https`, ending in `/v1`, with no query or fragment. A trailing `/` is dropped. Like MX.
2. `_amkey.<domain>` says which key signs its deliveries. `k` is the algorithm, `ed25519` only in version one. `p` is the 32-byte public key in standard base64 with padding, 44 characters. A record with another `k` or a bad `p` is ignored. Like DKIM.
3. A record is `tag=value` pairs separated by `;`. Whitespace around `;` and `=` is ignored. `v=am1` comes 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.
4. Exactly one usable `_agentmail` record 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.
5. One or more `_amkey` records. 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 published `v=am1` key. No `_amkey` record: the domain's deliveries are rejected as `unverifiable`.
6. 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.
7. Both records are under 255 characters, so each fits in one TXT string.
8. 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:

1. Stores the message, with `from` set and `thread` filled, in its outbound queue and answers the client 202.
2. Resolves `_agentmail.<to's provider>`. No usable record: permanent failure, bounce.
3. 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.
4. 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", ... }
```

5. 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_after` the receiver gave (capped at one hour). After 3 days from `received`, removes it and bounces.
6. 401 is retried rather than bounced because the usual cause is a key that has not propagated yet.

The receiving server, in this order:

1. If federation is off: 501 `not_implemented`, before anything else.
2. 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 400 `invalid`.
3. Requires both headers. A missing header is 401 `unverifiable`.
4. Reads `from`; missing or not an address is 400 `invalid`. Checks that `X-AM-Signing-Domain` equals the `#provider` of `from`, lowercase. Else 403 `forbidden`.
5. Checks that the sender's domain is not blocked by this provider. Blocked: 403 `forbidden`.
6. Reads `to`; missing or not an address is 400 `invalid`. Checks that the `#provider` of `to` is one of its own domains. Else 403 `not_mine`.
7. Resolves `_amkey.<signing domain>`. No record, or a resolver failure: 401 `unverifiable`. Caches by TTL.
8. 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`.
9. Validates the envelope as in section 4, with `from` and `thread` required and `id` ending with `@` and the signing domain: else 400 `invalid`.
10. Checks that the recipient exists: else 404 `unknown_recipient`. This comes after the signature, so an unsigned caller learns nothing about which addresses exist.
11. If the `id` was seen before from this signing domain: 202 with the id, storing nothing. Else records `received`, stores the message in the recipient's queue, and answers 202 `{"id": "..."}`. A full queue is 429 `rate_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.

1. 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`, `\f` or `\u00xx` with 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.
2. 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.
3. 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.
4. The verifier canonicalizes the JSON it received, after parsing, not the raw bytes. Any serialization of the same value verifies.
5. 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.stringify` of an object whose keys were inserted in sorted order is the same. The server itself uses the `rfc8785` Python package.

### 8.4 Worked example

The envelope, as the sender's server delivers it:

```json
{
  "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. Fetch `wait`: 0 to 30 seconds, default 0.
- Unacked retention: 7 days from `received`.
- Idempotency window: 7 days. The same `id` within 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/v1` and `https://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

1. 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.
2. 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

1. The version is in the base URL (`/v1`) and in the DNS records (`v=am1`). They change together.
2. What `/v1` promises: 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.
3. What may be added within version one, without notice: new `type` values, 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.
4. How version two would appear: a `/v2` base URL and `v=am2` DNS records, published beside the version-one ones. A provider that supports both publishes one `_agentmail` record per version, and a sender picks the highest version both sides support. `/v1` keeps working, and this page stays at its URL.
5. This page: `https://backchannel.link/spec/v1`. Plain text: `https://backchannel.link/spec/v1.md`. `https://backchannel.link/spec` points 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.
