More sandboxes
On this page
Push notification sandbox
Test push notifications without Apple or Google. Your server sends to Testlie instead of APNs or FCM, using the same paths, headers, JSON and authentication; nothing reaches a device. Every accepted notification is captured, rendered as an iOS and Android preview, and can be asserted on from your tests.
- APNs-compatible:
POST /3/device/{deviceToken}with an ES256 provider token, likeapi.push.apple.com. - FCM HTTP v1-compatible:
POST /v1/projects/{projectId}/messages:sendwith an OAuth2 access token, likefcm.googleapis.com.
Create a push inbox
Open Push notifications in the workspace and create an inbox (owners and admins). Each inbox comes with:
| APNs auth key | A P-256 key generated by Testlie: download it as AuthKey_<KeyID>.p8, with its Key ID (JWT kid) and Team ID (JWT iss). You can use your own .p8 instead; only its public key is stored. |
| Firebase project | A project ID such as tl-push-1a2b3c4d5e and a service-account JSON whose token_uri is https://testlie.com/push/fcm/token. |
| Test devices | Device tokens your tests use, each with a simulated outcome. |
These are sandbox credentials. Keep your real Apple key and Firebase service account out of test configuration.
Send through the APNs API
Sign a provider token (header {"alg":"ES256","kid":"<Key ID>"}, claims {"iss":"<Team ID>","iat":<now>}) with the .p8 and send to https://testlie.com instead of https://api.push.apple.com:
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
const b64 = (v) => Buffer.from(JSON.stringify(v)).toString("base64url");
const input = `${b64({ alg: "ES256", kid: KEY_ID })}.${b64({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })}`;
const sig = sign("sha256", Buffer.from(input), { key: createPrivateKey(readFileSync("AuthKey.p8")), dsaEncoding: "ieee-p1363" });
const res = await fetch(`https://testlie.com/3/device/${deviceToken}`, {
method: "POST",
headers: {
authorization: `bearer ${input}.${sig.toString("base64url")}`,
"apns-topic": "com.example.app",
"apns-push-type": "alert",
},
body: JSON.stringify({ aps: { alert: { title: "Sign in", body: "Your code is 482913" } } }),
});
// 200 with an apns-id header, or Apple's {"reason": "..."} with its status code.Headers read: apns-topic (required), apns-push-type (alert, background, voip, location, complication, fileprovider, mdm, liveactivity, pushtotalk, widgets; default alert), apns-priority (10, 5, 1), apns-expiration, apns-collapse-id and apns-id. A valid apns-id is echoed back; otherwise a new one is returned. Successful responses also carry apns-unique-id, which here is the captured notification's ID.
HTTP/2. Apple's service only speaks HTTP/2. The sandbox is served by the web app, so it answers over HTTP/1.1 and, on the hosted service and behind any HTTP/2-capable proxy, over HTTP/2 with TLS. Plain fetch, curl and HTTP/1.1 clients work; libraries that insist on HTTP/2 (for example @parse/node-apn, Pushy, apns2) work when you point their host at an HTTPS deployment. There is no HTTP/2 cleartext (h2c) listener.
APNs responses
| Status | Reason | When |
|---|---|---|
| 200 | Accepted and captured | |
| 400 | BadCollapseId |
apns-collapse-id longer than 64 bytes |
| 400 | BadDeviceToken |
Token is not 64 or more hex characters; a registered device simulating a bad token; an unregistered token when the inbox rejects unknown tokens |
| 400 | BadExpirationDate |
apns-expiration is not a number |
| 400 | BadMessageId |
apns-id is not a UUID |
| 400 | BadPriority |
Priority other than 10, 5 or 1, or 10 for a background push |
| 400 | BadTopic |
Malformed topic, or a push type whose topic suffix is missing (for example .voip) |
| 400 | DeviceTokenNotForTopic |
A registered device bound to another bundle ID, or simulating "wrong app" |
| 400 | DuplicateHeaders |
An apns-* header sent twice |
| 400 | InvalidPushType |
Unknown apns-push-type |
| 400 | MissingDeviceToken / MissingTopic |
No token in the path / no apns-topic |
| 400 | PayloadEmpty |
No body, or a body that is not a JSON object (Apple documents no separate reason for malformed JSON) |
| 400 | TopicDisallowed |
The bundle ID is not in the inbox's allowed topics |
| 403 | MissingProviderToken |
No authorization header |
| 403 | InvalidProviderToken |
Not an ES256 JWT, unknown kid, wrong iss, bad signature, or iat in the future |
| 403 | ExpiredProviderToken |
iat more than an hour ago (refresh tokens every 20 to 60 minutes, like with Apple) |
| 404 | BadPath |
Any path other than /3/device/{token} |
| 405 | MethodNotAllowed |
Not a POST |
| 410 | Unregistered |
A registered device simulating an uninstalled app; the body has timestamp (ms) |
| 413 | PayloadTooLarge |
Payload over 4096 bytes (5120 for voip) |
| 429 | TooManyRequests |
Over 50 requests per second for one inbox, or the monthly quota is used up |
Send through the FCM API
Exchange a service-account assertion for an access token at the JSON's token_uri, then send to https://testlie.com instead of https://fcm.googleapis.com:
# Python google-auth reads token_uri from the JSON, so tokens come from the sandbox.
from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession
creds = service_account.Credentials.from_service_account_file(
"service-account.json", scopes=["https://www.googleapis.com/auth/firebase.messaging"])
r = AuthorizedSession(creds).post(
"https://testlie.com/v1/projects/PROJECT_ID/messages:send",
json={"message": {"token": REGISTRATION_TOKEN, "notification": {"title": "Sign in", "body": "Your code is 482913"}}})
print(r.json()) # {"name": "projects/PROJECT_ID/messages/0:..."}The token endpoint implements the OAuth2 JWT-bearer grant (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<JWT>) the way Google's does: RS256, signed by the service account's key, iss the client email, aud the token URI, at most an hour long, with a scope that includes firebase.messaging or cloud-platform. Errors use Google's { "error", "error_description" } (invalid_grant, unsupported_grant_type, invalid_scope). Access tokens last an hour; rotating the service-account key revokes them.
Libraries that hard-code Google's token URL (the Node google-auth-library, the Firebase Admin SDKs) can use self-signed JWTs instead: a JWT signed with the service-account key, with a scope claim or an aud of the FCM URL, sent directly as the bearer token. With google-auth-library, set client.useJWTAccessWithScope = true on a JWT client. The Firebase Admin SDKs have no setting for the FCM host, so they need a proxy or DNS override and are not covered here.
Messages are validated like FCM's v1 API: exactly one of token, topic (with or without /topics/) or condition (up to five 'topic' in topics terms with &&, ||, ! and parentheses); notification, data (string values, reserved keys refused), android, apns, webpush and fcm_options with their fields; unknown field names are refused; the payload may be at most 4096 bytes. validate_only: true checks the message and answers projects/<p>/messages/fake_message_id without capturing it.
FCM errors
Errors use Google's JSON, { "error": { "code", "message", "status", "details" } }, with an FcmError detail whose errorCode client libraries map to their own errors:
| HTTP | status |
errorCode |
When |
|---|---|---|---|
| 400 | INVALID_ARGUMENT |
INVALID_ARGUMENT |
Invalid message (field violations in a BadRequest detail), a malformed token, a registered device simulating a bad token, or an unregistered token when the inbox rejects unknown tokens |
| 404 | NOT_FOUND |
UNREGISTERED |
A registered device simulating an uninstalled app |
| 403 | PERMISSION_DENIED |
SENDER_ID_MISMATCH |
A device simulating "wrong app", or a token registered to another push inbox of your team |
| 429 | RESOURCE_EXHAUSTED |
QUOTA_EXCEEDED |
Over 50 requests per second for one inbox, or the monthly quota is used up |
| 401 | UNAUTHENTICATED |
Missing, invalid, expired or revoked access token | |
| 403 | PERMISSION_DENIED |
The URL's project is not the token's project, or the token lacks the messaging scope |
Test devices and simulated outcomes
On the Test devices tab (or POST /api/push-inboxes/:id/devices), register the tokens your tests use and pick what a send to each does:
| Outcome | APNs | FCM |
|---|---|---|
| Deliver | 200, captured | 200, captured |
| Unregistered | 410 Unregistered |
404 UNREGISTERED |
| Bad token | 400 BadDeviceToken |
400 INVALID_ARGUMENT |
| Wrong app | 400 DeviceTokenNotForTopic |
403 SENDER_ID_MISMATCH |
An APNs device can also be bound to a bundle ID; sends with another apns-topic fail with DeviceTokenNotForTopic. Tokens that are not registered are captured by default; set Unregistered tokens to Reject in Settings to treat them as bad tokens. Rejected sends are not captured and do not count toward the quota.
Assert on the capture
Use a team API key to wait for a notification. token is the device token, contains matches the title or body, and since avoids older captures:
curl -f -H "Authorization: Bearer $TESTLIE_API_KEY" \
"https://testlie.com/api/push-inboxes/$PUSH_INBOX_ID/notifications/wait?token=$DEVICE_TOKEN&contains=code&timeout=30"A match returns HTTP 200 with { notification }, including extracted.codes (likely one-time codes, best first) and extracted.links, taken from the title, body and string data values. A timeout returns HTTP 408. From a Node.js test:
const res = await fetch(`${TESTLIE_URL}/api/push-inboxes/${pushInboxId}/notifications/wait?token=${deviceToken}&since=${startedAt}&timeout=30`, {
headers: { Authorization: `Bearer ${process.env.TESTLIE_API_KEY}` },
});
const { notification } = await res.json();
const code = notification.extracted.codes[0];| Method and path | Description |
|---|---|
GET, POST /api/push-inboxes |
List or create push inboxes ({ "name", "projectId"?, "apnsTopics"?, "unknownTokens"? }); responses include the APNs key and the service-account JSON |
GET, PATCH, DELETE /api/push-inboxes/:id |
Read, update (name, projectId, apnsTopics, unknownTokens: accept or reject) or delete |
POST /api/push-inboxes/:id/apns-key |
{ "generate": true, "teamId"? } for a new key, or { "p8", "keyId", "teamId" } to use your own |
POST /api/push-inboxes/:id/fcm-key |
Rotate the service-account key |
GET, POST /api/push-inboxes/:id/devices |
List or register test devices ({ "platform": "apns" | "fcm", "token", "label"?, "topic"?, "outcome"? }) |
PATCH, DELETE /api/push-inboxes/:id/devices/:deviceId |
Change (label, topic, outcome) or remove a device |
GET /api/push-inboxes/:id/notifications |
List, newest first: service, token, topic, q, page, limit (max 500) |
PATCH, DELETE /api/push-inboxes/:id/notifications |
Mark all read; empty the inbox |
GET /api/push-inboxes/:id/notifications/wait |
Long-poll: service, token, topic, contains, since, timeout (1-120 s) |
GET, PATCH, DELETE /api/push/:id |
One notification with extracted codes and links; mark read; delete |
GET /api/push/:id/extract |
{ id, codes, links } |
Team members can read captures and manage test devices; owners and admins manage inboxes and keys.
Webhooks
Team-wide webhooks subscribed to push.received get a signed JSON POST for every captured notification (same signing as the other events):
{ "event": "push.received",
"pushInbox": { "id": "…", "name": "Mobile app" },
"notification": { "id": "…", "service": "apns", "messageId": "…", "token": "…", "topic": "com.example.app",
"title": "Sign in", "body": "Your code is 482913", "payload": { "aps": { … } }, "receivedAt": "…", "url": "…" } }Subscribe on the Webhooks page, with POST /api/webhooks { "url", "events": ["push.received"] }, or from the push inbox's Settings tab.
Limits
Push uses the SMS limits of your plan rather than separate ones: a plan allows as many push inboxes as SMS inboxes, and as many push notifications per month as SMS, counted separately. Each push inbox keeps the plan's messages kept per inbox, and captures are deleted after the plan's retention. Over the monthly quota, APNs answers 429 TooManyRequests and FCM 429 QUOTA_EXCEEDED. See Plans & limits.