# EosNoti Integration Guide (for AI coding assistants)

> **You are an AI assistant reading this file to wire EosNoti push notifications into a developer's 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.
- Delivery goes to the user's phone through the **EosNoti mobile app** — **iPhone/iPad via native APNs** and **Android via native FCM**. You send once; EosNoti fans the message out to every device that user has connected and picks the right transport per device. EosNoti operates these push channels with its own Apple/Google credentials — **you never ship an app, handle device tokens, or write any push/service-worker code.**
- A user's identity in EosNoti is a **device** (running the EosNoti app) 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.
- **This is transparent to your integration.** Your backend does the exact same thing for every user — mint a pairing code (or subscribe-link) carrying their `external_id`, then send to `ext:<external_id>`. EosNoti's app handles device registration and the OS push-permission prompt.

## Prerequisites

**Developer, once (manual):**

1. Create an account and an **Application** in the EosNoti web console at `https://console.eosnoti.com`.
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.**

**End user, once:** install the **EosNoti app** — iOS from the App Store, Android from Google Play. Connecting a user (below) happens **inside that app**; a user with no app installed cannot receive pushes.

Set these for the integration:

```
EOSNOTI_API_BASE = https://api.eosnoti.com          # EosNoti hosted API
EOSNOTI_API_KEY  = <api key>                         # backend secret only
```

## Core concept: binding

```
developer's user (external_id)  ◀──bind──▶  EosNoti subscription (a device running the EosNoti app)
```

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

Binding is established through a **code that carries the `external_id`** — a **6-digit pairing code** (primary) or a subscribe-link code. The developer's backend mints the code; the user adds it in the **EosNoti app** (types it, scans its QR, or taps the subscribe-link, which opens the app), and the app registers the device, subscribes, **and binds the `external_id` automatically**. The developer writes no device or push code — only the backend code-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 codes, send messages): `Authorization: Bearer <EOSNOTI_API_KEY>`. This is the only authentication your integration needs.
- The device side (registration, subscription, OS push-permission prompt) is handled entirely by the **EosNoti app** when the user adds the code — 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`.

---

## Connect a user — 6-digit pairing code (primary)

The main way to bind a user. Your backend mints a short code carrying the `external_id`; the user types it into the EosNoti app (Forward), or the app shows a code and your backend redeems it (Reverse). 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 the EosNoti app

```
1. Backend   POST {EOSNOTI_API_BASE}/v1/pairing-codes        (Bearer api_key)  {"external_id":"user_123"}
             → 201 {"code":"AB7K9P","url":"https://www.eosnoti.com/s/AB7K9P","expires_at":"<ISO8601>"}
2. User      opens the EosNoti app → "코드 추가 → 코드 입력" and types AB7K9P
             (the app 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`.

#### The same code as a QR

`url` is the identical code as a deep link. Render **`url`** as the QR (never the bare 6 characters — a plain string means nothing to a phone's camera). Scanning it with the phone's ordinary camera opens the EosNoti app and redeems the code; if the app is not installed, the link lands on an install page that shows the same 6 characters to type. Scanning it inside the app ("코드 추가 → QR 스캔") works too.

**Nothing else changes.** One mint, one code, and the *same* `GET /v1/pairing-codes/{code}` poll from step 3 detects the connection however the user came in — typed, scanned, or tapped. Do not mint a subscribe-link for this; that is a different code with a different lifetime (below).

Use `url` as returned rather than building it yourself: the host that serves `/s/` is the one carrying the Universal Link / App Link association files, and it is not the API host.

⚠️ A pairing code expires in **10 minutes**, so a displayed QR goes stale. Show the remaining time and a "new code" button, and re-mint when the poll returns `expired`. If your users typically have to install the app first, consider the subscribe-link below (24h) instead.

### Reverse — the EosNoti app 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 app's "코드 추가 → 내 코드 보기" (Show my code) screen.

```
1. App       the EosNoti app 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, url, 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 an app-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).

### Confirm the binding worked

**Silent check (recommended)** — ask whether an `external_id` is subscribed, without sending anything and without spending quota:

```
GET {EOSNOTI_API_BASE}/v1/subscriptions/lookup?external_id=user_123   (Bearer api_key)
```

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

Returns `200` whether or not the user is subscribed (not-subscribed is **not** an error), always the same shape:

```json
{ "subscribed": true, "device_count": 2, "push_enabled": true, "platforms": ["ios","android"] }
```

Not subscribed comes back as `{ "subscribed": false, "device_count": 0, "push_enabled": false, "platforms": [] }`. `push_enabled` is `false` when the user is subscribed only on polling / token-less devices. `platforms` order is unspecified — treat it as a set.

For **Forward** pairing you can also poll `GET /v1/pairing-codes/{code}` until `redeemed` (above). And when you are **about to send anyway**, `POST /v1/messages` returns `queued_deliveries` (`>0` = bound) — but that *delivers* to subscribed users, so use `/v1/subscriptions/lookup` when you only want to check. 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 — subscribe-link (tap a link, or scan its QR)

When typing a code is awkward. Your backend mints a subscribe-link code carrying the `external_id` and gets back a `url`; you can deliver it two ways, with the same binding result — the app extracts the code, registers the device, and auto-binds `external_id`.

- **Tap the link** — send the `url` itself (SMS, messenger, email). On a phone with EosNoti installed the link opens the app (iOS Universal Link / Android App Link) and the subscription completes there. Without the app it falls back to a web page that shows the code plus install badges.
- **Scan its QR** — typically **cross-device**: the user signed up on desktop and scans the QR with their phone. ⚠️For a *pollable* QR use the pairing code's `url` instead (previous section).

### 1 — Backend mints a subscribe-link code

```
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://…/s/<code>","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}'
```

### 2a — Send the `url` and let the user tap it

Deliver `url` as-is. On a phone with EosNoti installed the tap opens the app and subscribes; otherwise the user lands on the fallback page for that link, which shows the code and the store badges. Nothing extra to build on your side.

### 2b — Or present the `url` as a QR

Encode `url` as a QR. The phone's ordinary camera opens it (the app takes over the link), and scanning it inside **EosNoti → "코드 추가" → "QR 스캔"** works as well. The app auto-binds `external_id` and prompts for push permission. The code is one-time and expires at `expires_at` — mint a fresh one per request. Confirm the same way as above (`queued_deliveries` on send).

⚠️ **This path has no status endpoint.** `POST /v1/subscribe-links` is the right choice when the link must outlive a dialog (24h default, 30d max) — but nothing polls it, so confirm with a send or with `/v1/subscriptions/lookup`. **If you want a QR your backend can poll, use the pairing code's `url` instead** (above): same QR experience, and `GET /v1/pairing-codes/{code}` tells you the moment it is redeemed.

**Three ways to connect a user, pick per surface:** 6-digit pairing code (type a code — primary) · subscribe-link (tap the link, or scan its QR — auto-binds `external_id`) · anonymous subscribe code (receive only, no `external_id`; shown in the console). All end up added inside the EosNoti app.

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

When the send consumes quota (`queued_deliveries > 0`) the `201` response also reports
your remaining **daily and monthly** quota, so you can self-throttle **before** hitting a `429`:

```json
{ "message_id": "...", "queued_deliveries": 2,
  "quota": { "limit": 100, "used": 42, "remaining": 58,
             "reset_at": "2026-07-08T15:00:00Z", "near_limit": false,
             "monthly": { "limit": 1000, "used": 812, "remaining": 188,
                          "reset_at": "2026-08-02T15:00:00Z", "near_limit": false } } }
```

The same values are on the response headers `X-Quota-Limit`, `X-Quota-Used`,
`X-Quota-Remaining`, `X-Quota-Reset` (daily) and `X-Quota-Month-Limit`,
`X-Quota-Month-Used`, `X-Quota-Month-Remaining`, `X-Quota-Month-Reset` (monthly).
`near_limit` is `true` once `remaining` drops to ≤20% of the corresponding `limit`.
A send that queues **0** deliveries (e.g. an unbound `ext:<id>`) consumes nothing and
omits the `quota` block/headers.

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": "...", "external_id": "user_123", "choice": "Approve", "body": null, "created_at": "<ISO8601>" } ] }
```

**Read status** — `deliveries.read` is how many recipients actually *opened* the message in the app (`sent` only means it reached APNs/FCM). For an `ext:<id>` send that's 0 or 1; for `broadcast`, a count. There is no read webhook — **poll** `GET /v1/messages/{id}`.

**Reply attribution** — each reply carries **`external_id`**: the id *you* bound to that replier, so you know **who answered what**. For `ask` to `broadcast` this turns an anonymous tally into per-user answers.

> ⚠️ **`external_id` is `null` when the replier subscribed without one.** Anonymous subscribe codes (receive-only) and any subscription you never bound an `external_id` to reply with `"external_id": null` — you get the choice/body but **cannot tell who it was**. If you need attributed replies (especially for `broadcast` asks), bind every user's `external_id` at subscribe time (pairing code or subscribe-link, which bind automatically). `ext:<external_id>` sends are always attributed, since the target is itself an `external_id`.

Targeting: `to: "ext:<external_id>"` for one user, `to: "broadcast"` for all subscribers. A message to `ext:<id>` fans out to **every** device that user has connected — iPhone/iPad (APNs) and Android (FCM) alike — as separate deliveries; `queued_deliveries` counts them all.
Fields: `title?`, `body` (required), `url?`, `image?`, `icon?`, `priority: normal|high`, `data?` (arbitrary JSON). `choices` and `expires_at` are **ask-only** (sending them with `notify` returns `invalid_choices` / `invalid_expires`).

> ⚠️ **Console-only fields — do not send from a backend.** Two request fields exist in
> the wire format but are reserved for the operator console / Manager app session path
> and are rejected on the API-key path: `scheduled_at` (schedule a send for later →
> `invalid_schedule`; also a **plan feature** — the console path returns `plan_required`
> with `feature: "scheduled_send"` on **Free**, available on **Standard and higher**) and
> `parent_message_id` (ask conversation follow-up turn → `invalid_parent`). Scheduling
> and conversation follow-ups are operator features (see below); your backend
> integration sends immediately and one message at a time.

### Ask conversations (threads)

A reply doesn't have to be the end. After a recipient answers an `ask`, the developer
(as operator) can **continue the exchange as a threaded conversation** in the
**EosNoti Manager app (macOS)**: send a follow-up question, get another reply, and
finally send a **closing message** that ends the thread — the recipient sees the whole
exchange as one messenger-style conversation in their app, and each reply is attributed
the same way as a normal ask reply.

What this means for your integration:

- **The API surface is unchanged.** Follow-up turns are sent from the Manager app, not
  from your backend (`parent_message_id` is console-only, above). Your code still sends
  the initial `ask` and reads replies via `GET /v1/messages/{id}` / `…/replies`.
- **Conversation continuation is a plan feature.** Every plan (including **Free**) can
  send the initial `ask`, receive the reply, and send a **closing message**. Sending
  *further question turns* — keeping the conversation going, without limit — is
  available on **Standard and higher** plans (turns still consume the normal delivery
  quota). On Free, the Manager app offers the closing message only.
- Each follow-up turn = 1 delivery against the daily/monthly quota; recipient replies
  are free.

> **`icon`** — an absolute HTTPS URL to a square PNG (≥ 192px). Rendered as the
> **sender avatar** on the notification — the circular image on the **left**
> (iOS communication-style / Android conversation notification) — and next to
> the message in the inbox. Omit it to fall back to the application avatar set
> in the console. If you don't host your own, reuse the EosNoti brand glyph
> (see **Branding** below).
>
> **`image`** — an absolute HTTPS URL of a **content image** for this message.
> Shown on the notification as the **right-side thumbnail** (iOS) / attached
> image (Android), and on the message card in the inbox. **One image per
> message.** Images travel only through this field — URLs written inside the
> `body` text are treated as plain text and never auto-rendered. Non-http(s)
> values are rejected (`invalid_image`); same rule applies to `icon`
> (`invalid_icon`). Roles at a glance: `icon` = who sent it (left avatar),
> `image` = what it's about (right/content).

---

## Frontend states to generate

The developer's own UI is minimal — everything after the user adds the code happens in the EosNoti app. Generate:

1. **Pairing-code display + polling** — mint a code from the backend (`POST /v1/pairing-codes` with the user's `external_id`), show the 6-digit `code` with a prompt like "Enter this in the EosNoti app → 코드 추가 → 코드 입력", **and its `url` as a QR** for users who would rather scan than type. Poll `GET /v1/pairing-codes/{code}` until `redeemed` — the one poll covers both — then show a success state.
2. **Subscribe-link (optional)** — for a link that must outlive the dialog (24h default): mint a subscribe-link and send its `url` for the user to tap on their phone (opens the app; falls back to an install page). Not pollable — see the caveat in its section.
3. **App-install prompt** — if the user doesn't have EosNoti yet, link to the **App Store (iOS)** and **Google Play (Android)** so they can install it before adding the code. Instead of writing your own install-and-subscribe walkthrough, link to the public recipient-facing guide: **https://www.eosnoti.com/subscribe** (Korean; covers installing the app, allowing notifications, and connecting via code / QR / link, plus troubleshooting).
4. **Expiry handling** — codes are one-time and time-boxed (pairing: 10 min; subscribe-link: `ttl`); mint a fresh one per request, and re-mint if a user reports an expired code.

The push-permission prompt, the success state, and the blocked-permission fallback are all shown by the EosNoti app, 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 from the **console host**
(not the API host) at `https://console.eosnoti.com/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/pairing-codes` | Bearer api_key **or** device_token | Mint a 6-digit pairing code (api_key→Forward w/ `external_id`, device→Reverse). Forward also returns `url` — the same code as a deep link, for the QR |
| `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/subscribe-links` | Bearer api_key | Mint a subscribe-link code (carries `external_id`; send the `url` to tap, or present it as a QR) |
| `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 |
| `GET /v1/subscriptions/lookup?external_id=` | Bearer api_key | Silently check if an `external_id` is subscribed (`subscribed`/`device_count`/`push_enabled`/`platforms`); no send, no quota |
| `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 device-side endpoints (registration, subscription, push permission) are called by the EosNoti app, 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` / `invalid_choices` / `invalid_expires` / `invalid_icon` / `invalid_image` (400), `invalid_schedule` / `invalid_parent` (400, console-only fields sent on the API-key path — see *Console-only fields* above), `not_found` (404), `wrong_direction` (409, pairing code redeemed by the wrong side), `gone` (410, expired/used link or pairing code), `rate_limited` (429, short-term burst — see **Limits**), `quota_exceeded` (429, daily **or monthly** quota — see **Limits**).

---

## Limits

EosNoti meters usage by **deliveries** (fan-out), not by API calls. One send to
`ext:<id>` with N connected devices, or a `broadcast` to N subscribers, counts as
**N deliveries** — `queued_deliveries` in the send response is exactly what was metered.

Every plan has a **daily and a monthly** delivery quota, summed across all of that
developer's applications:

| Plan | Daily | Monthly | Notes |
|---|---|---|---|
| **Free** | 100 | 1,000 | single-shot `ask` + closing message; no scheduled sends; unlimited ask conversations require Standard+ |
| Standard | 1,000 | 30,000 | unlimited ask conversation turns (within quota) + scheduled sends — *in preparation* |
| Professional | 5,000 | 150,000 | *in preparation* |
| Max | 50,000 | 1,500,000 | *in preparation* |

- The **daily** counter resets at **00:00 KST** (Asia/Seoul). The **monthly** counter
  resets at 00:00 KST on the developer's **signup day-of-month** (anchored billing
  period — e.g. signed up on the 12th → resets on the 12th; months shorter than the
  anchor clamp to their last day).
- Deliveries are counted **when queued**, so a delivery to a stale/expired device that
  ultimately fails still counts. Keep your bound devices current to avoid burning quota
  on unreachable targets.
- A **`broadcast`** counts one delivery per subscriber. If a single broadcast would push
  you past the remaining quota (daily **or** monthly) it is rejected **whole**
  (all-or-nothing — see below), so near a limit a large broadcast may not go through at
  all; check `remaining` first.

When either quota is exceeded the send is **rejected as a whole** — nothing is queued.
The `scope` field says which axis you hit (`daily` or `monthly`), and the matching
header set (`X-Quota-*` / `X-Quota-Month-*`) carries the numbers:

```
HTTP 429 Too Many Requests
Retry-After: <seconds until that scope resets>

{ "error": {
    "code": "quota_exceeded",
    "message": "daily delivery quota exceeded",
    "scope": "daily",
    "limit": 100, "used": 100, "remaining": 0,
    "reset_at": "2026-07-08T15:00:00Z"
} }
```

Two distinct 429s: `rate_limited` is a short-term burst guard (~600 messages per app
per 60s); `quota_exceeded` is the plan delivery quota above (daily or monthly, per
`scope`). Back off using the `Retry-After` header or `reset_at`.

---

## 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, mint a **pairing code** — `POST /v1/pairing-codes` with the user's `external_id` — show the 6-digit `code` with instructions to enter it in the EosNoti app, and poll `GET /v1/pairing-codes/{code}` until `redeemed`. Present the code's `url` as a QR alongside it for users who prefer scanning — the same poll covers both. Offer a **subscribe-link** only when the link must outlive the dialog (it has no status endpoint). Prompt the user to install EosNoti (App Store / Google Play) if they haven't.
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 the pairing poll or `queued_deliveries` on send; never call `/v1/console/*` from the backend.
6. Don't implement native push yourself — EosNoti's first-party apps handle it. The user connects by adding a pairing/subscribe **code in the EosNoti app**; a message to `ext:<id>` then reaches their phone over APNs (iOS) or FCM (Android). Your backend only ever mints codes and sends to `ext:<id>` — identical for every surface.
