# EosNoti Integration Guide (for AI coding assistants)

> **You are an AI assistant reading this file to wire EosNoti push notifications into a developer's web application.** EosNoti delivers push notifications to one individual user (not topics/broadcast-only). Your job: when a user signs up or logs in to the developer's app, connect that user to EosNoti so the developer's backend can push to that single person. Generate code that matches the developer's actual stack (language, framework). Everything below is language-agnostic with `curl` examples — translate to their stack.

## What EosNoti is

- An organization (the developer) sends push notifications to **individual users** of their own product.
- The notification surface in this guide is the **web**: notifications are delivered via the **Web Push** standard (VAPID) to the user's browser.
- A user's identity in EosNoti is a **device** plus an **`external_id`** that the developer assigns (typically their internal user id). Binding `external_id` to a subscription is what lets the developer target one person.
- **Native mobile delivery (iOS APNs / Android FCM) is not covered here** — it is a separate, future capability. This guide is web-only.

## Prerequisites (developer does this once, manually)

1. Create an account and an **Application** in the EosNoti web console (your EosNoti host, e.g. `http://localhost:3000` in local dev).
2. Copy the **API key** — shown once at creation (rotatable later in the console). Store it as a backend secret. **Never expose it to the browser.**

Set these for the integration:

```
EOSNOTI_API_BASE = https://<your-eosnoti-host>     # local dev default: http://localhost:8080
EOSNOTI_API_KEY  = <api key>                        # backend secret only
```

## Core concept: binding

```
developer's user (external_id)  ◀──bind──▶  EosNoti subscription (a browser/device)
```

Once bound, the developer's backend sends to that person with `to: "ext:<external_id>"`.

Binding is established through a **personalized subscribe link**: the developer's backend mints a link that carries the `external_id`; when the user opens it, EosNoti's hosted receiver registers the device, subscribes, **and binds the `external_id` automatically**. The developer writes no browser or service-worker code — only the backend link-mint and the send calls.

**Identity invariant (important):** a subscription is *one device's* line to *one app*, and at any moment it holds **at most one `external_id`**. Re-pairing the same device to a new `external_id` (e.g. a different account) **overwrites** the previous binding on that same subscription — it does not create a second one. Consequences:

- A `subscription_id` is **not** a stable handle for "a user." The same device shared across accounts (common in testing, or shared kiosks) reassigns its single subscription to whoever paired most recently.
- Therefore **unbind by `external_id`, never by a stored `subscription_id`** (see [Unbinding](#unbinding-disconnect-a-user)). Clearing by a stale `subscription_id` can wipe whoever currently owns that device.

## Authentication

- **Backend → EosNoti** (mint links, send messages): `Authorization: Bearer <EOSNOTI_API_KEY>`. This is the only authentication your integration needs.
- The browser side (device registration, subscription, Web Push permission) is handled entirely by **EosNoti's hosted receiver** when the user opens the link — you implement none of it.
- **Note:** endpoints under `/v1/console/*` are authenticated by the browser console **session cookie**, not the API key. Never call them from a backend — an API key gets `401`.

---

## Integration — personalized subscribe link + QR

The `external_id` is carried **inside the link**, so binding happens automatically when the user opens it. The developer's backend only mints the link; the EosNoti receiver does the rest. This works on the same device (link button) or across devices (QR — e.g. signed up on desktop, wants notifications on their phone).

### 1 — Backend mints a personalized link

```
POST {EOSNOTI_API_BASE}/v1/subscribe-links
Header: Authorization: Bearer {EOSNOTI_API_KEY}
Body: {"external_id":"user_123","ttl":86400}   # ttl seconds; omit/0 = 24h
→ {"url":"https://<receiver>/s/<code>","code":"...","expires_at":"<ISO8601>"}
```

```bash
curl -X POST "$EOSNOTI_API_BASE/v1/subscribe-links" \
  -H "Authorization: Bearer $EOSNOTI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"external_id":"user_123","ttl":86400}'
```

### 2 — Frontend presents the link

- **Same device:** render `url` as a button ("Enable notifications").
- **Other device:** render `url` as a **QR code** ("Scan with your phone").
- Opening the link lands the user on the EosNoti receiver, which registers their device, subscribes, **auto-binds `external_id`**, and prompts for push permission. No further developer code needed.

### 3 — (optional) Confirm the binding worked

There is **no API-key endpoint that reads subscriptions**, so confirm with a signal the API key can see — send a message and look at `queued_deliveries`:

```
POST {EOSNOTI_API_BASE}/v1/messages   (Bearer api_key)
Body: {"to":"ext:user_123","kind":"notify","body":"…"}
→ {"message_id":"…","queued_deliveries": <n>}
```

`queued_deliveries > 0` means the `external_id` is bound to at least one subscription (the user opened the link); `0` means not bound yet. You can also see bound subscribers visually in the console (application → Subscribers). Do **not** call `GET /v1/console/applications/{id}/subscribers` from a backend — it is console-session-only and returns `401` for an API key.

---

## Alternative — manual 6-digit pairing code

Use when a clickable link/QR is awkward (separate apps, two screens). The user types a short code instead of opening a link. Same binding result; the difference is only how the code travels. Codes are 6 chars (unambiguous A–Z/2–9), single-use, expire in 10 minutes. Both endpoints accept **either** an API key **or** a device token — the side that created the code polls; the other side redeems.

### Forward — your app shows the code, the user types it into EosNoti

```
1. Backend   POST {EOSNOTI_API_BASE}/v1/pairing-codes        (Bearer api_key)  {"external_id":"user_123"}
             → 201 {"code":"AB7K9P","expires_at":"<ISO8601>"}        # show the code, start polling
2. User      opens the EosNoti receiver → "코드 추가 → 코드 입력" and types AB7K9P
             (the receiver redeems it on the device side)
3. Backend   GET  {EOSNOTI_API_BASE}/v1/pairing-codes/AB7K9P  (Bearer api_key)
             → 200 {"status":"pending"|"redeemed"|"expired","subscription_id":<str|null>}
```

Poll step 3 every ~2.5s until `status` is `redeemed` (bound) or `expired`.

### Reverse — EosNoti shows the code, the user types it into your app

Your app needs a small input field where the user types the code. The user gets it from the EosNoti receiver's "코드 추가 → 내 코드 보기" screen.

```
1. Receiver  EosNoti's receiver mints a code (device side) and displays it, e.g. "X9Q3MR"
2. Backend   POST {EOSNOTI_API_BASE}/v1/pairing-codes/X9Q3MR/redeem  (Bearer api_key)  {"external_id":"user_123"}
             → 200 {"subscription_id":"…","external_id":"user_123"}   # bound immediately
```

```bash
curl -X POST "$EOSNOTI_API_BASE/v1/pairing-codes" \
  -H "Authorization: Bearer $EOSNOTI_API_KEY" -H "Content-Type: application/json" \
  -d '{"external_id":"user_123"}'                       # Forward: returns {code, expires_at}

curl -X POST "$EOSNOTI_API_BASE/v1/pairing-codes/X9Q3MR/redeem" \
  -H "Authorization: Bearer $EOSNOTI_API_KEY" -H "Content-Type: application/json" \
  -d '{"external_id":"user_123"}'                       # Reverse: redeem a receiver-shown code
```

Pairing-specific errors: `wrong_direction` (409, code redeemed by the wrong side), `gone` (410, expired/used), `rate_limited` (429, too many redeem attempts).

**Three ways to connect a user, pick per surface:** anonymous subscribe code (receive only) · personalized link/QR (one tap) · manual pairing code (type a 6-digit code).

## Sending notifications (backend → a bound user)

```
POST {EOSNOTI_API_BASE}/v1/messages
Header: Authorization: Bearer {EOSNOTI_API_KEY}
```

Notify (one-way):

```json
{ "to": "ext:user_123", "kind": "notify", "title": "Order shipped",
  "body": "Your order #1234 is on the way.", "url": "https://app.example/orders/1234",
  "priority": "high" }
```

Ask (request a reply / choice):

```json
{ "to": "ext:user_123", "kind": "ask", "body": "Approve this login?",
  "choices": ["Approve","Deny"], "expires_at": "2026-07-01T00:00:00Z" }
```

Response: `{ "message_id": "...", "queued_deliveries": <n> }`.

Read status / collect replies:

```
GET {EOSNOTI_API_BASE}/v1/messages/{id}                      # delivery + read counts, replies
GET {EOSNOTI_API_BASE}/v1/messages/{id}/replies?wait=30      # long-poll up to 30s for ask replies
```

`GET /v1/messages/{id}` returns:

```json
{ "id": "...", "target": "ext:user_123", "kind": "notify", "priority": "normal",
  "deliveries": { "queued": 0, "sent": 1, "failed": 0, "read": 0 },
  "replies": [ { "id": "...", "choice": "Approve", "body": null, "created_at": "<ISO8601>" } ] }
```

Targeting: `to: "ext:<external_id>"` for one user, `to: "broadcast"` for all subscribers.
Fields: `title?`, `body` (required), `url?`, `image?`, `icon?`, `priority: normal|high`, `data?` (arbitrary JSON). `choices` and `expires_at` are **ask-only** (sending them with `notify` is an error).

> **`icon`** — an absolute HTTPS URL to a square PNG (≥ 192px) shown on the
> notification. If you don't host your own, reuse the EosNoti brand glyph
> (see **Branding** below).

---

## Frontend states to generate

In this link-based flow the developer's own UI is minimal — everything after the click happens on the EosNoti receiver. Generate:

1. **Enable button (same device)** — render the minted `url` as "🔔 Enable notifications". Mint the link from the backend at signup or on demand.
2. **QR (other device)** — render the `url` as a QR with "📱 Get notifications on your phone". Note the link expires (`expires_at`).
3. **Expiry handling** — links are one-time and time-boxed; mint a fresh one per request, and re-mint if a user reports an expired link.

The push-permission prompt, the success state, and the blocked-permission fallback are all shown by the EosNoti receiver, not your page.

---

## Unbinding (disconnect a user)

When a user logs out / disconnects notifications, unbind **by `external_id`** so the operation targets that user and no one else:

```
DELETE {EOSNOTI_API_BASE}/v1/subscriptions/external?external_id=user_123   (Bearer api_key)
→ 200 {"status":"cleared","cleared":<n>}
```

```bash
curl -X DELETE "$EOSNOTI_API_BASE/v1/subscriptions/external?external_id=user_123" \
  -H "Authorization: Bearer $EOSNOTI_API_KEY"
```

- Clears the binding on **every** subscription of your app currently bound to that `external_id` (a user may have several devices) and returns how many were cleared.
- **Idempotent:** clearing a user with no current bindings returns `{"cleared":0}` (still `200`) — safe to call on every logout.
- **Isolated:** it only touches rows whose `external_id` matches, so it can never disconnect a different user who was later paired on the same device.

There is also a lower-level, **subscription-scoped** pair (advanced; prefer the `external_id` form above):

```
PUT    {EOSNOTI_API_BASE}/v1/subscriptions/{id}/external   (Bearer api_key)  {"external_id":"user_123"}   # bind/rebind one subscription
DELETE {EOSNOTI_API_BASE}/v1/subscriptions/{id}/external   (Bearer api_key)                                # clear one subscription's binding
```

⚠️ The `{id}`-scoped `DELETE` clears **whatever** `external_id` that subscription holds right now. If the device was re-paired to another user since you stored `{id}`, this clears *that* user. Use it only when you are certain the subscription still belongs to the intended user; otherwise use the `external_id` form.

---

## Branding — representing the EosNoti channel

EosNoti's brand glyph is served publicly with no auth at
`{EOSNOTI_HOST}/icon.png` — a 512×512 square PNG. The **same asset** covers two
distinct uses:

- **In your own UI** — to label the EosNoti channel (a notifications-settings
  row, a "Connect EosNoti" button, a channel toggle), render it as a small
  image. For production, bundle a copy in your assets rather than hotlinking.
- **On the push itself** — pass the same URL as the message `icon` field
  (see *Sending notifications*) so notifications show the glyph by default.

These are different uses of one icon, not two different icons.

---

## API reference (used in this guide)

| Method & path | Auth | Purpose |
|---|---|---|
| `POST /v1/subscribe-links` | Bearer api_key | Mint a personalized subscribe link (carries `external_id`) |
| `POST /v1/pairing-codes` | Bearer api_key **or** device_token | Mint a 6-digit pairing code (api_key→Forward w/ `external_id`, device→Reverse) |
| `POST /v1/pairing-codes/{code}/redeem` | Bearer device_token **or** api_key | Redeem a pairing code (binds the device ↔ `external_id`) |
| `GET /v1/pairing-codes/{code}` | Bearer api_key **or** device_token | Poll pairing status (`pending`/`redeemed`/`expired`); creator only |
| `POST /v1/messages` | Bearer api_key | Send a notification (`to: ext:… \| broadcast`) |
| `GET /v1/messages/{id}` | Bearer api_key | Message status, delivery/read counts, replies |
| `GET /v1/messages/{id}/replies?wait=` | Bearer api_key | Long-poll for `ask` replies |
| `DELETE /v1/subscriptions/external?external_id=` | Bearer api_key | Unbind a user across all their subscriptions (idempotent; **use for logout**) |
| `PUT /v1/subscriptions/{id}/external` | Bearer api_key | Bind/rebind one subscription to an `external_id` (advanced) |
| `DELETE /v1/subscriptions/{id}/external` | Bearer api_key | Clear one subscription's binding (advanced; clears its current owner) |

All developer-facing endpoints authenticate with the API key. The browser-side endpoints (device registration, subscription, Web Push) are called by the EosNoti receiver, not by your code.

### Error format

All non-2xx responses are JSON with a **nested `error` object**:

```json
{ "error": { "code": "<code>", "message": "<human readable>" } }
```

Common codes: `unauthorized` (401), `invalid_target` / `invalid_kind` / `invalid_priority` / `invalid_body` / `invalid_external_id` (400), `not_found` (404), `wrong_direction` (409, pairing code redeemed by the wrong side), `gone` (410, expired/used link or pairing code), `rate_limited` (429). Send rate limit is ~600 messages per app per 60s.

---

## Implementation checklist (for you, the AI)

1. Place the API key as a backend secret, never in client code.
2. At the user-registration (or "enable notifications") point, call `POST /v1/subscribe-links` with the user's `external_id`, then present the returned `url` as a button (same device) or QR (other device).
3. Add a backend send call where the app needs to notify a user (`to: "ext:<their id>"`).
4. Use `external_id` = the developer's existing internal user id, so targeting needs no extra mapping.
5. (optional) Confirm a binding via `queued_deliveries` on send; never call `/v1/console/*` from the backend.
6. Do not implement native (APNs/FCM) — it is out of scope here.
