> Source: https://nexusatlas.dev/docs/2.0.0/presence-api — Nexus Atlas developer documentation (Reference). Converted from the HTML page; the page is canonical. This is the 2.0.0 release line; the current line is 3.0.0 (https://nexusatlas.dev/docs/presence-api).

# Presence API

The relay directory stores one thing: sealed presence records, each saying "I am on these relays" to exactly one peer, under an id only that pair of nodes can compute. The server holds opaque blobs under opaque ids — it never learns which relay a key is on, nor which peer a record is for. `atlasd` speaks this API itself when [the directory](https://nexusatlas.dev/guides/relay-directory) is on; this page is the contract for anyone implementing or auditing it.

> **Base URL.** `atlasd` uses `https://nexusatlas.net/api`, overridden by `[traversal] directory_url`, else by `relay_map_url`. Every path below is relative to it, so the record endpoint is `https://nexusatlas.net/api/v1/presence`. Every response carries `Cache-Control: no-store`.

## The record

Node A, holding relay sessions, posts one record for each of its static peers B. All keys below are raw 32-byte values; the JSON carries standard, padded, canonical base64.

### id

```
id = lowercase hex( SHA-256( "atlas-presence-v1" ‖ B_pub ‖ A_pub ) )

B = recipient, A = sender — both 32-byte raw X25519 transport keys
64 ASCII characters; id(A→B) ≠ id(B→A)
```

Only someone who knows both public keys can compute an id, so a record cannot be found by one key alone.

### blob

```
blob = E_pub(32) ‖ ChaCha20-Poly1305(key, nonce = 0^12, aad = A_pub ‖ B_pub)(plaintext)

key  = HKDF-SHA256( ikm  = X25519(e, B_pub) ‖ X25519(A_static, B_pub),
                    salt = "nexus-atlas-presence-v1",
                    info = id as its 64 ASCII hex bytes,
                    L    = 32 )

B opens with ikm = X25519(B_static, E_pub) ‖ X25519(B_static, A_pub)
```

`e` is a fresh ephemeral X25519 key per record. The static-static half of the key material means only the holder of A's private key (or B) can produce a blob B accepts — knowing both public keys is not enough. The raw blob is at most 2048 bytes.

```
plaintext = {"v":1,
             "relays":[{"server":"ip:port","pubkey":"<b64>","region":"…"}],
             "since":<unix>,
             "ttl":<secs>}
            right-padded with ASCII spaces to 1024 bytes (2000 if it does not fit)
```

The padding makes every ordinary blob 1072 bytes long (32 + 1024 + 16), so its length does not tell the server how many relays it names. A record lists at most 8 relays, and only relays the sender holds an established session with.

### from and sig

```
from = base64( Ed25519 verifying key, 32 bytes )

sig  = base64( Ed25519-sign( "nexus-atlas-presence-sig-v1" 0x00   28 bytes
                             ‖ id                            64 ASCII bytes, lowercase hex
                             ‖ blob                          the raw decoded bytes
                             ‖ exp                           8 bytes, u64 big-endian ) )

Plain Ed25519 (RFC 8032, no pre-hash); 64-byte signature
```

The Ed25519 key is derived from the node's X25519 transport private key — HKDF-SHA256 with salt `nexus-atlas-presence` and info `nexus-atlas-presence-v1/ed25519`, expanded to a 32-byte seed — never the X25519 scalar itself. It needs no storage, and its public half is unlinkable to the transport key and to the node's fleet-record signing key, so `from` names nothing the server could join on. The server verifies `sig` against `from` alone; authenticity towards B comes from the static-static seal.

### exp

Unix seconds, an integer. The server accepts `now < exp ≤ now + 600`, with a 30-second allowance for clock skew; `atlasd` sends `now + 590`.

## Endpoints

### POST `/v1/presence`
Publish one record. Authenticated by the signature in the body; no account or token.

| Field | Type | Rule |
|-------|------|------|
| id | string | Exactly 64 lowercase hex characters. |
| blob | string | Canonical padded base64 of at most 2048 bytes. Never decrypted by the server. |
| from | string | Canonical padded base64 of a 32-byte Ed25519 verifying key. |
| sig | string | Canonical padded base64 of a 64-byte Ed25519 signature over the signed bytes. |
| exp | integer | Unix seconds, now < exp ≤ now + 600 (+30 s skew). |

Request

```
curl -X POST -H "Content-Type: application/json" \
     -d '{"id":"3f6c…e1a0","blob":"q1Zp…AA==","from":"Lr8n…Yk=","sig":"0x9d…Bg==","exp":1792252190}' \
     https://nexusatlas.net/api/v1/presence
```

Response

```
{ "ok": true }
```

| Status | Body | Meaning |
|--------|------|---------|
| 200 | {"ok":true} | Stored. |
| 400 | {"ok":false,"error":…} | invalid_json, invalid_body, invalid_id, invalid_blob, blob_too_large, invalid_from, invalid_sig, invalid_exp, exp_too_far, expired, bad_signature. |
| 409 | owned | The unexpired record under this id belongs to another poster key. It can be claimed once it expires. |
| 409 | stale | Same poster, but an older exp than the stored record — a replayed earlier post cannot roll a fresher record back. An equal exp is an idempotent retry and succeeds. |
| 413 | body_too_large | Body over 8 KiB. |
| 429 | rate_limited | Per poster key or per IP; see rate limits. |
| 500 | store_failed | The write did not complete; post again. |

`atlasd` counts every refusal in `traversal.directory.publish_failures` and posts again in the next round, 60 s later.

### POST `/v1/presence/lookup`
Fetch one record by id, with the id in the body so it never appears in a request path — and so never next to the caller's address in a request log. This is the lookup `atlasd` uses. Body: `{"id": "<64 lowercase hex>"}`.

Request

```
curl -X POST -H "Content-Type: application/json" \
     -d '{"id":"3f6c…e1a0"}' \
     https://nexusatlas.net/api/v1/presence/lookup
```

Response

```
{ "blob": "q1Zp…AA==", "exp": 1792252190 }
```

| Status | Body | Meaning |
|--------|------|---------|
| 200 | {"blob","exp"} | The blob exactly as posted, and its expiry in unix seconds. |
| 404 | {"error":"not_found"} | No record, or it has expired. |
| 400 | {"error":…} | invalid_json, invalid_body (not a JSON object), invalid_id, query_not_allowed (a query string was sent). |
| 413 | body_too_large | Body over 1 KiB. |
| 429 | rate_limited | Per IP, the same bucket as the GET below. |
| 500 | read_failed | The read did not complete; look up again. |

A `GET` on this path answers `405`. When a control plane answers the lookup POST itself with `405`, `atlasd` falls back to the GET below.

### GET `/v1/presence/{id}`
The same answer, validation and headers as the lookup, with the id in the path: `200 {"blob","exp"}`, `404 not_found`, `400 invalid_id` or `query_not_allowed`, `429 rate_limited`. There is no list, search or filter of any kind — the id must be exact.

## Rate limits

| Route | Key | Limit |
|-------|-----|-------|
| POST /v1/presence | poster key (from) | 60 per minute |
| POST /v1/presence | caller IP | 240 per minute |
| lookup and GET together | caller IP | 240 per minute |

Each server instance counts in fixed one-minute windows, keyed by a SHA-256 of the poster key or the IP that is held in memory for one window and never logged or persisted. A node posts one record per static peer every 5 minutes and on each relay change, and looks a missing peer up at most once a minute, which leaves room for fleets of a few dozen peers per node.

## What the server stores — and what it does not

A record is stored as `presence/{id}` with exactly four fields:

| Field | Content |
|-------|---------|
| blob | The sealed blob as posted. The server never opens it, so no relay name, address or region is visible to it. |
| exp | The expiry, as a timestamp. A time-to-live policy deletes expired records; reads treat exp ≤ now as absent regardless. |
| updatedAt | When the record was last written. |
| fromHash | HMAC-SHA256 of from under a server-held secret. It binds a live record to its first poster without storing the key, and cannot be tested against candidate keys without the secret. |

Not stored, and not logged: the poster key `from`, the signature `sig`, the caller's IP address, the peer the record is for, and the relays it names. There is no endpoint that lists, searches or enumerates records; a record is reachable only by an id that takes both nodes' keys to compute.

The same rule covers relays: a relay's heartbeat names its client sessions only by a salted per-run hash, never by key — see [heartbeat and map additions](https://nexusatlas.dev/docs/2.0.0/relay-protocol#heartbeat). Together, the control plane holds no record of which key is on which relay.
