Where to look first
A failed send never throws an error back from POST /v1/apps/{app_id}/notifications itself - that call only confirms Notti accepted the request. Delivery outcome is per-Device, checked via GET /v1/apps/{app_id}/notifications/{id}/deliveries. Each Delivery has a status (sent / failed / pending) and, on failure, a last_error string.
last_error is the raw provider error text, not a stable error code - its exact wording differs between APNs and FCM and can change if the provider changes its own message text. Match on substrings / provider-specific fields below, don't expect a fixed enum.
Auto-unsubscribe: what device.invalid means
When a provider reports a push token as permanently invalid (see the two tables below), Notti does two things automatically, before you take any action:
- Sets that Device's
subscribedtofalse. - Dispatches a
device.invalidwebhook event instead ofnotification.failed.
This is not a bug or a flaky failure - it means the provider told Notti the token will never work again (app uninstalled, token revoked, wrong gateway - see below). Re-subscribing without a fresh, valid token from the device will just fail again the same way. Your webhook handler is the reliable signal here - don't infer "permanently invalid" from last_error text alone, key off the device.invalid event type.
APNs: permanent vs retryable
APNs reason (appears inside last_error) | Outcome | What it usually means |
|---|---|---|
BadDeviceToken | Permanent - auto-unsubscribed | Token malformed, revoked, or (very commonly) sent to the wrong gateway - see Testing: sandbox vs production |
Unregistered | Permanent - auto-unsubscribed | App was uninstalled from the device |
ExpiredToken | Permanent - auto-unsubscribed | Token no longer valid |
DeviceTokenNotForTopic | Permanent - auto-unsubscribed | Token belongs to a different bundle ID than this App's APNs credentials |
TooManyRequests, InternalServerError, ServiceUnavailable, Shutdown, ExpiredProviderToken | Retryable | Transient APNs-side issue or an expired provider auth token (re-upload APNs credentials if this persists) |
| Any other reason string | Treated as permanent | Notti does not retry unrecognized APNs reasons |
If you're seeing BadDeviceToken on every send from a specific App, check that App's apns_environment (sandbox vs production, the environment you set when saving its APNs credentials) against how the client build that produced the token was signed - this is the single most common cause, not a broken integration.
FCM: permanent vs retryable
FCM error code (appears inside last_error) | Outcome | What it usually means |
|---|---|---|
UNREGISTERED | Permanent - auto-unsubscribed | App was uninstalled, or the token was rotated on the client without re-registering |
INVALID_ARGUMENT | Permanent - auto-unsubscribed | Malformed token, or a token from a different Firebase project than the App's configured credentials |
UNAVAILABLE, QUOTA_EXCEEDED | Retryable | Transient FCM-side issue or you've hit FCM's own sending quota |
HTTP 429, 500, 503 with no matching error code above | Retryable | Same as above, classified by HTTP status when FCM omits a specific code |
| Any other code | Treated as permanent | Notti does not retry unrecognized FCM codes |
provider_not_configured
A distinct last_error value, unrelated to the token itself: the App has no APNs credentials configured for an iOS Device, or no FCM credentials for an Android Device. Fix by completing credential setup for that platform - the Device's token isn't the problem.
Retryable failures
A failed Delivery with a retryable error (see the tables above) does not mean you need to resend manually - the Notti worker retries these with backoff on its own. Resending the same logical notification yourself risks a duplicate; use an Idempotency-Key if you do need to safely retry the API call itself.