# Verify Delivery

Prove that push notifications work — test-send to one device or token, app health counts, and the credential probe — with the exact response fields to read.

Universal push has no receipts (APNs and FCM answer at send time, and that answer is final), so verification is three small endpoints instead of a delivery log. On the dashboard, the same three checks sit under **Setup & docs → Push notifications setup → Verification**.

## 1. Test send — one device or one token

```text
POST https://app.nativenotify.com/api/universal/test-send
```

Target **either** a registered device:

```json
{
  "appId": 123,
  "appToken": "yourAppToken",
  "title": "Test from the dashboard",
  "message": "If you can read this, APNs/FCM accepted it.",
  "deviceId": "A1B2C3D4-install-key"
}
```

**or** an explicit raw token — `tokenType` is `"apns"`, `"fcm"` or `"web"` (for web, `token` is the browser subscription's `endpoint` URL):

```json
{
  "appId": 123,
  "appToken": "yourAppToken",
  "title": "Test to one token",
  "message": "Hello from the credentials probe.",
  "tokenType": "apns",
  "token": "0c9d8e…"
}
```

Title and message are required; `pushData`, `bigPictureURL` and the rich fields work exactly like a [normal send](/docs/push/sending). A `deviceId` test send also writes the send record and one notification-inbox entry for that device; an explicit raw token writes nothing, because there is no device to key an entry to.

```json
{
  "ok": true,
  "accepted": true,
  "transport": "apns",
  "result": "accepted",
  "reasonCode": null,
  "reasonPlainEnglish": "Apple accepted the notification for delivery to this device. APNs has no delivery receipts — whether the device displayed it is not knowable from the server, so 'accepted' is the strongest signal a native token can give.",
  "attempted": 1,
  "testSendId": 8841,
  "target": {
    "mode": "deviceId",
    "deviceId": "A1B2C3D4-install-key",
    "platform": "ios",
    "subscriberId": "user_8241",
    "tokenType": "apns",
    "tokenMasked": "…0c9d8e",
    "tokenSource": "universal"
  }
}
```

What to read:

- `accepted` / `result` — `true` / `"accepted"` or `false` / `"failed"`, straight from the real send pipeline. The request itself answers `200` either way.
- `reasonCode` / `reasonPlainEnglish` — when it failed: Apple's, Google's or the browser push service's answer, in a sentence. A credential-level reason for **every** token means fix the credentials first.
- `target.mode` — `"deviceId"` or `"token"`; `target.tokenSource` — `"universal"` (the device's registered token) or `"explicit"`.
- `tokenMasked` — responses never carry a full token; this is `…` plus the last six characters.

| Status | Code                                             | Meaning                                                                                     |
| ------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `400`  | `missing_field`, `invalid_field`, `invalid_body` | Missing `appId` / `title` / `message`, or an invalid `appId` or rich field.                 |
| `400`  | `missing_target`, `invalid_target`               | No target, both a `deviceId` and a token, or `tokenType` without `token` (or the reverse).  |
| `400`  | `invalid_token_type`, `invalid_token_format`     | `tokenType` is not `apns` / `fcm` / `web`, or the token does not match its shape.           |
| `403`  | `trial_expired`                                  | The free trial ended, or sending is paused for an unpaid invoice — nothing was sent.        |
| `404`  | `app_not_found`, `device_not_found`              | The app is not one this caller can access, or no device with that `deviceId` is registered. |
| `409`  | `no_deliverable_target`                          | The device has no live token for its platform, or the app has no credentials for it.        |

## 2. Health — what is in storage right now

```text
GET https://app.nativenotify.com/api/universal/health/:appId/:appToken
```

```json
{
  "ok": true,
  "appId": 123,
  "checkedAt": "2026-09-23T09:45:00.000Z",
  "devices": { "total": 1349, "mass": 1228, "subscriber": 121 },
  "tokens": { "apns": 640, "fcm": 660, "web": 45, "totalLive": 1345 },
  "retired": {
    "apns": 1,
    "fcm": 2,
    "web": 1,
    "total": 4,
    "note": "dead = true — excluded from every future send. Web push subscriptions are retired on the first 404/410 the push service reports; native tokens after 2+ dead-class failures behind DELETE_DEAD_TOKENS=1."
  }
}
```

Counts are exact and no token material appears in the response. `devices` splits registered devices into `mass` (no subscriber id) and `subscriber`; `tokens` counts live (sendable) tokens per type plus `totalLive`, and `retired` counts the ones excluded from future sends. Use it to confirm that a registration landed, and to see retired tokens before a blast.

## 3. Credentials — live probe

```text
POST /api/universal/credentials/validate   (owner / admin)
```

Covered in full on [Push Credentials](/docs/push/credentials): Google must mint an OAuth token from the stored service account, and Apple must answer the invalid probe token with `BadDeviceToken`.

## Reading the failures

| Symptom                                                                                                      | What it usually is                                                                                               | Do this                                                                                                 |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `failed` with a credential reason on every token                                                             | Wrong or expired FCM service account / APNs key                                                                  | Re-run the credential probe, re-save from the dashboard wizard                                          |
| `failed` with a per-token reason (`BadDeviceToken`, `Unregistered`, `UNREGISTERED`, web `NotFound` / `Gone`) | That install is gone (uninstalled, token rotated, browser unsubscribed)                                          | Re-register on the next app launch — registration clears the dead flag                                  |
| `failed` with web `EndpointNotAllowed`                                                                       | The registered endpoint is not a public push service, so the server refused to connect (never a strike)          | Register the browser's real `PushSubscription` endpoint                                                 |
| `skipped` counters in a send                                                                                 | No credentials for that transport on the app                                                                     | Save the missing credential set                                                                         |
| `devices: 0` on an `all` send                                                                                | No device is registered in the send's environment, or none has a live token                                      | Check the `environment` and the health counts above                                                     |
| `accepted` but the device shows nothing                                                                      | Android: the app never posted the FCM data message. Otherwise OS-level delivery (focus, DND, permission revoked) | Add the display code from your [recipe](/docs/push/recipes); check the device's notification permission |

Strikes and dead tokens on both paths are described in [Tokens & Receipts](/docs/tokens-and-receipts).
