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, like api.push.apple.com.
  • FCM HTTP v1-compatible: POST /v1/projects/{projectId}/messages:send with an OAuth2 access token, like fcm.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:

js
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
# 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:

bash
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:

js
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):

json
{ "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.