# Care X Public API — Guide for AI Coding Agents

> Tài liệu này dành cho AI agent / lập trình viên tích hợp; bản cho người đọc: /developers

Care X sends Zalo ZBS template messages (tin ZBS) on behalf of a business, through the business's own Zalo App,
Official Account (OA) and ZBS account. This guide covers the **public `/v1` API only**, which is authenticated with
API keys. Company integrations use one permanent profile key for account creation, Zalo configuration, templates and
message sending under `/v1/accounts`. Integrations run on member-company servers. It is written from the source code.

- Base URL (production): `https://api.carex.io.vn`. All paths below are relative to it and start with `/v1`.
- OpenAPI 3.1 document (generated from the same Zod schemas): `https://api.carex.io.vn/v1/openapi.json`. Every `/v1`
  route declares its request schema, its success response schema and the error envelope for its error statuses. The
  response schemas are also the serializers, so the document always matches what the API returns.
- `/dashboard-api/*` and `/admin-api/*` also exist. They use browser sessions (cookies, CSRF), are not for integrations,
  and only the profile-key lifecycle is documented here to explain initial setup; integration calls use `/v1`.

---

## 1. Overview and key concepts

Account is the only tenant. New company integrations use one profile key and the account path below;
legacy account-key compatibility is isolated in §10.

| Concept          | Integration contract                                                                                                                                                                                                                                                                    |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Profile key**  | Permanent server credential belonging to a profile. Full current business permissions; no expiry or business-scope picker. Account range is `all` or `selected`.                                                                                                                        |
| **Account**      | One Zalo OA and its own isolated contacts, templates, notifications and settings. Select it through `/v1/accounts/{accountId}`; access still requires current membership/role and key range.                                                                                            |
| **Environment**  | Business endpoints require `?environment=development` or `?environment=live`. Notifications, batches, events and reports are separated by account and environment; contacts/templates are shared within an account. The profile key does not decide the environment.                    |
| `development`    | Real OA token in Zalo development mode. Only Zalo App/OA admins can receive, by phone; Zalo rejects other recipients with `-127`. Uses the separate development wallet (`-126` if empty), requires connected OA but not verified live billing. Errors here do not suspend live sending. |
| `live`           | Requires a ready account: OA connected, business ZBS verified, connection usable and live sending not suspended. Otherwise 409 `ACCOUNT_NOT_READY` or `BILLING_NOT_VERIFIED`.                                                                                                           |
| **Template**     | Business-owned Zalo template, approved and synced with status `ENABLE`. Send its Zalo `template_id` and exact parameter names; no aliases.                                                                                                                                              |
| **Contact**      | Account-local recipient with phone, UID or phone-hash identity. Stable customer ID independent of its phone/hash. Send `contact_id` alone or with an explicit phone/phone_hash.                                                                                                         |
| **Notification** | One template message for one recipient; API accepts it with 202, worker dispatches, events/webhooks report progress.                                                                                                                                                                    |
| **Batch**        | Up to 1,000 independent notifications grouped for progress and reports.                                                                                                                                                                                                                 |

Care X never re-sends a message whose dispatch outcome is unknown (`dispatch_unknown`). Your integration must not do it
automatically either (see §9).

---

## 2. Authentication and company setup

Keep `Authorization: Bearer $PROFILE_API_KEY` on the company server. The company's website calls its own server.
No account key is needed for the workflow below. Never send keys as query parameters or embed them in browser code.
Selector headers (`X-Account-Id`, `X-CareX-Account`, `X-CareX-Account-Id`, `X-OA-Id`, `X-Company-Id`) are rejected
with 400 `CONTEXT_SELECTOR_REJECTED`; strict request bodies reject unknown `account_id`/`oa_id`/`callback_url` fields.

### 2.1 One profile key for company integrations

Care X admins provision a company profile. The company signs in at Dashboard → account list (`/accounts`) →
**API access của profile**, confirms its password (within 10 minutes), enters a key name and copies the key once.
The creation body is `{name,account_access?}`; name is 2–80 characters, expiry/business-scope overrides are rejected. New keys have no expiry;
existing profile key expiry was removed by migration. Pause/resume/revoke are on the same page. Keep the key on your
server; your website calls your server. Rotation is creating a replacement and revoking the old key.

The customer dashboard's **Profile của tôi** (`/profile`) supports display-name updates, password changes using the
current password, sign-out and revoking other login sessions. Password changes revoke other sessions. These actions
do not revoke profile API keys; pause/revoke keys separately at `/accounts` when needed.

Authenticate with `Authorization: Bearer $PROFILE_API_KEY`; format `carex_profile_<16 chars>_<43 chars>`.
Keys have all of the **current profile's permissions**, without a key-scope picker. Every call checks key status/range,
active/verified profile and active membership/role on the selected account. A profile can access multiple accounts;
each remains its own tenant with one OA and isolated data. Access also requires the account to be inside the key's account range (below). This grants no Care X admin powers. Account keys cannot
use these profile routes. Browser cookies never override key identity. `X-Account-Id`, `X-OA-Id` and other selector
headers remain rejected; select the account only in the path. Unknown body fields such as `account_id` are rejected.

Unknown/inaccessible/closed/suspended accounts return `404 NOT_FOUND`, insufficient role returns `403 FORBIDDEN`,
paused/revoked keys or disabled profiles return `401 UNAUTHENTICATED`, unverified profiles return `403 EMAIL_NOT_VERIFIED`.
Rate limit is 60 requests/minute per profile key across this API's accounts and operations. Responses use `Cache-Control: no-store`.
Profile keys cannot issue other profile keys or call `/admin-api`. The old `/v1/profile/accounts` provisioning prefix
is a compatibility alias; there are no profile API routes for issuing account keys.

#### Key account range

Choose **Tất cả account của profile** or **Account được chọn** at creation; existing keys retain `all`.
`account_access` is one of:

```json
{ "mode": "all" }
```

```json
{ "mode": "selected", "account_ids": ["acc_…", "acc_…"] }
```

`all` is the default and covers accounts currently accessible to the profile and those it creates/joins later.
`selected` requires 1–1,000 unique public account IDs with current active membership on draft/active accounts.
Selecting another profile's account returns 404; an empty/duplicate list or unknown fields returns 422.
The selection never grants or freezes a role: every request checks current membership/role as well as the key's range.
Admin → Viewer immediately removes write powers for new requests; revoked membership removes access even if the ID remains selected.

List/create metadata includes `account_access`, without reading back the secret. Change range in Dashboard via
`PATCH /dashboard-api/me/api-keys/{keyId}` with `{account_access:…}`. This requires the key's own profile session,
CSRF and recent password reauthentication; profile machine keys cannot change their own range. Changes apply on
the next request without rotating the secret. Paused keys can change range but remain paused; revoked keys cannot change range.
An inaccessible selected ID may remain in metadata; remove it or restore membership, it grants no access.

`GET /v1/accounts` returns the intersection of active memberships and key range. Account-bound API calls outside
the range return 404, including Zalo/template/webhook configuration and business routes. `POST /v1/accounts`
returns 403 for `selected` keys: use an `all` key when the company's server must provision new accounts.
There is no effect on the separate session dashboard's ability to create accounts.

#### Account provisioning

| Method/path                      | Result                                                             | Permission                                        |
| -------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- |
| `GET /v1/accounts`               | 200 `{accounts:[{id,name,slug,status,role}],next_cursor,has_more}` | Current memberships inside key range              |
| `POST /v1/accounts`              | 201 `{account,role:"owner"}`                                       | `all` key; active/verified profile, becomes Owner |
| `GET /v1/accounts/{accountId}`   | 200 `{account,role}`                                               | Account read                                      |
| `PATCH /v1/accounts/{accountId}` | 200 `{account,role}`                                               | Owner/Admin                                       |

Create takes `name` (2–120 characters), optional valid IANA `timezone` (default `Asia/Ho_Chi_Minh`), optional
`cors_origins` (§2.2). PATCH accepts the same fields, at least one required. The account object has `id`, `name`,
`slug`, `status`, `timezone`, `cors_origins`, `created_at`. Account creation has no Idempotency-Key support: reconcile
`GET /v1/accounts` after a lost response rather than blindly creating another account.

#### Company Zalo configuration and template authoring

Your own application collects the credentials; your server forwards them to Care X over HTTPS using the profile key.
The business owns its App/OA/ZBS. Care X stores app secrets and tokens encrypted and never returns them from reads.
These configuration/authoring routes do **not** take the business `environment` query (test-send explicitly takes `development`).

| Method/path after `/v1/accounts/{accountId}`       | Body/result                                                                                                                                                  | Permission      |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `GET /zalo/app`                                    | 200 `{app}` or `{app:null}`; settings + verification/callback/webhook URLs, no credentials                                                                   | Read connection |
| `PUT /zalo/app`                                    | `provider_app_id` (5–32 digits), `app_secret` (8–256 chars), optional `display_name`, `oa_secret_key`, `appsecret_proof`; 200 `{app}`                        | Owner/Admin     |
| `PUT /zalo/app/domain-verification`                | `{value}` (verification code, meta tag or filename; empty clears); 200 `{app}`                                                                               | Owner/Admin     |
| `POST /zalo/tokens`                                | `refresh_token` (20–2048 chars), optional `access_token` (20–4096 chars); 200 `{oa,connection}`                                                              | Owner/Admin     |
| `GET /zalo`                                        | 200 connection/OA/billing/sending/checks state                                                                                                               | Read connection |
| `GET /zalo/checks`                                 | 200 `{checks,connection}`                                                                                                                                    | Read connection |
| `POST /zalo/test-send`                             | `{phone,template_id,template_data,development}`; 200 `{result,billing_verified}`                                                                             | Owner/Admin     |
| `POST /zalo/pause`                                 | 200 connection state                                                                                                                                         | Owner/Admin     |
| `POST /zalo/resume`                                | `{phone,template_id,template_data}` + **Idempotency-Key**; one paid live probe; 200 result, 202 while running; connection + `requeued,replayed,verification` | Owner/Admin     |
| `DELETE /zalo/app`                                 | 200 disconnected state; wipes held secrets/tokens, live sends blocked                                                                                        | Owner/Admin     |
| `POST /templates/sync`                             | 200 sync result; pulls Zalo review/status/price/params                                                                                                       | Owner/Admin     |
| `GET /template-drafts`                             | 200 `{enabled,drafts,next_cursor,has_more}`                                                                                                                  | Read templates  |
| `POST /template-drafts`                            | `{draft}`; 201 draft view                                                                                                                                    | Owner/Admin     |
| `GET /template-drafts/{draftId}`                   | 200 draft view                                                                                                                                               | Read templates  |
| `PUT /template-drafts/{draftId}`                   | `{draft}`; 200 draft view                                                                                                                                    | Owner/Admin     |
| `DELETE /template-drafts/{draftId}`                | 204, no body                                                                                                                                                 | Owner/Admin     |
| `POST /template-drafts/{draftId}/duplicate`        | 201 new draft view                                                                                                                                           | Owner/Admin     |
| `POST /template-drafts/from-template/{templateId}` | 201 draft view with `content_recovered`                                                                                                                      | Owner/Admin     |
| `POST /template-drafts/{draftId}/submit`           | 200 draft view; calls Zalo create/edit for approval                                                                                                          | Owner/Admin     |
| `POST /template-media`                             | `{kind,filename?,data_base64}`; 201 media view                                                                                                               | Owner/Admin     |

App ID changes require disconnecting first. Token import checks the OA with the supplied access token before consuming
the refresh token. A new grant repairs authorization only: a previously suspended, paused, revoked or disconnected
connection stays stopped until explicit `POST /zalo/resume` after OA and ZBS checks pass. Ordinary token rotation on
an already active connection does not suspend it. Resume requires a successful paid live probe before activation.
Care X refreshes and manages the grant. Refresh tokens are single-use: **do not retry automatically
if the network result is unknown**, obtain fresh tokens from Zalo. No API read returns access/refresh token values.
Optional OAuth alternatives are `POST /zalo/connect` → `{authorize_url,expires_at}` and
`POST /zalo/oauth/complete` with `{state,code,oa_id}`; complete must use the same profile and account that started it.
Care X's callback URL is returned in the app settings; token import is the company-app integration path without needing
Care X dashboard OAuth completion. A profile key bypasses browser password prompts on sensitive account operations
because it was issued after reauthentication; current role checks still apply. Session dashboard callers still reauthenticate.

### Verified resume (one paid live probe)

After fixing the cause, reconnect if the token is unusable. For a `paused` or `degraded` account, explicitly submit:

```bash
curl -X POST "$API/v1/accounts/$ACCOUNT_ID/zalo/resume" \
  -H "Authorization: Bearer $PROFILE_API_KEY" \
  -H "Idempotency-Key: $ACTIVATION_REQUEST_ID" \
  -H 'Content-Type: application/json' \
  -d '{"phone":"0901234567","template_id":"YOUR_TEMPLATE_ID","template_data":{"customer_name":"Nguyen Van A"}}'
```

The business supplies a recipient it intentionally wants to test. This sends **one paid production message** through
its App/OA/ZBS; there is no `development` flag or `environment` query. Care X first validates App ID/Secret via a
controlled OAuth refresh once per saved credential fingerprint, then checks the current access token and exact OA,
provider-enabled template, parameters, Zalo hours and known quota holds. Subsequent resumes reuse the App credential
evidence; token refresh occurs only when due. An unknown refresh requires new tokens, never automatic reuse.

Throughout verification the account stays stopped. Only Zalo acceptance with `message_id`, followed by another
current profile/key/range/Owner/Admin and grant/App/payer/pause check, verifies candidate billing and activates sending.
Acceptance means provider acceptance, not delivery. One successful phone/template probe does not guarantee sufficient
balance, quota or permission for every queued route/template/recipient; workers recheck those sends and stop again on
route errors. The probe appears in the audit log (`zalo.test_send`); it is not a business notification/report row.

Response includes the connection view plus `requeued`, `replayed` and:

```json
{
  "verification": {
    "kind": "accepted",
    "stage": "send",
    "provider_code": null,
    "provider_message_id": "ZALO_MSG_ID",
    "remediation": null
  }
}
```

`kind` is `accepted|rejected|unknown|in_progress`; `stage` is `credentials|oa|template|send|activation`.
A rejected verification returns 200 with its stage/code/remediation and leaves sending stopped. If the paid probe was
accepted but final permission/configuration changed, `stage:activation` retains the message ID without activating.
App errors `-101/-103/-104` are configuration errors; update/activate the App or correct its Secret. They do not revoke
a valid stored grant. Token failures require reconnect. Input/current-state/authorization errors use the normal 4xx envelope.

**Keep the same Idempotency-Key and body** when retrying a timed-out API call or polling. Completed results survive API
restarts and are replayed without another provider send; changing the body with the same key returns 409
`IDEMPOTENCY_CONFLICT`. Concurrent calls with the same key return 202 `in_progress`; a different key while running
returns 409. A claim with no progress for five minutes becomes `unknown` when polled using the original key; Care X
never automatically resends it. If credential verification was interrupted, the refresh grant is marked uncertain and must be reimported before another attempt. If the key is lost, stop and reconcile before another request; do not loop new keys.
After a rejected or unknown result, investigate and reconcile with Zalo, then intentionally create a **new key** if
another paid check is desired. Unknown notifications in the existing queue are never released. Activation attempts
are limited to 20 per account per 24 hours, in addition to the diagnostic send limit and normal API rate limits.

A draft is `{name,tag,header,title,paragraphs,table,buttons,params,note?}`. `tag` is `"1"|"2"|"3"`;
`header` is `{kind:"logo",lightMediaId,darkMediaId}` or `{kind:"images",mediaIds:[...]}`;
`table` is null or rows `{title,value,rowType?}`, buttons are `{type,title,content}`, params are `{name,type,sampleValue}`.
Draft view contains `id,name,tag,draft,provider_template_id,status,status_reason,source,editable,submit_count,
last_submitted_at,last_error,media,updated_at`. Incomplete drafts can be saved; validation happens on submit.
Submission is gated by `TEMPLATE_WRITE_API_ENABLED`; Zalo must approve before sending. After rejection, edit/resubmit
uses the same template ID. Creation/submit/media uploads do not support Idempotency-Key: reconcile drafts and provider
status after a lost response, rather than blindly repeating effects.

Media kinds: `logo_light`, `logo_dark`, `image`; logos are PNG 400×96, both light/dark versions; images are PNG/JPG 16:9,
maximum 500 KB each. The JSON `data_base64` supports a data URL prefix. Media view has
`media_id,kind,width,height,bytes,data_url,warnings`. The Zalo upload/create limits and existing validation still apply.

Live sending requires the global send flag, connected OA and verified business ZBS. An accepted **production**
`/zalo/test-send` verifies billing; development or rejected/unknown results do not. Check readiness through `/zalo/checks`.

#### Sending, contacts, results and webhook integration

The business endpoints and recipes in §4–§9 already use `/v1/accounts/{accountId}` and the profile key. Every business route requires the explicit
query `environment=development` or `environment=live`, even for contacts/templates. A missing/invalid environment
returns 422. This preserves environment separation for notifications/events/batches/reports; no environment comes from the key.

- `/account`, `/templates`, `/templates/{templateId}`: readable by Viewer and above.
- `/contacts/*`: read/write by Operator and above; preference writes require contact-write permission.
- `/notifications/*`, `/batches/*`, `/events`: reads by Operator and above; sending/cancel/replace by Operator and above.
- `/reports/{summary,daily,details}`: Viewer and above. Results include only the selected environment.
- Sending/batch/replace still require Idempotency-Key. Its domain remains account + environment + method + operation;
  using another profile key does not duplicate the same request. RLS enforces all resource IDs belong to the selected account.
- Notification attribution uses `origin:"api"` and the profile actor; it does not store a profile key ID in an account-key foreign key.

Webhook management also uses the same profile key (Owner/Admin), without an environment query:
`GET/POST /webhook-endpoints`, `POST /webhook-endpoints/{endpointId}/{verify,rotate-secret,pause,resume,disable,test,replay}`,
`GET /webhook-endpoints/{endpointId}/deliveries`. Lists return `{endpoints,next_cursor,has_more}` or
`{deliveries,next_cursor,has_more}`; see §3.5 for pagination. Create body is `{url,environment,subscriptions?,make_default?}`;
returns 201 with endpoint metadata, one-time `secret` and `verification`. Rotation takes `{emergency}` and returns
one-time `{secret,emergency}`. Verify takes `{make_default}`, replay takes `{event_id}`; lifecycle/test/replay/verify
return 200. URL SSRF checks, verification challenges, signing and retry behavior are unchanged; the signing secret
is separate from the API key.

```bash
# Keep credential JSON files private. Never put secrets in shell history or logs.
curl -X POST "$CAREX_API_URL/v1/accounts" \
  -H "Authorization: Bearer $PROFILE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"Chi nhánh A"}'

# APP_CONFIG_FILE: provider_app_id, app_secret, optional oa_secret_key/appsecret_proof/display_name.
curl -X PUT "$CAREX_API_URL/v1/accounts/$ACCOUNT_ID/zalo/app" \
  -H "Authorization: Bearer $PROFILE_API_KEY" -H 'Content-Type: application/json' -d "@$APP_CONFIG_FILE"

# TOKEN_CONFIG_FILE: refresh_token, optional access_token.
curl -X POST "$CAREX_API_URL/v1/accounts/$ACCOUNT_ID/zalo/tokens" \
  -H "Authorization: Bearer $PROFILE_API_KEY" -H 'Content-Type: application/json' -d "@$TOKEN_CONFIG_FILE"

curl -X POST "$CAREX_API_URL/v1/accounts/$ACCOUNT_ID/templates/sync" -H "Authorization: Bearer $PROFILE_API_KEY"
curl "$CAREX_API_URL/v1/accounts/$ACCOUNT_ID/templates?environment=development&status=ENABLE" \
  -H "Authorization: Bearer $PROFILE_API_KEY"

# SEND_PAYLOAD_FILE follows §5.7; the same profile key sends directly, no account key step.
curl -X POST "$CAREX_API_URL/v1/accounts/$ACCOUNT_ID/notifications?environment=development" \
  -H "Authorization: Bearer $PROFILE_API_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $REQUEST_ID" -d "@$SEND_PAYLOAD_FILE"
```

### 2.2 Account CORS

Company server calls without `Origin` keep working with any allowlist, including the default `[]`.
Configure origins in Dashboard → account → **Cài đặt**, or PATCH the account via the profile API with
`{"cors_origins":["https://app.company.example","http://localhost:9000"]}`.
Use up to 50 explicit HTTP/HTTPS origins; optional port is significant. Root trailing slash is normalized away and
duplicates are removed. Wildcards, `null`, URL credentials, paths, queries and fragments are rejected.
Only Owner/Admin can change settings. Clearing the list blocks every browser origin on that account's business API.

Profile API preflight (`OPTIONS /v1/accounts/{accountId}/*`) has no Bearer credential: it checks that selected account's
allowlist and status. Actual requests require a valid profile key, active membership and that account's exact origin.
Legacy preflight (`OPTIONS /v1/*`) only checks whether any API-enabled account declares the origin; legacy actual requests
still check their account key's own account. In both cases,
an origin allowed on account B cannot read/write account A. Disallowed actual origins return `403 CORS_REJECTED`
before business effects. Removal applies immediately to actual requests even if a browser cached a preflight.
Allowed methods: GET, HEAD, POST, PUT, PATCH, DELETE. Allowed request headers: Authorization, Content-Type,
Idempotency-Key, If-Match, Accept. Response headers exposed: X-Request-Id, Retry-After, ETag. Preflight cache is 300 seconds.
Cookies are not enabled for cross-origin account API calls. CORS does not open global account creation/list, `/dashboard-api`,
`/api/auth` or `/admin-api` to company domains, restrict server IPs, or replace key authentication.
Keep all long-lived integration keys on company servers; websites should call those servers rather than embed keys.

## 3. Conventions

### 3.1 Format

- Requests and responses are JSON. Send `Content-Type: application/json` on requests with a body.
- For `POST …/cancel`, which has no body, send no body and no JSON content type. An empty body sent as JSON is rejected
  by Fastify with `400`.
- Body size limits:
  - `POST /v1/accounts/{accountId}/notifications` and `POST /v1/accounts/{accountId}/notifications/{id}/replace`: 64 KiB.
  - `POST /v1/accounts/{accountId}/notifications/batch`: 4 MiB.
  - Everything else: 1 MiB.
  - A larger body gets `413`, reported with code `VALIDATION_FAILED`.
- Every response has an `x-request-id` header (`req_…`). The same value appears in error bodies as `request_id`.

### 3.2 IDs

Public IDs have the form `<prefix>_<26 chars>`. The body is lowercase Crockford base32 of a UUIDv7, so IDs sort by
creation time. Treat them as opaque strings.

| Prefix | Resource                      |
| ------ | ----------------------------- |
| `acc_` | account                       |
| `pky_` | profile API key metadata      |
| `cnt_` | contact                       |
| `cid_` | contact channel identity      |
| `ntf_` | notification                  |
| `bat_` | batch                         |
| `evt_` | event (webhook / events feed) |
| `req_` | request ID                    |

In a path, a malformed ID, or an ID with the wrong prefix, returns `404 NOT_FOUND`. It never returns a 422.

Templates are identified by the **Zalo `template_id`** string, for example `"639714"`. It has no prefix.

### 3.3 Timestamps and money

- Output timestamps are ISO 8601 in UTC with milliseconds, for example `2026-10-04T02:00:00.000Z`.
- Input timestamps must be ISO 8601 date-times **with `Z` or an offset**, for example `2026-10-04T09:00:00+07:00`. A
  time with no offset is rejected.
- Money is in integer **VND**. An unknown price is `null` with status `unavailable`. **It is never `0`.**

### 3.4 Idempotency (`Idempotency-Key`)

These endpoints require the `Idempotency-Key` header:

- `POST /v1/accounts/{accountId}/notifications`
- `POST /v1/accounts/{accountId}/notifications/batch`
- `POST /v1/accounts/{accountId}/notifications/{id}/replace`

Rules:

- The key is 1–255 printable ASCII characters (`0x21`–`0x7E`), with no spaces. A missing or invalid key returns
  `422 VALIDATION_FAILED` ("Header Idempotency-Key là bắt buộc (1–255 ký tự in được).").
- Keys are scoped per **account + environment + HTTP method + route**. For replace, the route includes the target
  notification. The same key on another route, or in the other environment, is independent.
- The body is compared by SHA-256 of **canonical JSON** of the _validated_ body: object keys are sorted, strings are
  trimmed where the schema trims them, and defaults are applied. Key order and extra whitespace do not matter. For a
  batch, each valid item is normalized the same way; an invalid item is hashed as sent.
- Outcomes:
  - **Same key + same body** → the stored response is replayed: same status (`202`) and **the same body as the first
    time**. That body is a snapshot from creation time, not the current state; use `GET` for the current state. The
    response header `idempotent-replayed: true` marks a replay. First executions carry `idempotent-replayed: false`.
  - **Same key + different body** → `409 IDEMPOTENCY_CONFLICT`. Do not "fix" this by generating a random new key. It
    means your code sent two different requests under one business identity.
  - **Concurrent duplicates** wait for the first request's transaction and then receive its stored response.
  - **Errors are not stored.** If the first attempt failed (4xx/5xx), the record is rolled back, and retrying with the
    same key runs the request again.
- Records are kept for **30 days** from the first request. After that, the same key creates a new resource.
- Derive the key from your business event, not from randomness, for example
  `dentalx:appointment:APT-123:r4:reminder`. This way a crash-and-retry of your own process cannot create a duplicate.
- Other endpoints need no key. `PUT` contacts is naturally idempotent. `cancel` on an already canceled notification
  returns `200` with the same notification.

### 3.5 Pagination

Resource lists accept `limit` (integer 1–100, default 50) and optional `cursor` (max 1024 characters).
Items are newest ID first. Responses add `{next_cursor,has_more}`; `next_cursor:null` and `has_more:false` mean the
end. Existing list fields remain: `accounts`, `drafts`, `endpoints`, `deliveries`, or `data` depending on the endpoint.
This covers accounts, templates, drafts, webhook endpoints/deliveries, notifications, batches and report details.

- Pass the returned opaque cursor back with the **same filters**. Do not decode or manufacture it.
- Cursors are signed and bound to the resource, account (profile for the account list), selected environment where
  applicable, and filters. Altered or mismatched cursors return 422. Changing `limit` is permitted.
- Phone filter values never appear in the cursor; their filter fingerprint is an HMAC.
- Report details preserve the first page's resolved time window, including default `to=now`, across subsequent pages.
- Every page checks the current key/profile/range/membership/role again. A cursor does not retain lost permission.
- Pages are a live view, not a frozen export: status/cost can change, and newly created rows can appear before the
  first page. Restart when a consistent new report is needed.
- The legacy account-key notifications/batches routes also accept old ID cursors; profile API routes require signed cursors.

**Events feed** (`GET /v1/accounts/{accountId}/events`) remains distinct: limit 1–200, default 100, cursor max 512.
Its signed cursor is bound to account/environment, order is oldest first, response is `{data,next_cursor,has_more,first_id}`.
At the current end it retains the last cursor for future polling, rather than clearing it. Retention is 90 days (§5.16).

### 3.6 Error envelope

Every error uses this shape:

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request validation failed.",
    "retryable": false,
    "details": [{ "path": "/recipient", "message": "Invalid input" }]
  },
  "request_id": "req_01m3r0mh80eqjracr94d774dfn"
}
```

- **Branch on `code` and HTTP status, never on `message`.** Most messages are Vietnamese and may change.
- `details` lists the offending fields: `path` is a JSON pointer into the body, query or params (`/recipient/phone`,
  `/parameters/gio_hen`, `/identities/1/value`, `/event_at`, `/external_reference/revision`…). It is present for schema
  validation failures and for most business errors about one field. `NOTIFICATION_FINAL` and `REVISION_CONFLICT` also
  put the current status in `details[0].message` (`/status`: `delivered`; `/external_reference/revision`:
  `same revision already scheduled`).
- `retryable` is `true` only for `500 INTERNAL`, `503 SERVICE_UNAVAILABLE` and `429 RATE_LIMITED`.
- Network errors, timeouts and `503` on idempotent POSTs are safe to retry with the **same** key and body.

| Code                        | HTTP                                                   | Returned by `/v1` when…                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNAUTHENTICATED`           | 401                                                    | Profile key missing/malformed, secret invalid, paused/revoked, or profile inactive.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `FORBIDDEN`                 | 403                                                    | Current role cannot perform the action, or a selected key attempts account creation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `EMAIL_NOT_VERIFIED`        | 403                                                    | The profile must verify email when email verification is enabled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `CONTEXT_SELECTOR_REJECTED` | 400                                                    | An account/OA selector header was sent (see §1).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `NOT_FOUND`                 | 404                                                    | Unknown/inaccessible account (including outside key range or revoked membership), resource outside account/environment, or malformed/wrong-prefix resource ID.                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `VALIDATION_FAILED`         | 422 (also 400/413/415 for framework-level body errors) | Schema violations; missing or invalid `Idempotency-Key`; unknown or missing template parameters, or wrong length (`/parameters/<name>`); an identity, `phone` or `phone_hash` value that cannot be parsed ("Số điện thoại không hợp lệ.", "Zalo UID không hợp lệ.", "phone_hash phải là SHA-256…"; path `/identities/<i>/value`, `/recipient/phone` or `/recipient/phone_hash`); `expires_at` not after `schedule_at`; recipient/route mismatch; no identity matching the route on the contact; non-phone route in `development`; malformed or foreign cursor; malformed `contact_id`/`channel_identity_id` in a body. |
| `CONFLICT`                  | 409                                                    | `replace` with a stale `expected_version`/`If-Match`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `IDEMPOTENCY_CONFLICT`      | 409                                                    | Same `Idempotency-Key`, different body.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `RATE_LIMITED`              | 429                                                    | More than 60 requests/min for this key. `retryable: true`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `ACCOUNT_NOT_READY`         | 409                                                    | Account suspended; OA not connected; live sending suspended (the message includes the Zalo code and remediation); connection paused; `live` not ready.                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `BILLING_NOT_VERIFIED`      | 409                                                    | `live` send while ZBS billing is not verified.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `TEMPLATE_NOT_READY`        | 409                                                    | `template_id` not synced into this account, or its status is not `ENABLE`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `RECIPIENT_BLOCKED`         | 409                                                    | The recipient cannot receive for this purpose. The message includes one of `IDENTITY_REVOKED`, `SUPPRESSED` or `PREFERENCE_DENIED`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `STALE_REVISION`            | 409                                                    | An active notification with the same `external_reference` and purpose already has a **higher** `revision`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `REVISION_CONFLICT`         | 409                                                    | A notification with the same `external_reference`, purpose and **equal** `revision` is still in progress (not `delivered`/`failed`/`canceled`/`expired`). Increase the revision to replace it.                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `OUTSIDE_EVENT_WINDOW`      | 422                                                    | `event_at` is more than 7 days before or after the send time (ZBS rule). `details[0].path` is `/event_at`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `DISPATCH_ALREADY_STARTED`  | 409                                                    | `cancel`/`replace` on a notification whose send already started and has no final outcome yet (`dispatching`, `provider_accepted`, `dispatch_unknown`, `delivery_unknown`).                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `NOTIFICATION_FINAL`        | 409                                                    | `cancel`/`replace` on a notification in a final status (`delivered`, `failed`, `expired`; for `replace` also `canceled`). The status is in the message and in `details[0].message`.                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `CURSOR_EXPIRED`            | 410                                                    | `GET /v1/accounts/{accountId}/events` with a cursor whose anchor event left the 90-day retention.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `INTERNAL`                  | 500                                                    | Unexpected server error. `retryable: true`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `SERVICE_UNAVAILABLE`       | 503                                                    | The database could not be reached; nothing was stored. `retryable: true`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

The error catalogue (`packages/contracts/src/errors.ts`) also defines codes that only dashboard/session flows return:
`FORBIDDEN`, `REAUTH_REQUIRED`, `EMAIL_NOT_VERIFIED`, `INVITATION_INVALID`, `LAST_OWNER`, `CSRF_REJECTED`,
`OA_ALREADY_CLAIMED`, `OA_IDENTITY_MISMATCH`, `ZALO_NOT_CONFIGURED`, `ZALO_PROVIDER_ERROR` and `OAUTH_STATE_INVALID`.
Handle unknown codes generically.

---

## 4. Notification lifecycle

`status` values (`packages/domain/src/notification-status.ts`):

| Status              | Meaning                                                                                                                                                                                                                                                                                                  | Cancelable | Terminal |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------- |
| `accepted`          | Exists in the lifecycle, but new notifications are stored directly as `scheduled` or `queued`.                                                                                                                                                                                                           | yes        | no       |
| `scheduled`         | Waiting for `schedule_at`, which is more than 1 s in the future.                                                                                                                                                                                                                                         | yes        | no       |
| `queued`            | Waiting for the worker. A notification also returns here after a pre-send retry.                                                                                                                                                                                                                         | yes        | no       |
| `blocked`           | Held before sending. `status_reason` says why, for example `LIVE_SENDING_DISABLED`, `CONNECTION_<STATUS>` (e.g. `CONNECTION_REAUTH_REQUIRED`), `CONFIGURATION_CHANGED`, `PROVIDER_CONFIGURATION:<code>` or `QUOTA:<code>`.                                                                               | yes        | no       |
| `dispatching`       | The send attempt has started. From here on it **cannot be canceled**.                                                                                                                                                                                                                                    | no         | no       |
| `provider_accepted` | Zalo accepted the message (a `provider_message_id` exists).                                                                                                                                                                                                                                              | no         | no       |
| `dispatch_unknown`  | The send may or may not have reached Zalo (timeout, crash). **Never auto-resent.** Reconciliation may later move it to `provider_accepted`, `delivered` or `failed`.                                                                                                                                     | no         | no*      |
| `delivered`         | Zalo reported delivery.                                                                                                                                                                                                                                                                                  | no         | yes      |
| `delivery_unknown`  | No delivery fact 2 h after provider acceptance (`status_reason: NO_DELIVERY_FACT`). It can still become `delivered` or `failed`.                                                                                                                                                                         | no         | no*      |
| `failed`            | Rejected or not sendable. `status_reason` examples: `PROVIDER_REJECTED:<code>`, `RETRIES_EXHAUSTED:<reason>`, `SENDER_PERMISSION_REVOKED`, `ACCOUNT_CLOSED`, `OUTSIDE_EVENT_WINDOW`, `TEMPLATE_DISABLED`, `TEMPLATE_CHANGED`, `IDENTITY_CHANGED`, `IDENTITY_REVOKED`, `SUPPRESSED`, `PREFERENCE_DENIED`. | no         | yes      |
| `canceled`          | `status_reason` is `CANCELED_BY_CALLER`, `SUPERSEDED` (newer revision) or `REPLACED`.                                                                                                                                                                                                                    | –          | yes      |
| `expired`           | Not sent before `expires_at`. `status_reason` examples: `EXPIRED_BEFORE_DISPATCH`, `EXPIRED_DURING_RETRY`, `EXPIRED_WHILE_BLOCKED:<reason>`, `QUOTA:<code>`.                                                                                                                                             | no         | yes      |

\* Batch progress counts `dispatch_unknown` and `delivery_unknown` as "finished", but either can still change. For
`cancel`/`replace` and same-revision checks, only `delivered`, `failed`, `canceled` and `expired` are final.

`version` starts at 1 and increases on every status change. Use it for `replace?environment={environment}&expected_version=` and to ignore stale
webhook events (`aggregate.version`).

Estimated cost:

- `estimated_cost.amount` is the template's phone price, or its UID price on the `zalo_zbs_uid` route. Hash-phone sends
  are billed as phone sends.
- `estimated_cost.status` is `"unavailable"` and `amount` is `null` when Zalo gave no price.

---

## 5. Business endpoints

Quota holds (`status_reason: QUOTA:<code>`) are shared by pending messages in the affected account/environment:
`-144` phone/hash-phone OA; `-147` template; `-1472` promotion recipient/day;
`-1441` promotion OA/month; `-1471` promotion recipient/month. UID is separate; promotion holds do not block
transaction/customer-care templates. Phone/hash-phone recipient holds match with SHA-256 of normalized `84…` digits.
The worker checks holds before sends, including after token refresh. Quota `notification.blocked` timeline/webhook
data includes `retry_at`: next period from 00:10 Vietnam time, then the applicable sending window. This is Care X's
retry policy; Zalo remains authoritative about availability. Expired messages are never revived, unknown dispatches
never auto-resent. `-115` means insufficient ZBS balance, `-137` payment failure: live sending requires explicit resume
after remediation (§2.1); new live requests return `409 ACCOUNT_NOT_READY`.

Every endpoint in this section requires `?environment=development|live` in addition to the path shown.
`{accountId}` is a public `acc_…` ID inside the key range and current memberships. Field shapes below are shared
with legacy routes, but the authentication, account selection and environment query are the profile-key contract.

For example: `GET /v1/accounts/{accountId}/templates?environment=development&status=ENABLE`.

### 5.1 `GET /v1/accounts/{accountId}/account`

Returns the selected account and its Zalo connection state. Permission: Viewer or above.

```json
{
  "id": "acc_01m3r0mh80ea9r0d4n09r7a5ww",
  "name": "Nha khoa ABC",
  "status": "active",
  "timezone": "Asia/Ho_Chi_Minh",
  "environment": "live",
  "zalo_connection": {
    "status": "ready",
    "ready": true,
    "oa_name": "Nha khoa ABC",
    "billing": "verified",
    "sending_suspended": false,
    "suspension": null
  }
}
```

- `environment`: the explicit `environment` query of this request.
- `status` (account): `draft`, `active`, `suspended`, `closing` or `closed`.
- `zalo_connection.status`: `draft`, `authorizing`, `connected`, `ready`, `degraded`, `reauth_required`, `paused`,
  `revoked` or `disconnected`.
- `billing`: `not_configured`, `pending_verification` or `verified`.
- `ready` is true only when the OA is connected, billing is verified, and the status is not `degraded`/`paused`.
- `sending_suspended` is `true` whenever Care X holds live sending after a Zalo error. `suspension` then says why:

  ```json
  {
    "sending_suspended": true,
    "suspension": {
      "cause": "provider_route",
      "provider_code": -115,
      "remediation": "ZBS Account không đủ số dư — cần nạp tiền.",
      "failures": null
    }
  }
  ```

  | `cause`                      | When                                                                                                                                                                                                                                                                     | Ends when                                                                                                                              |
  | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
  | `provider_route`             | A live send got a route-level Zalo error (`-115` ZBS out of balance, `-137` payment failed, `-136` app not linked to ZBS…). `status` is `degraded`.                                                                                                                      | An admin fixes it and clicks “Tiếp tục gửi” in the dashboard. Reconnecting the OA does **not** end it.                                 |
  | `reauth_required`            | The OA token is invalid or revoked: a send was rejected with `-124`/`-216`/`-220`, a token refresh failed (e.g. `-14014`), or a health check found it. `status` is `reauth_required` before repair; after reconnect it is `degraded` while awaiting explicit activation. | Reconnect the OA, then explicitly call resume. Before repair, resume answers 409. After repair, status is `degraded` until activation. |
  | `repeated_provider_failures` | Five distinct anomalous live sends in the shared rolling window, without a newer acceptance. `status` is `degraded`; `failures` contains diagnostics.                                                                                                                    | Inspect and fix the cause, then explicitly complete verified paid resume.                                                              |

  `repeated_provider_failures` is a Care X protection hold (`status: degraded`): by default five distinct
  live notifications have anomalous provider outcomes within a rolling ten minutes without a newer Zalo acceptance.
  Counted: unclassified rejections, unknown dispatches (timeout/malformed response), and actual provider failures
  definitively before sending. Retries of the same notification count once in that window. Recipient/template/quota
  errors, local validation/permission changes, token-access preconditions and development outcomes do not count.
  Those excluded outcomes do not reset the streak; only a newer provider acceptance resets it while ready.
  The counter is durable across restarts and serialized per account. Global operational settings are
  `ZALO_SEND_FAILURE_THRESHOLD=5` and `ZALO_SEND_FAILURE_WINDOW_SECONDS=600`, identical on API/worker, never account settings.
  An accepted result arriving after the hold cannot open it. Successful verified resume clears the counter; failed,
  unknown or replayed failed resume does not. Late outcomes started before the last acceptance/activation are ignored.
  Requests already in flight at Zalo can still finish after a hold; this is not an exact maximum on concurrent calls.

  Example `suspension` for this cause:

  ```json
  {
    "cause": "repeated_provider_failures",
    "provider_code": null,
    "remediation": "Lỗi bất thường liên tiếp: kiểm tra lỗi gửi và tình trạng Zalo, rồi kiểm tra để tiếp tục gửi bằng một tin thử tính phí.",
    "failures": {
      "count": 5,
      "threshold": 5,
      "window_seconds": 600,
      "first_failure_at": "2026-10-01T14:00:00.000Z",
      "last_failure_at": "2026-10-01T14:01:00.000Z",
      "last_error": { "kind": "unknown", "provider_code": null }
    }
  }
  ```

  `failures` is null for other causes. `last_error.kind` is `unclassified`, `unknown` or `retry_before_send`;
  `last_error.provider_code` is numeric for a Zalo rejection, otherwise null. No recipient or message content is exposed.

  `provider_code` is the Zalo code, or `null` if unknown. `remediation` is Vietnamese text for humans, or `null`.
  `suspension` is `null` when `sending_suspended` is `false`.

- A manual pause (`status: "paused"`) is not a suspension: `sending_suspended` is `false` and `ready` is `false`.
  It lasts until an admin resumes; reconnecting the OA does not lift it. Always check `ready` before sending live.

### 5.2 `GET /v1/accounts/{accountId}/templates`

Lists the latest synced snapshots of the account's OA, newest snapshot ID first. Uses cursor pagination (§3.5).
Permission: Viewer or above.

Query:

| Field    | Type   | Required | Constraints                                                                                       |
| -------- | ------ | -------- | ------------------------------------------------------------------------------------------------- |
| `status` | string | no       | max 32 chars. Exact match on Zalo's status, e.g. `ENABLE`, `PENDING_REVIEW`, `REJECT`, `DISABLE`. |

Response: `{ "data": [Template, …], "next_cursor": null, "has_more": false }`. Also accepts `limit` and `cursor` (§3.5). The Template object is shown in §5.3.

### 5.3 `GET /v1/accounts/{accountId}/templates/{templateId}`

Returns one template, looked up by Zalo `template_id` (max 64 chars). Permission: Viewer or above. If the template has
never been synced into this account, you get `404`.

```json
{
  "provider_template_id": "639714",
  "name": "Nhắc lịch hẹn",
  "status": "ENABLE",
  "status_reason": null,
  "tag": "CUSTOMER_CARE",
  "params": [
    { "name": "ten_kh", "required": true, "type": "STRING", "maxLength": 30 },
    { "name": "gio_hen", "required": true, "type": "STRING", "maxLength": 10 },
    { "name": "ngay_hen", "required": true, "type": "STRING", "maxLength": 20 },
    { "name": "phong_kham", "required": false, "type": "STRING", "maxLength": 50, "acceptNull": true }
  ],
  "buttons": [],
  "price": { "phone": 300, "uid": null, "currency": "VND" },
  "timeout_ms": 7200000,
  "quality": null,
  "preview_url": "https://…",
  "synced_at": "2026-09-30T02:00:00.000Z"
}
```

- `params[]` is Zalo's parameter list. Each entry has `name`, `required` and `type`, plus optional `minLength`,
  `maxLength` and `acceptNull`.
- **Use `params[].name` verbatim as keys in `parameters`.**
- `tag` is Zalo's template tag, kept verbatim, for example `TRANSACTION`, `IN_TRANSACTION`, `CUSTOMER_CARE`,
  `PROMOTION` or `OTP`.
- `buttons` is Zalo's raw button list.
- `price.*` is `null` when unknown.
- `status_reason` is Zalo's reason text for `REJECT`/`DISABLE`.

### 5.4 `PUT /v1/accounts/{accountId}/contacts/external/{namespace}/{externalId}`

Creates a contact, or updates it, keyed by your own system's ID. Permission: Operator or above. Response: `200`.

Path:

| Field        | Constraints                                                                                |
| ------------ | ------------------------------------------------------------------------------------------ |
| `namespace`  | `^[a-z][a-z0-9_-]{0,31}$`, e.g. `dentalx`. It only separates ID spaces and grants nothing. |
| `externalId` | 1–128 printable ASCII chars (`0x21`–`0x7E`).                                               |

Body (strict; unknown fields → 422):

| Field                  | Type           | Required          | Constraints                                                                                                                                                                                                                                                                                                 |
| ---------------------- | -------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `display_name`         | string \| null | no                | trimmed, max 120. If omitted, the name is left unchanged. `null` clears it.                                                                                                                                                                                                                                 |
| `identities`           | array          | no (default `[]`) | max 10 items                                                                                                                                                                                                                                                                                                |
| `identities[].channel` | enum           | yes               | `zalo_phone` \| `zalo_uid` \| `zalo_phone_hash`                                                                                                                                                                                                                                                             |
| `identities[].value`   | string         | yes               | 3–128 chars. Phones are parsed with default region VN and stored as E.164; `isPossible` is required. A UID must be 5–32 digits. A phone hash must be 64 hex chars (it is lowercased). Invalid values → **422 `VALIDATION_FAILED`** with `details[0].path` = `/identities/<index>/value`; nothing is stored. |

Behaviour:

- `contact_id` is the stable, account-local customer ID, not a phone and not your company CRM ID. The notification
  stores it in a dedicated indexed column. Use `/contacts/external/{namespace}/{externalId}` to map your own CRM/customer
  ID to it and save the response `id`; concurrent upserts of the same reference return the same contact.
- Updating the same external reference with a new phone or hash preserves this `id`. You can also start with
  `identities:[]` and attach the exact address at send time (§5.7).
- Identities you list are **added** if new. Existing identities are kept; this endpoint never removes one.
- A `zalo_uid` is unique among active identities in the account. If the UID already belongs to another contact, the
  insert is silently skipped. Check `identities` in the response.
- `warnings` appears only when non-empty, for example `"phone_number_unrecognized_prefix"`. That warning means the
  phone is possible but not recognised by the phone metadata; it is still stored.

```json
{
  "id": "cnt_01m3r0mh80fph8w19g4jgnzycg",
  "display_name": "Nguyễn Văn A",
  "identities": [
    {
      "id": "cid_01m3r0mh80e6w83w34wm626dth",
      "channel": "zalo_phone",
      "masked": "+8490****567",
      "status": "active",
      "version": 1
    }
  ]
}
```

### 5.5 `GET /v1/accounts/{accountId}/contacts/{contactId}`

Permission: Operator or above. Returns the same shape as §5.4, without `warnings`. `display_name` is returned decrypted, so
treat it as personal data. Identities are only ever shown masked.

### 5.6 `POST /v1/accounts/{accountId}/contacts/{contactId}/preferences`

Records a communication preference fact for one purpose. Permission: Operator or above. Response: `200`.

| Field             | Type              | Required | Constraints                                                                                                       |
| ----------------- | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `purpose`         | string            | yes      | `^[a-z][a-z0-9_]{2,63}$`, e.g. `customer_care`, `promotion`                                                       |
| `status`          | enum              | yes      | `allowed` \| `denied`                                                                                             |
| `proof_reference` | string            | no       | max 256                                                                                                           |
| `recorded_at`     | datetime (offset) | no       | default: now. **The latest `recorded_at` wins.** An older fact is stored but does not change the effective state. |

```json
{ "purpose": "promotion", "effective_status": "denied" }
```

When the effective preference for a purpose is `denied`:

- New sends for that purpose return `409 RECIPIENT_BLOCKED` (`PREFERENCE_DENIED`).
- Already-queued ones end `failed` with reason `PREFERENCE_DENIED`, because the preference is re-checked just before
  sending.

### 5.7 `POST /v1/accounts/{accountId}/notifications`

Accepts one template message. Permission: Operator or above. The `Idempotency-Key` header is required.

- Response: **`202`** plus the header `idempotent-replayed`.
- `202` is returned only after the notification and its dispatch job are committed. Nothing calls Zalo during the
  request.

Body (strict):

| Field                                            | Type              | Required | Constraints / behaviour                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------------------------------------ | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `template_id`                                    | string            | yes      | trimmed. `^[A-Za-z0-9_-]{1,64}$`. Zalo template ID; the template must be synced and `ENABLE`.                                                                                                                                                                                                                                                                                                                                                                              |
| `route`                                          | enum              | no       | `zalo_zbs_phone` \| `zalo_zbs_uid` \| `zalo_zbs_hashphone`. Defaults to `zalo_zbs_hashphone` when the recipient is `phone_hash`, else `zalo_zbs_phone`. In `development` only `zalo_zbs_phone` is allowed.                                                                                                                                                                                                                                                                 |
| `recipient`                                      | object            | yes      | **Exactly one** of the three strict shapes below.                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `recipient.contact_id` (+ `channel_identity_id`) | string            | –        | `{ "contact_id": "cnt_…", "channel_identity_id": "cid_…"? }`. The contact must have an active identity on the route's channel (`zalo_phone` / `zalo_uid` / `zalo_phone_hash`). If `channel_identity_id` is omitted, the newest matching identity is used.                                                                                                                                                                                                                  |
| `recipient.phone` (+ `contact_id`, `name`)       | string            | –        | `{ "phone": "0901234567", "contact_id": "cnt_…"?, "name": "Lan"? }`. `phone` is 1–32 chars (trimmed); `name` is max 120. Only valid with route `zalo_zbs_phone`. With `contact_id`, Care X attaches/reuses this exact phone on that customer. Without it, Care X reuses the newest contact with this phone, or creates one. It sends to the supplied phone, not another address on the contact. An unparseable phone → 422 with path `/recipient/phone`.                   |
| `recipient.phone_hash` (+ `contact_id`, `name`)  | string            | –        | `{ "phone_hash": "<sha256 hex>", "contact_id": "cnt_…"?, "name"? }`. Must be 64 hex chars. Only valid with route `zalo_zbs_hashphone`, so effectively `live` only. Care X never sees the number. Optional `contact_id` attaches/reuses this exact hash on that customer; otherwise it finds/creates a contact by hash. The phone normalisation Zalo expects before hashing is not fixed in code; the human docs suggest `84xxxxxxxxx` and mark it as still being verified. |
| `parameters`                                     | object            | yes      | Map from template param name to `string` (max 1000) \| `number` \| `null`. Unknown names → 422. A missing required param → 422, unless the param has `acceptNull`. `null` is sent as `""`. `minLength`/`maxLength` from the template are enforced. Numbers are sent as strings.                                                                                                                                                                                            |
| `schedule_at`                                    | datetime (offset) | no       | Missing/past means earliest permitted time from now. Global Zalo policy can advance a future overnight reminder or defer to opening. More than 1 s ahead after normalization → `scheduled`.                                                                                                                                                                                                                                                                                |
| `expires_at`                                     | datetime (offset) | no       | Default: send time + 24 h. Must be after the send time, else 422.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `event_at`                                       | datetime (offset) | no       | The business event time. The send time must be within **±7 days** of it (ZBS rule), else `422 OUTSIDE_EVENT_WINDOW`.                                                                                                                                                                                                                                                                                                                                                       |
| `purpose`                                        | string            | no       | `^[a-z][a-z0-9_]{2,63}$`. The default comes from the template tag: `TRANSACTION`/`IN_TRANSACTION`→`transaction`, `CUSTOMER_CARE`→`customer_care`, `PROMOTION`→`promotion`, `OTP`→`otp`, anything else→`general`. Used for preferences, suppression and revision ordering.                                                                                                                                                                                                  |
| `external_reference`                             | object            | no       | strict. `namespace` `^[a-z][a-z0-9_-]{0,31}$`; `type` `^[a-z][a-z0-9_]{0,31}$`; `id` 1–128 chars; `revision` integer ≥ 0 (optional). Revision rules are below.                                                                                                                                                                                                                                                                                                             |
| `subject_reference`                              | object            | no       | strict. `{ "type": string ≤ 32, "id": string ≤ 128 }`. Informational.                                                                                                                                                                                                                                                                                                                                                                                                      |
| `metadata`                                       | object            | no       | Any JSON object; its serialised JSON must be ≤ 4096 chars. Returned as-is. **Do not put phone numbers or secrets here.**                                                                                                                                                                                                                                                                                                                                                   |

Revision rules, when `external_reference.revision` is set:

- Revisions increase (Spec §12.5). Care X looks at other notifications with the same `namespace`, `type`, `id`
  **and** the same `purpose`. Requests for the same object are serialized, so two concurrent requests cannot both pass.
- If an active one (not `failed`/`canceled`/`expired`) has a higher revision → `409 STALE_REVISION`.
- If one with the **same** revision is still in progress (anything but `delivered`, `failed`, `canceled`, `expired`)
  → `409 REVISION_CONFLICT` ("increase revision to replace"). Once that one is final, the same revision may be sent
  again under a new `Idempotency-Key`. Resending the same body with the same key is an idempotent replay (§3.4), not a
  conflict.
- Those with a lower revision (or no revision) that are still cancelable (`scheduled`, `queued`, `blocked`) are
  canceled with `status_reason: SUPERSEDED`.
- Ones already `dispatching` or later are **not** touched, so both messages may reach the customer.

The checks run in this order:

1. Account status.
2. Template exists and is `ENABLE`.
3. Recipient/route compatibility and identity.
4. Parameters.
5. Timing.
6. Environment readiness (for `live`: sending suspension first, then OA connection, billing, pause, readiness).
7. Recipient blockers (suppression, preferences).
8. Revision.

Example request:

```bash
curl -X POST https://api.carex.io.vn/v1/accounts/{accountId}/notifications \
  -H "Authorization: Bearer $CAREX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dentalx:appointment:APT-123:r4:reminder" \
  -d '{
    "template_id": "639714",
    "recipient": { "contact_id": "cnt_01m3r0mh80fph8w19g4jgnzycg" },
    "parameters": { "ten_kh": "Nguyễn Văn A", "gio_hen": "09:00", "ngay_hen": "05/10/2026", "phong_kham": "Quận 1" },
    "schedule_at": "2026-10-04T09:00:00+07:00",
    "event_at": "2026-10-05T09:00:00+07:00",
    "external_reference": { "namespace": "dentalx", "type": "appointment", "id": "APT-123", "revision": 4 }
  }'
```

Response `202` (the Notification object; the same shape is returned by every notification endpoint):

```json
{
  "id": "ntf_01m3r0mh80epz8rks1fbchwsq4",
  "account_id": "acc_01m3r0mh80ea9r0d4n09r7a5ww",
  "status": "scheduled",
  "status_reason": null,
  "version": 1,
  "environment": "live",
  "origin": "api",
  "template_id": "639714",
  "batch_id": null,
  "route": "zalo_zbs_phone",
  "recipient": {
    "contact_id": "cnt_01m3r0mh80fph8w19g4jgnzycg",
    "channel_identity_id": "cid_01m3r0mh80e6w83w34wm626dth",
    "masked": "+8490****567"
  },
  "purpose": "customer_care",
  "external_reference": { "namespace": "dentalx", "type": "appointment", "id": "APT-123", "revision": 4 },
  "subject_reference": null,
  "metadata": {},
  "scheduled_at": "2026-10-04T02:00:00.000Z",
  "expires_at": "2026-10-05T02:00:00.000Z",
  "accepted_at": "2026-09-30T02:00:00.123Z",
  "dispatched_at": null,
  "provider_accepted_at": null,
  "delivered_at": null,
  "failed_at": null,
  "canceled_at": null,
  "estimated_cost": { "amount": 300, "currency": "VND", "status": "estimated" },
  "original_id": null,
  "superseded_by": null
}
```

Field notes:

- `origin` is `api` or `dashboard`.
- `original_id` is set on a notification created by `replace`.
- `superseded_by` is set on the old notification once it has been superseded or replaced.

Specific errors: `422 VALIDATION_FAILED` (including an unparseable phone or phone_hash), `422 OUTSIDE_EVENT_WINDOW`,
`409 TEMPLATE_NOT_READY`, `409 ACCOUNT_NOT_READY`, `409 BILLING_NOT_VERIFIED`, `409 RECIPIENT_BLOCKED`,
`409 STALE_REVISION`, `409 REVISION_CONFLICT`, `409 IDEMPOTENCY_CONFLICT`.

For customer-level reconciliation, keep the same `contact_id` when sending by normal phone, by hash or after a
phone change. For example:

```json
{ "recipient": { "contact_id": "cnt_…", "phone": "0901234567" } }
```

```json
{ "recipient": { "contact_id": "cnt_…", "phone_hash": "<64 hex chars>" } }
```

These are recipient fragments to include in the send body. Then filter `/reports/{summary,daily,details}` or
`/notifications` with `contact_id=cnt_…` to retrieve that customer's messages across all these addresses.
Phones can be shared by several customers (e.g. guardians); an explicit contact keeps them separate. Care X does
not infer a link between a phone and a hash, merge contacts or rewrite previous notifications. If you previously
sent using only a phone/hash, reuse the `recipient.contact_id` returned by Care X for that customer.
The optional `name` is used only when auto-creating a contact; edit an existing name through contact upsert.
A foreign/unknown/erased contact combined with an address returns 404. Malformed contact IDs and combining
phone + phone_hash or channel_identity_id + explicit address return 422. Adding an address never reactivates a
revoked identity or bypasses contact preferences/suppression; blocked sends roll back address changes.

#### Scheduling a future send

Your company server owns its nightly customer scan. Submit birthday items for 07:00 and appointment reminders
for appointment time minus 30 minutes; Care X does not discover birthdays or appointments. For a 09:00 appointment,
use `schedule_at=08:30` and `expires_at=09:00`, both as full ISO datetimes with offsets. Effective immediate sends
and messages whose expiry is within one hour of their effective schedule have reserved worker capacity; short deadlines
are prioritized in that lane. Other scheduled batches retain separate capacity so urgent traffic cannot consume it.
No API priority parameter is required. The internal birthday processing target is provider acceptance by 07:30 for a
07:00 cohort while account/provider are ready; this is not a delivery guarantee or an automatic 07:30 expiry.

Add `schedule_at` to the normal send body, or to each batch item. Use an ISO datetime with an explicit offset;
`2026-10-05T09:00:00+07:00` is 09:00 in Vietnam. Acceptance returns 202 and `scheduled` when the instant is more
than one second ahead. The queue stores the job in Postgres in the same transaction as the notification.
Restarting the API/worker does not discard committed jobs. The scheduled instant is when dispatch becomes eligible;
queue load, connection readiness and Zalo can delay dispatch or delivery.

Set `expires_at` when a late message would be inappropriate. These are timing fragments for the normal send body:

```json
{
  "schedule_at": "2026-10-05T09:00:00+07:00",
  "expires_at": "2026-10-05T09:30:00+07:00"
}
```

After the deadline, the worker expires the message instead of initiating a late dispatch. Without `expires_at`,
the deadline is 24 hours after the resolved send time. Missing/past `schedule_at` means the earliest permitted time from now. Use cancel or
replace (§5.11–5.12) while the message is still cancelable to revoke or change a schedule; replace creates a new
notification ID atomically. Account state, sender profile/membership/role, key status and current account range,
recipient preferences, identity version, template and live connection/configuration are checked again at dispatch.
Revoking/deleting the originating key, removing its account range, disabling the profile or losing send permission
fails the pending notification with `SENDER_PERMISSION_REVOKED`; it does not send under the new owner's identity.
Pre-migration profile API commands without originating key provenance also fail closed; replace them with a fresh
request under an authorized key. Dashboard commands are checked against the creating profile's current membership.
Account suspension holds messages (`ACCOUNT_SUSPENDED`); closing fails them (`ACCOUNT_CLOSED`).
`event_at` is persisted business event time, not the time you called Care X; the ±7-day validation uses the effective
schedule at acceptance and is checked again at dispatch (`OUTSIDE_EVENT_WINDOW`).

Zalo sending hours are shared provider policy, not an account setting. The [official notice of Oct 1, 2026](https://zalo.solutions/news/thong-bao-vv-cap-nhat-co-che-cua-zbs-template-message-/eou7dloqfopa2puvj2h0e9wy) announces the following **expected from Oct 15, 2026**: Tag 1 transaction messages are available 24/24; Tag 2 customer-care and Tag 3 promotion messages are available [07:00,22:00), Vietnam time, on phone, hash-phone and UID routes. Before the effective date, sends remain unrestricted. Care X derives the tag from the provider-synced template, never caller-supplied purpose; an unknown tag uses the restricted window.

Care X operators configure `ZALO_SENDING_POLICY` identically on API and worker (JSON containing effective_from, start, end, advance_to; defaults: 2026-10-15T00:00:00+07:00, 07:00, 22:00, 21:00). See repository `docs/runbooks/README.md` and `.env.example`. Customers cannot override this policy through account APIs or Dashboard. Restart every API/worker instance together when changing it. Sending timezone is independent of the account reporting timezone.

For a restricted tag after the effective date, a future overnight schedule moves to the preceding 21:00 **only if it has not passed**: 23:00 on Oct 20 moves to 21:00 Oct 20; 02:00 Oct 21 moves to 21:00 Oct 20. If that 21:00 has passed at acceptance, use the opening after the requested instant (07:00 Oct 21 in both examples). `scheduled_at` is the effective schedule; default TTL starts there, while explicit expires_at is not shifted and must remain later.

The worker independently enforces not-before and the **current** global Zalo policy and latest synced tag. An outage/retry that reaches
closing time waits for the next opening, without advancing back into the past; if the opening is at/after expires_at,
the message becomes expired (`NO_ALLOWED_TIME_BEFORE_EXPIRY`). `scheduled_at` updates when the worker defers it.
Synchronous `/zalo/test-send` and CLI smoke sends return a conflict outside permitted hours; they are not silently
queued. Changing the policy does not rewrite all waiting jobs: the worker applies it when a job wakes, and use replace
for a deliberate schedule change. No maximum advance horizon is currently imposed; the report span cap is separate.
Terminal content/history retention counts from the last lifecycle activity (90/180 days), so a long schedule does
not age out its content immediately after dispatch.

List `/notifications` or `/reports/{summary,daily,details}` using `date_field=scheduled_at` and explicit from/to
around the future dates. The normal 90-day span limit, signed cursor filters, environment and current permissions
still apply. Unknown dispatch/delivery outcomes are never re-enqueued as scheduled sends.

### 5.8 `POST /v1/accounts/{accountId}/notifications/batch`

Accepts up to **1,000** notifications in one request. Permission: Operator or above. The `Idempotency-Key` header is
required and covers the whole batch; a replay returns the same batch and the same per-item results. Response: `202`.

| Field   | Type   | Required | Constraints                                                                  |
| ------- | ------ | -------- | ---------------------------------------------------------------------------- |
| `name`  | string | no       | trimmed, max 120                                                             |
| `items` | array  | yes      | 1–1000 items. Each item has the body schema of §5.7, validated item by item. |

Behaviour:

- **Whole-request failures.** These reject the entire batch and create nothing:
  - the envelope is invalid: `items` is not an array, is empty or has more than 1,000 entries, `name` is too long, or
    there is an unknown top-level field (422, `details[].path` like `/items`),
  - for `live`: sending is suspended or the account is not ready (`409 ACCOUNT_NOT_READY`).
- **Per-item failures.** Everything else is rejected **per item**, and the other items are still accepted: an item
  that fails the §5.7 schema (unknown field, wrong type, not an object…), a malformed `contact_id` /
  `channel_identity_id`, and every business rule of §5.7 (unknown template, parameters, blocked recipient, invalid
  phone, stale or conflicting revision, …). A rejected item carries `error.code`, `error.message` and, when known,
  `error.details` with paths **relative to the item** (e.g. `/parameters/gio_hen`, `/recipient/contact_id`).

```json
{
  "batch_id": "bat_01m3r0mh80eknrghnr9r3f4g83",
  "total": 3,
  "accepted": 2,
  "rejected": 1,
  "results": [
    { "index": 0, "status": "accepted", "notification_id": "ntf_01m3r0mh80ej08nb9ks72rx0sa" },
    {
      "index": 1,
      "status": "rejected",
      "error": {
        "code": "VALIDATION_FAILED",
        "message": "Thiếu tham số bắt buộc \"gio_hen\".",
        "details": [{ "path": "/parameters/gio_hen", "message": "Thiếu tham số bắt buộc \"gio_hen\"." }]
      }
    },
    { "index": 2, "status": "accepted", "notification_id": "ntf_01m3r0mh80fhyra3sr1j1whp40" }
  ]
}
```

`index` is the 0-based position of the item in `items`.

### 5.9 `GET /v1/accounts/{accountId}/notifications/{notificationId}`

Permission: Operator or above. Returns the Notification object plus a `timeline` of every internal event, oldest first.
A notification from the other environment returns `404`.

```json
{
  "id": "ntf_01m3r0mh80epz8rks1fbchwsq4",
  "status": "delivered",
  "version": 4,
  "timeline": [
    {
      "type": "notification.accepted",
      "occurred_at": "2026-09-30T02:00:00.123Z",
      "data": { "environment": "live", "scheduled_at": "2026-10-04T02:00:00.000Z" }
    },
    {
      "type": "notification.dispatching",
      "occurred_at": "2026-10-04T02:00:01.002Z",
      "data": { "attempt": 1 }
    },
    {
      "type": "notification.provider_accepted",
      "occurred_at": "2026-10-04T02:00:01.480Z",
      "data": { "provider_message_id": "c4e1…" }
    },
    {
      "type": "notification.delivered",
      "occurred_at": "2026-10-04T02:00:03.000Z",
      "data": { "delivered_at": "2026-10-04T02:00:03.000Z", "source": "provider_webhook" }
    }
  ]
}
```

Single-message detail additionally returns `provider_message_id` (latest known provider message ID, nullable) and
`cost:{currency,estimated,charged,released,adjustment}`. `estimated` is nullable when unavailable; ledger amounts
are aggregated per notification. The recipient remains masked.

The example is abridged; the response contains all Notification fields. The timeline also includes
`notification.accepted` and `notification.dispatching`. **These two are not published as webhooks or feed events.**
Timeline `data` is the raw event data, without the extra fields that webhooks add (see §6.3).

### 5.10 `GET /v1/accounts/{accountId}/notifications`

Permission: Operator or above. Lists the selected environment, newest first.

| Query                | Type    | Constraints                                                                   |
| -------------------- | ------- | ----------------------------------------------------------------------------- |
| `external_namespace` | string  | exact match on `external_reference.namespace`                                 |
| `external_type`      | string  | exact match on `external_reference.type`                                      |
| `external_id`        | string  | exact match on `external_reference.id`                                        |
| `status`             | string  | exact notification status; unknown values return 422.                         |
| `cursor`             | string  | opaque signed `next_cursor` from the previous page; invalid/mismatched → 422. |
| `limit`              | integer | 1–100, default 50                                                             |

Also accepts `from`, `to`, `date_field`, `phone`, `contact_id` and `template_id`; see §5.15 for period/filter rules.

Response: `{ "data": [Notification, …], "next_cursor": "opaque-signed-cursor" | null, "has_more": true | false }`.

### 5.11 `POST /v1/accounts/{accountId}/notifications/{notificationId}/cancel`

Cancels a notification before dispatch. Permission: Operator or above. No body. Response: `200` with the Notification.

- Cancelable statuses: `accepted`, `scheduled`, `queued`, `blocked`. The result is `canceled` with
  `status_reason: CANCELED_BY_CALLER`, and a `notification.canceled` event.
- Already `canceled` → `200` with the notification unchanged. No new event.
- `delivered`, `failed` or `expired` → `409 NOTIFICATION_FINAL` (the status is in the message and in
  `details[0].message`).
- Sending started and not final yet (`dispatching`, `provider_accepted`, `dispatch_unknown`, `delivery_unknown`) →
  `409 DISPATCH_ALREADY_STARTED`.

### 5.12 `POST /v1/accounts/{accountId}/notifications/{notificationId}/replace`

Atomically cancels a notification and creates a new one. Permission: Operator or above. The `Idempotency-Key` header is
required. Response: `202` with the **new** Notification (`original_id` = the old ID).

- Body: exactly the §5.7 schema. The new notification is validated like a fresh send.
- Optimistic concurrency, optional: `?environment={environment}&expected_version=<int ≥ 1>` or the header `If-Match: "<version>"`. The query
  parameter wins if both are given. A mismatch → `409 CONFLICT`. A non-numeric `If-Match` is ignored.
- The old notification must be cancelable (`accepted`, `scheduled`, `queued`, `blocked`). A final one (`delivered`,
  `failed`, `expired`, `canceled`) → `409 NOTIFICATION_FINAL`; one whose sending started →
  `409 DISPATCH_ALREADY_STARTED`.
- The old notification becomes `canceled` with `status_reason: REPLACED` and `superseded_by` = the new ID. Exactly one
  `notification.canceled` event is emitted for it, with `reason: REPLACED` and `replaced_by` = the new `ntf_` ID.
- The body may keep or raise `external_reference.revision`: the notification being replaced is excluded from the
  revision checks. Other notifications of the same object follow the revision rules of §5.7.

### 5.13 `GET /v1/accounts/{accountId}/batches`

Permission: Operator or above. Lists batches of the selected environment, newest first.

| Query    | Constraints                                                 |
| -------- | ----------------------------------------------------------- |
| `cursor` | `next_cursor` from the previous page (opaque signed cursor) |
| `limit`  | 1–100, default 50                                           |

```json
{
  "data": [
    {
      "id": "bat_01m3r0mh80eknrghnr9r3f4g83",
      "name": "Nhắc lịch ngày 01/10",
      "environment": "live",
      "origin": "api",
      "total": 3,
      "accepted": 2,
      "rejected": 1,
      "created_at": "2026-09-30T02:00:00.000Z"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

### 5.14 `GET /v1/accounts/{accountId}/batches/{batchId}`

Permission: Operator or above. Returns batch progress. A batch from the other environment returns `404`.

```json
{
  "id": "bat_01m3r0mh80eknrghnr9r3f4g83",
  "name": "Nhắc lịch ngày 01/10",
  "environment": "live",
  "origin": "api",
  "total": 3,
  "accepted": 2,
  "rejected": 1,
  "created_at": "2026-09-30T02:00:00.000Z",
  "template_ids": ["639714"],
  "by_status": { "delivered": 1, "provider_accepted": 1 },
  "finished": 1,
  "in_progress": 1,
  "cost": { "currency": "VND", "estimated": 300, "charged": 300 }
}
```

- `finished` counts notifications in `delivered`, `failed`, `canceled`, `expired`, `delivery_unknown` or
  `dispatch_unknown`.
- `in_progress` = `accepted − finished`.
- `cost` comes from the usage ledger:
  - `estimated` = the sum of estimate postings. The code does not subtract releases here.
  - `charged` = the sum of charges. Charges are posted only for `live` deliveries within the template timeout.

### 5.15 Account reports: summary, daily and reconciliation details

All report routes require Viewer or above and the explicit `environment=development|live` query.
The current key range, profile and account membership/role are checked on every call.

| Query                   | Meaning                                                                                                                                      |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `from`, `to`            | ISO datetime with offset. Half-open `[from,to)`. Default `to=now`, `from=to−30 days`. `from<to`, maximum duration 90 days.                   |
| `date_field`            | `accepted_at` (default), `scheduled_at`, `dispatched_at` or `delivered_at`. Null timestamps are excluded.                                    |
| `phone`                 | A valid phone, normalized like send recipients. Exact match of the message's phone identity via account-scoped HMAC; not a substring search. |
| `contact_id`            | Optional `cnt_…` exact recipient filter; malformed IDs return 422.                                                                           |
| `template_id`, `status` | Optional exact filters; status must be a notification status.                                                                                |

`phone` and `contact_id` combine with AND. Phone search covers `zalo_phone` identities, not UID/hash-phone routes.
Responses never include the full phone. Use URL encoding, e.g. `--data-urlencode "phone=+84901234567"`.
Query logging must redact phone values at the proxy/ingress too; Care X's application logger redacts them.

**`GET /v1/accounts/{accountId}/reports/summary`** returns one aggregate for the selected cohort, including current
status counts, delivery rate, unknown outcomes, template totals, top 10 failure reasons and usage ledger amounts.
`period` also includes `date_field`. `totals.accepted` is the number of Care X requests in the selected timestamp cohort.
Open tasks keep their original rule: unresolved tasks created within `[from,to)` for the environment, with recipient,
template and status filters applied to their notification; their date is task creation, not the selected notification timestamp.

```json
{
  "period": {
    "from": "2026-09-01T00:00:00.000Z",
    "to": "2026-10-01T00:00:00.000Z",
    "environment": "live",
    "date_field": "accepted_at"
  },
  "totals": {
    "accepted": 1200,
    "by_status": { "delivered": 1100, "failed": 40, "delivery_unknown": 20, "canceled": 40 }
  },
  "rates": { "delivery_rate": 0.9821, "unknown": 20 },
  "top_failure_reasons": [{ "reason": "PROVIDER_REJECTED:-118", "count": 25 }],
  "cost": { "currency": "VND", "estimated": 360000, "charged": 330000, "notifications_without_price": 0 },
  "by_template": [
    {
      "template_id": "639714",
      "total": 1200,
      "by_status": { "delivered": 1100, "failed": 40, "delivery_unknown": 20, "canceled": 40 }
    }
  ],
  "open_tasks": 3
}
```

- `delivery_rate` = `delivered / (provider_accepted + delivered + delivery_unknown)`. It is `null` when that
  denominator is 0.
- `unknown` = `dispatch_unknown + delivery_unknown`.
- `top_failure_reasons` covers `failed`, `blocked` and `expired`, top 10.
- `open_tasks` counts unresolved dashboard tasks (e.g. a recipient tapped a response button) **created in
  `[from, to)`** by notifications of the selected environment.

**`GET /v1/accounts/{accountId}/reports/daily`** returns chart data. Accepts the same filters plus
`timezone=Asia/Ho_Chi_Minh|UTC` (default Vietnam). The first/last calendar day can be partial when bounds are not midnight.
Every intersecting calendar day is present, including empty days; every notification status has a count (zero if absent).

```json
{
  "period": {
    "from": "2026-09-29T17:00:00.000Z",
    "to": "2026-10-02T17:00:00.000Z",
    "environment": "live",
    "date_field": "accepted_at",
    "timezone": "Asia/Ho_Chi_Minh"
  },
  "data": [
    {
      "date": "2026-09-30",
      "total": 2,
      "by_status": { "delivered": 1, "provider_accepted": 1 },
      "delivery_rate": 0.5,
      "unknown": 0,
      "cost": { "currency": "VND", "estimated": 600, "charged": 300, "notifications_without_price": 0 }
    },
    {
      "date": "2026-10-01",
      "total": 0,
      "by_status": { "delivered": 0, "provider_accepted": 0 },
      "delivery_rate": null,
      "unknown": 0,
      "cost": { "currency": "VND", "estimated": 0, "charged": 0, "notifications_without_price": 0 }
    },
    {
      "date": "2026-10-02",
      "total": 0,
      "by_status": { "delivered": 0, "provider_accepted": 0 },
      "delivery_rate": null,
      "unknown": 0,
      "cost": { "currency": "VND", "estimated": 0, "charged": 0, "notifications_without_price": 0 }
    }
  ]
}
```

Example abbreviated `by_status`; the actual response includes all statuses. Costs belong to the selected notification
cohort, not the date the ledger entry was posted. Unknown prices are counted separately, never silently treated as a known zero.

**`GET /v1/accounts/{accountId}/reports/details`** returns `{period,data,next_cursor,has_more}` with cursor pagination
(§3.5). Each item contains the Notification fields, `provider_message_id` and
`cost:{currency:"VND",estimated:number|null,charged:number,released:number,adjustment:number}`. Accepted/dispatched/delivered
and failure/cancel timestamps are included. Multiple dispatch attempts do not multiply ledger costs. Released amounts are
released estimates; they do not mean a refund of a ZBS charge. Final official spending must be reconciled with ZBS reports.

```bash
curl -G "$CAREX_API/v1/accounts/$ACCOUNT_ID/reports/details" \
  -H "Authorization: Bearer $PROFILE_API_KEY" \
  --data-urlencode "environment=live" \
  --data-urlencode "from=2026-09-01T00:00:00+07:00" \
  --data-urlencode "to=2026-10-01T00:00:00+07:00" \
  --data-urlencode "date_field=delivered_at" \
  --data-urlencode "phone=0901234567" \
  --data-urlencode "limit=50"
```

`GET /notifications` supports the same recipient/date/template/status filters, plus its existing external-reference filters.
Without `from/to`, it retains the original full-history list; when either time bound is supplied the 30-day default for
an omitted bound and the 90-day maximum apply. Single-message detail remains `/notifications/{notificationId}` and adds
provider message ID/ledger costs without a report time filter.

### 5.16 `GET /v1/accounts/{accountId}/events`

The reconciliation feed. Its items use the same envelope as webhooks. Permission: Operator or above.

| Query    | Type    | Constraints                                                                                            |
| -------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `cursor` | string  | max 512. The opaque `next_cursor` from a previous call. Omit it to start at the oldest retained event. |
| `limit`  | integer | 1–200, default 100                                                                                     |

```json
{
  "data": [
    { "id": "evt_01m3r0mh80eemr1wkse2xm9m0f", "type": "notification.delivered", "…": "envelope, see §6.3" }
  ],
  "next_cursor": "eyJhIjoi….3f9a…",
  "has_more": false,
  "first_id": "evt_01m3r0mh80eemr1wkse2xm9m0f"
}
```

- Retention is **90 days**. Items are ordered oldest first.
- The feed contains every account event type of the selected environment (`notification.*`, `account.*` in `live`, and
  `webhook.test`), regardless of webhook endpoint subscriptions.
- If a page is empty, `next_cursor` echoes the cursor you sent (or `null`). Keep polling with it.
- `has_more` is `true` when the page is full, so the next page may be empty.
- A cursor from another account or environment, or a tampered one → `422 VALIDATION_FAILED` (path `/cursor`).
- A cursor whose anchor event has left the 90-day retention → **`410 CURSOR_EXPIRED`**.

---

## 6. Webhooks (Care X → your system)

### 6.1 Setup

- Owner/Admin manages endpoints through the same profile key: `/v1/accounts/{accountId}/webhook-endpoints` and its verify/rotate/lifecycle/test/replay/deliveries routes (see §2.1). Dashboard **Webhooks** uses the same rules.
- An endpoint belongs to one environment and has a subscription list: `*` or event types.
- An endpoint must pass these URL checks:
  - HTTPS only, port 443,
  - no credentials or fragment in the URL,
  - the host resolves only to public addresses (not `localhost`, `*.local` or `*.internal`),
  - redirects are **not** followed.
- The signing secret has the form `whsec_…`. It is shown once. The **whole string, prefix included**, is the HMAC key.
- **Challenge.** On creation or re-verify, Care X POSTs a signed body
  `{"id":"evt_challenge_<hex>","type":"webhook.challenge","challenge":"<nonce>"}`. The endpoint becomes active only if
  you answer `2xx` with a response body that **contains the nonce**, for example `{"challenge":"<nonce>"}`. Only the
  first 16 KiB of your response are read.
- **Routing.**
  - Notification events go to the environment's default active endpoint **as it was when the notification was
    accepted**. If no endpoint was default and active at that moment, the notification's events are not pushed; they
    are still in `GET /v1/accounts/{accountId}/events`.
  - `account.*` events go to the current default `live` endpoint.
  - An event is pushed only if the endpoint's subscriptions include `*` or that event type.
- Secret rotation: a planned rotation keeps the old secret valid for 24 h. During that time the signature header
  carries one `v1=` per valid secret, newest first. An emergency rotation revokes the old secret immediately.

### 6.2 Delivery and signature

Each delivery is `POST <your URL>` with these headers:

```
Content-Type: application/json
User-Agent: CareX-Webhooks/1
X-CareX-Event-Id: evt_…
X-CareX-Delivery-Id: evt_….<attempt number>
X-CareX-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]
```

- `v1 = hex(HMAC-SHA256(key = secret, message = t + "." + event_id + "." + raw_body))`. Here `event_id` is the
  `X-CareX-Event-Id` value, which equals the body's `id`.
- **Verify over the raw bytes, before parsing JSON.**
- Every delivery attempt (first try, each retry, each manual replay) is **re-signed with a fresh `t`**. The body bytes
  are identical every time; only `t` and `v1` change.
- The timestamp tolerance is enforced by **your receiver**, not by Care X. Reject when |now − t| > 300 s (the examples
  below do). Because each retry carries a fresh `t`, a retry is never rejected for being old.
- **Success** means any `2xx` within **10 s** (connect timeout 3 s). Anything else, including 3xx, is a failure.
- **Retries** are scheduled from the event's `recorded_at`: +1 m, 5 m, 15 m, 1 h, 6 h, 12 h, 24 h, 48 h, 72 h. That is
  up to 10 attempts; after that the delivery is marked dead.
  - If your endpoint answers `400`, `401`, `403`, `404`, `410` or `422`, delivery stops after 5 attempts.
  - A paused endpoint's deliveries fail and follow this retry schedule. A disabled endpoint's deliveries go straight
    to dead.
  - Admins can manually replay an event from the dashboard. A replay sends the same event ID and body.
- **Dedupe by event `id`.** Delivery is at least once, and ordering is not guaranteed. Use `aggregate.version`: ignore
  a notification event whose version is lower than the one you already stored.

**Node.js (Express) verification**

```js
import crypto from 'node:crypto';
import express from 'express';

const SECRETS = [process.env.CAREX_WEBHOOK_SECRET]; // add the previous secret during a rotation

function verifyCareX(rawBody, eventId, signatureHeader, secrets, toleranceSec = 300) {
  if (!eventId || !signatureHeader) return false;
  let t = null;
  const v1 = [];
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=');
    const k = part.slice(0, i);
    const v = part.slice(i + 1);
    if (k === 't') t = v;
    else if (k === 'v1') v1.push(v);
  }
  if (!t || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const message = Buffer.concat([Buffer.from(`${t}.${eventId}.`, 'utf8'), rawBody]);
  return secrets.some((secret) => {
    const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(message).digest();
    return v1.some(
      (hex) => /^[0-9a-f]{64}$/.test(hex) && crypto.timingSafeEqual(expected, Buffer.from(hex, 'hex')),
    );
  });
}

const app = express();
app.post('/carex/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const eventId = req.get('x-carex-event-id');
  if (!verifyCareX(req.body, eventId, req.get('x-carex-signature'), SECRETS)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString('utf8'));
  if (event.type === 'webhook.challenge') return res.json({ challenge: event.challenge });
  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // dedupe
  await enqueueForProcessing(event); // store durably, answer fast (< 10 s)
  res.sendStatus(200);
});
```

**Python (Flask) verification**

```python
import hashlib, hmac, json, os, time
from flask import Flask, request, jsonify

SECRETS = [os.environ["CAREX_WEBHOOK_SECRET"]]  # add the previous secret during a rotation

def verify_carex(raw_body: bytes, event_id: str | None, signature: str | None, secrets, tolerance=300) -> bool:
    if not event_id or not signature:
        return False
    t, v1 = None, []
    for part in signature.split(","):
        k, _, v = part.partition("=")
        if k == "t":
            t = v
        elif k == "v1":
            v1.append(v)
    if not t or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    message = f"{t}.{event_id}.".encode("utf-8") + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, candidate) for candidate in v1):
            return True
    return False

app = Flask(__name__)

@app.post("/carex/webhook")
def carex_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_carex(raw, request.headers.get("X-CareX-Event-Id"),
                        request.headers.get("X-CareX-Signature"), SECRETS):
        return "", 401
    event = json.loads(raw)
    if event["type"] == "webhook.challenge":
        return jsonify(challenge=event["challenge"])
    if already_processed(event["id"]):
        return "", 200
    enqueue_for_processing(event)
    return "", 200
```

### 6.3 Event envelope

Webhook bodies and `GET /v1/accounts/{accountId}/events` items use this envelope:

```json
{
  "id": "evt_01m3r0mh80f6s83ye2cjwmw3s4",
  "type": "notification.delivered",
  "schema_version": "1",
  "account_id": "acc_01m3r0mh80ea9r0d4n09r7a5ww",
  "environment": "live",
  "occurred_at": "2026-10-04T02:00:03.000Z",
  "recorded_at": "2026-10-04T02:00:03.412Z",
  "aggregate": { "type": "notification", "id": "ntf_01m3r0mh80epz8rks1fbchwsq4", "version": 4 },
  "data": {
    "status": "delivered",
    "status_reason": null,
    "provider_message_id": "c4e1…",
    "external_reference": { "namespace": "dentalx", "type": "appointment", "id": "APT-123", "revision": 4 },
    "purpose": "customer_care",
    "delivered_at": "2026-10-04T02:00:03.000Z",
    "source": "provider_webhook"
  }
}
```

**Every `notification.*` event** carries these fields in `data`:

- `status` and `status_reason`: the notification's values **after** the change.
- `provider_message_id` (or `null`).
- `external_reference` and `purpose`.

The payload is deliberately minimal. It never contains a phone number, a name or template content.

### 6.4 Event types

| Type                             | Extra `data` fields                                                                                                                                                                                      | Notes                                                                                                                                                                                                                                                                   |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `notification.provider_accepted` | `provider_message_id`                                                                                                                                                                                    | Zalo accepted the send.                                                                                                                                                                                                                                                 |
| `notification.delivered`         | `delivered_at`, `source` (`provider_webhook` \| `reconciliation`)                                                                                                                                        | `occurred_at` = the delivery time.                                                                                                                                                                                                                                      |
| `notification.delivery_unknown`  | –                                                                                                                                                                                                        | No delivery fact 2 h after acceptance.                                                                                                                                                                                                                                  |
| `notification.dispatch_unknown`  | `reason` (e.g. `WORKER_INTERRUPTED`, or a transport error description)                                                                                                                                   | **Do not resend automatically.**                                                                                                                                                                                                                                        |
| `notification.blocked`           | `reason` (`LIVE_SENDING_DISABLED`, `CONNECTION_<STATUS>`, `CONFIGURATION_CHANGED`, `PROVIDER_CONFIGURATION`, `QUOTA`), `provider_code` for the last two                                                  | The message is held, not failed. For `QUOTA`, it is retried after 00:10 Vietnam time the next day if that is still before `expires_at`.                                                                                                                                 |
| `notification.failed`            | `reason` (e.g. `PROVIDER_REJECTED` + `provider_code` + `class`; `RETRIES_EXHAUSTED`; `TEMPLATE_DISABLED`; `TEMPLATE_CHANGED`; `IDENTITY_CHANGED`; `PREFERENCE_DENIED`; `SUPPRESSED`; `IDENTITY_REVOKED`) | Terminal.                                                                                                                                                                                                                                                               |
| `notification.canceled`          | `reason` (`CANCELED_BY_CALLER`, `SUPERSEDED` + `superseded_by`, `REPLACED` + `replaced_by`)                                                                                                              | `superseded_by`/`replaced_by` are the public `ntf_` ID of the new notification. Exactly one event per canceled notification.                                                                                                                                            |
| `notification.expired`           | optional `reason` (e.g. `QUOTA`, `EXPIRED`, `EXPIRED_WHILE_BLOCKED:<reason>`)                                                                                                                            | Terminal.                                                                                                                                                                                                                                                               |
| `notification.response_received` | `label`, `submitted_at`                                                                                                                                                                                  | The recipient tapped a response button. `label` is Zalo's button data, passed through as-is.                                                                                                                                                                            |
| `account.sending_suspended`      | `provider_code`, `reason` (`provider_configuration` \| `reauth_required` \| `repeated_provider_failures`), `remediation` (Vietnamese or `null`), `failures` snapshot for `repeated_provider_failures`    | `live` only. `aggregate` = `{ "type": "account", "id": "acc_…", "version": 1 }`. Emitted once per suspension: when a live send hits the error, or, for a token problem found by a token refresh or health check, when the next live notification is held because of it. |
| `account.sending_resumed`        | `requeued` (number of held notifications re-queued)                                                                                                                                                      | `live` only. Emitted once when a suspension ends: an Owner/Admin explicitly resumes after fixing the cause, including after reconnecting the OA. Reconnect and successful health checks never publish this event.                                                       |
| `webhook.test`                   | `message`                                                                                                                                                                                                | Sent from the dashboard test button. Uses dummy data.                                                                                                                                                                                                                   |

Notes:

- `notification.accepted` and `notification.dispatching` exist **only** in the per-notification `timeline`. They are
  not published.
- Every ID in event payloads and API responses is a public prefixed ID (`ntf_`, `acc_`, `evt_`, …), never an internal
  UUID. Events recorded before 30/09/2026 may still carry a UUID in `superseded_by`/`replaced_by`; for those, read
  `superseded_by` from `GET /v1/accounts/{accountId}/notifications/{aggregate.id}`.

Example `account.sending_suspended`:

```json
{
  "id": "evt_01m3r0mh80fpwrenena1jfk6nc",
  "type": "account.sending_suspended",
  "schema_version": "1",
  "account_id": "acc_01m3r0mh80ea9r0d4n09r7a5ww",
  "environment": "live",
  "occurred_at": "2026-10-04T02:05:00.000Z",
  "recorded_at": "2026-10-04T02:05:00.010Z",
  "aggregate": { "type": "account", "id": "acc_01m3r0mh80ea9r0d4n09r7a5ww", "version": 1 },
  "data": {
    "provider_code": -115,
    "reason": "provider_configuration",
    "remediation": "ZBS Account không đủ số dư — cần nạp tiền."
  }
}
```

---

## 7. Recipes

### 7.1 Send one template message

1. Call `GET /v1/accounts/{accountId}/account`. Confirm `environment`. For `live`, require `zalo_connection.ready === true`.
2. Call `GET /v1/accounts/{accountId}/templates/{template_id}`.
   - Require `status === "ENABLE"`.
   - Build `parameters` with **exactly** the names in `params[].name`.
   - Include every `required` param.
   - Respect `maxLength` and `minLength`.
3. Call `POST /v1/accounts/{accountId}/notifications` with a deterministic `Idempotency-Key` derived from the business event.
4. Handle the response:
   - On a timeout, network error, `429` or `5xx`: retry with the **same key and body**, with backoff.
   - On `4xx` other than 429: do not retry blindly. Fix the input, or surface the problem.
5. Store `id`. Track progress through webhooks or `GET /v1/accounts/{accountId}/events`. `GET /v1/accounts/{accountId}/notifications/{id}` is authoritative.

### 7.2 Bulk send from a list

1. Validate rows locally against the template's `params`.
   - Schema-invalid items are rejected individually in `results`; only an invalid batch envelope rejects the whole request.
   - Use `phone` (with route `zalo_zbs_phone`, the default) or `contact_id` recipients.
2. Split the list into chunks of ≤ 1,000 items, each within the 4 MiB body limit. Send each chunk to
   `POST /v1/accounts/{accountId}/notifications/batch` with its own stable key, e.g. `campaign-2026-10-01:chunk-0003`.
3. For each chunk:
   - Map `results[].index` back to your rows.
   - Save the `notification_id` of accepted items.
   - For rejected items, read `error.code`/`error.message`, fix them, and resubmit in a **new** batch with a **new** key.
4. Poll `GET /v1/accounts/{accountId}/batches/{batch_id}` for `by_status`, `finished` and `in_progress`, or consume events.
5. The rate limit is per request, so a 1,000-item chunk counts as one request.

### 7.3 Rescheduling (revision / replace / cancel)

- **Revision (for event-driven sources).**
  - Every time the appointment changes, send `POST /v1/accounts/{accountId}/notifications` with the same
    `external_reference {namespace, type, id}`, the same `purpose`, `revision` + 1, and a **new** `Idempotency-Key`
    that includes the revision.
  - Older cancelable notifications are canceled (`SUPERSEDED`).
  - A late-arriving older revision gets `409 STALE_REVISION`. Treat that as "already superseded", not as an error to
    retry.
  - Sending the same revision again with different content while the first one is still in progress gets
    `409 REVISION_CONFLICT`. Increase the revision (or use replace) to change the message.
  - If the older one is already `dispatching` or later, it is not canceled, and the new one still sends.
- **Replace (when you hold the notification ID).**
  - Call `POST /v1/accounts/{accountId}/notifications/{id}/replace?environment={environment}&expected_version={version}` with the full new body and a new key.
  - `409 CONFLICT` means your copy is stale. Call `GET` and decide again.
  - `409 DISPATCH_ALREADY_STARTED` means it is too late: the message is on its way. `409 NOTIFICATION_FINAL` means it
    already ended (see `details[0].message`). Decide whether to send a separate correction message.
- **Cancel.** Call `POST /v1/accounts/{accountId}/notifications/{id}/cancel`.
  - `200` means it is canceled, or already was.
  - `409 DISPATCH_ALREADY_STARTED` means the message is on its way; `409 NOTIFICATION_FINAL` means it already finished
    (`delivered`, `failed` or `expired`).
- To find notifications for a business object, call
  `GET /v1/accounts/{accountId}/notifications?environment={environment}&external_namespace=dentalx&external_type=appointment&external_id=APT-123`.

### 7.4 Reconcile with the events feed

Use the feed as a safety net for missed webhooks, or instead of webhooks.

```text
cursor = load_cursor()                     # null on first run
loop:
  page = GET /v1/accounts/{accountId}/events?environment={environment}&limit=200 (&cursor=cursor if set)
  for e in page.data:                      # oldest first
    if not seen(e.id): handle(e); mark_seen(e.id)   # same handler as webhooks
  if page.next_cursor: cursor = page.next_cursor; save_cursor(cursor)   # persist only after handling
  if not page.has_more: sleep(30–60 s)
on 410 CURSOR_EXPIRED: restart without a cursor (oldest retained event, ≤ 90 days)
  and rely on seen(e.id) dedupe; for anything older, re-check via GET /v1/notifications.
```

The feed orders by event ID. The code documents no guarantee against an event from a slow concurrent transaction
committing behind your cursor. For notifications you still consider non-terminal after a while, confirm with
`GET /v1/accounts/{accountId}/notifications/{id}`.

### 7.5 Handle a sending suspension

1. On `account.sending_suspended`, when `POST` returns `409 ACCOUNT_NOT_READY`, or when `GET /v1/accounts/{accountId}/account` shows
   `sending_suspended: true`:
   - Stop submitting new `live` sends, or queue them on your side.
   - Alert a human with `remediation`, for example "top up the ZBS account" for `-115`, or "reconnect the OA" for a
     token problem.
2. Notifications already accepted are **held**, not lost. They go to `blocked` with `status_reason`
   `PROVIDER_CONFIGURATION:<code>` (the one that hit the error) or `CONNECTION_<STATUS>` (the others), and each emits
   `notification.blocked`. Only notifications held **before** sending are ever re-queued; a `dispatch_unknown` one is
   never re-sent.
3. Fix the cause, then explicitly activate. Resume requires current Owner/Admin rights and account range, a usable OA grant, current App/Secret and a successful paid live probe. Candidate ZBS billing is verified only after acceptance and a final configuration/permission check:
   - `provider_route` (`provider_configuration`): a human fixes the cause, then clicks “Tiếp tục gửi” in the dashboard
     (**Kết nối Zalo**), or their company server calls `POST /v1/accounts/{accountId}/zalo/resume` after the fix. Reconnecting the OA does not end this kind of suspension.
   - `repeated_provider_failures`: inspect the recorded outcomes/provider availability, fix the cause and call the same verified resume. The triggering definitive rejection remains `failed`, an uncertain one remains `dispatch_unknown`, and neither is re-queued. A triggering pre-send retry is held as `CONNECTION_DEGRADED`; remaining unsent notifications are held before any provider call.
   - `reauth_required`: a human reconnects the OA (pastes a new refresh token from Zalo's API Explorer, or OAuth), then explicitly calls the same resume API. Reconnect repairs authorization only: `status` becomes `degraded`, `ready: false`, and `sending_suspended: true`. No held notifications are re-queued and no `account.sending_resumed` event is published until activation.
4. Either way Care X then emits `account.sending_resumed` once, with `requeued`, and re-dispatches the held
   notifications. Each is re-checked, so any that passed `expires_at` become `expired`.
5. Resume submitting once `GET /v1/accounts/{accountId}/account` shows `ready: true` and `sending_suspended: false`.
6. `development` requests never suspend `live` sending. A configuration error there only fails that test message.

---

## 8. Quick reference: business endpoints

All paths below start with `/v1/accounts/{accountId}` and **require the `environment` query**.
Account provisioning, Zalo/template authoring and webhook management are in §2.1; those configuration routes do not take this query.
OpenAPI: `GET /v1/openapi.json` requires no credential.

| Method | Path after account prefix                     | Current role      | Success              |
| ------ | --------------------------------------------- | ----------------- | -------------------- |
| GET    | `/account`                                    | Viewer or above   | 200                  |
| GET    | `/templates`                                  | Viewer or above   | 200                  |
| GET    | `/templates/{templateId}`                     | Viewer or above   | 200                  |
| PUT    | `/contacts/external/{namespace}/{externalId}` | Operator or above | 200                  |
| GET    | `/contacts/{contactId}`                       | Operator or above | 200                  |
| POST   | `/contacts/{contactId}/preferences`           | Operator or above | 200                  |
| POST   | `/notifications`                              | Operator or above | 202; Idempotency-Key |
| POST   | `/notifications/batch`                        | Operator or above | 202; Idempotency-Key |
| GET    | `/notifications`                              | Operator or above | 200                  |
| GET    | `/notifications/{notificationId}`             | Operator or above | 200                  |
| POST   | `/notifications/{notificationId}/cancel`      | Operator or above | 200                  |
| POST   | `/notifications/{notificationId}/replace`     | Operator or above | 202; Idempotency-Key |
| GET    | `/batches`                                    | Operator or above | 200                  |
| GET    | `/batches/{batchId}`                          | Operator or above | 200                  |
| GET    | `/reports/summary`                            | Viewer or above   | 200                  |
| GET    | `/reports/daily`                              | Viewer or above   | 200                  |
| GET    | `/reports/details`                            | Viewer or above   | 200                  |
| GET    | `/events`                                     | Operator or above | 200                  |

---

## 9. Invariants for agents (do not simplify away)

1. **Never auto-resend after an unknown outcome.**
   - `dispatch_unknown` and `delivery_unknown` mean the message may already be on the customer's phone.
   - Do not create a new notification automatically. Surface it to a human.
   - If they decide to resend, create a new notification with a new `Idempotency-Key`, knowing it may duplicate.
2. **Idempotency comes from `Idempotency-Key`, nothing else.**
   - Zalo's `tracking_id` (internal to Care X, fresh per attempt) is **not** an idempotency key.
   - Neither are `provider_message_id` and `external_reference`.
   - Reuse the same key only for the _same_ request. A different body under the same key is a bug in your code
     (`409 IDEMPOTENCY_CONFLICT`).
3. **Send by `template_id` with the exact parameter names** from `GET /v1/accounts/{accountId}/templates/{id}`.
   - There are no aliases, no renaming and no extra keys; unknown parameters are rejected.
   - Re-read the template if you get `TEMPLATE_NOT_READY` or `failed` with `TEMPLATE_CHANGED`/`TEMPLATE_DISABLED`.
4. **Select account only in the path; never supply an OA selector.** Key range and current membership/role must authorize the selected account. A past Admin/Owner role grants no current authority.
5. **Keep environments apart.**
   - `environment=development` only reaches Zalo App/OA admins, by phone.
   - Explicitly use `environment=development` for tests; profile keys can also access live, so never infer environment from a key prefix.
   - There is no simulator in the send path.
6. **Do not log personal data or secrets.**
   - Never log phone numbers, phone hashes, names, API keys, webhook secrets, or full request bodies that contain them.
   - Log `ntf_`/`evt_`/`req_` IDs and the `masked` recipient instead.
   - Keep `metadata` free of personal data.
7. **Verify webhook signatures on the raw body** before any side effect. Dedupe by event `id`, and order by
   `aggregate.version`.
8. **Respect suspensions.** After a route-level Zalo error, live sending stays suspended until a human explicitly
   resumes it; after a token error, reconnect the OA first, then explicitly resume. Do not loop retries against
   `409 ACCOUNT_NOT_READY`.
9. **Branch on `error.code`**, not on the Vietnamese `message`.

---

## 10. Legacy account-key compatibility

Existing integrations may continue using `carex_live_…` / `carex_dev_…` on the old `/v1/account`,
`/v1/templates`, `/v1/contacts/*`, `/v1/notifications/*`, `/v1/batches/*`, `/v1/events` and `/v1/reports/summary` routes.
Those keys are tied to one account/environment and retain stored scopes/expiry. They cannot authenticate profile routes;
profile keys cannot authenticate these legacy routes. Their account-key UI is removed; new company integrations use §2.1.

The success bodies, notification lifecycle, idempotency and provider rules are shared with the current endpoints.
For legacy calls, remove the `/accounts/{accountId}` segment and the business `environment` query; use the old
account/environment key. Legacy scope failures return 403 `INSUFFICIENT_SCOPE`. Do not use this translation for
account provisioning, Zalo configuration, template authoring or webhook management; those require profile-key routes.
Legacy preflight is described in §2.2. Exact legacy request/response schemas and security are in OpenAPI.
