Migrating from the FCM legacy API to HTTP v1
The FCM legacy API is shut down. How to move to HTTP v1: service account tokens, the new payload, sending to many tokens, and Node, PHP and cURL examples.
If your server still posts to https://fcm.googleapis.com/fcm/send with a server key, it has stopped delivering. Firebase deprecated the legacy HTTP and XMPP APIs on 20 June 2023 and began shutting them down on 22 July 2024. The replacement is the HTTP v1 API. Your app does not change. Your server does, in three places: how it authenticates, where it sends, and what the payload looks like.
What changes between legacy and v1
| Legacy API | HTTP v1 | |
|---|---|---|
| Endpoint | POST /fcm/send |
POST /v1/projects/PROJECT_ID/messages:send |
| Authentication | Authorization: key=SERVER_KEY, never expires |
Authorization: Bearer ACCESS_TOKEN, from a service account, lasts about an hour |
| Many devices | registration_ids with up to 1,000 tokens |
One target per request: a token, a topic or a condition |
| Platform options | Mixed into one object | Separate android, apns and webpush blocks |
| Data values | Any JSON | Strings only |
Step 1: get a service account key
In the Firebase console open Project settings, Service accounts and generate a new private key. Keep the JSON file out of your repo and your app. It is a password to every device of your project.
Step 2: turn the key into an access token
The token is an OAuth 2.0 bearer token with the scope https://www.googleapis.com/auth/firebase.messaging. Libraries fetch and refresh it for you.
// Node: npm install google-auth-library
import { GoogleAuth } from "google-auth-library";
const auth = new GoogleAuth({
keyFile: "service-account.json",
scopes: ["https://www.googleapis.com/auth/firebase.messaging"],
});
const token = await auth.getAccessToken();
Step 3: send with the v1 payload
curl -X POST \
"https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "DEVICE_TOKEN",
"notification": { "title": "Order shipped", "body": "Arriving today" },
"data": { "order_id": "88213" },
"android": { "priority": "high" },
"apns": { "headers": { "apns-priority": "10" } }
}
}'
Two things trip people up here. Every value in data must be a string, so send "88213", not 88213. And priority moved into the android block.
The same in PHP
// composer require google/auth guzzlehttp/guzzle
use Google\Auth\Credentials\ServiceAccountCredentials;
$creds = new ServiceAccountCredentials(
'https://www.googleapis.com/auth/firebase.messaging',
json_decode(file_get_contents('service-account.json'), true)
);
$token = $creds->fetchAuthToken()['access_token'];
$client = new GuzzleHttp\Client();
$client->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [
'headers' => ['Authorization' => "Bearer {$token}"],
'json' => ['message' => [
'token' => $deviceToken,
'notification' => ['title' => 'Order shipped', 'body' => 'Arriving today'],
]],
]);
Step 4: replace registration_ids
HTTP v1 has no list of tokens. You have two choices:
- One request per token. The Firebase Admin SDK does this in parallel with
sendEachandsendEachForMulticast. The oldersendAllandsendMulticastused a batch endpoint that was retired with the legacy API. - Topics. Subscribe devices to a topic once and send one message to
"topic": "offers". FCM fans it out.
// npm install firebase-admin
import { initializeApp, cert } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";
initializeApp({ credential: cert("service-account.json") });
const res = await getMessaging().sendEachForMulticast({
tokens: deviceTokens, // up to 500 per call
notification: { title: "Order shipped", body: "Arriving today" },
});
console.log(res.successCount, "sent,", res.failureCount, "failed");
Step 5: handle the new errors
v1 errors are clearer than legacy ones. The two you will see most:
UNREGISTERED(404): the token is dead, usually because the app was uninstalled. Delete it from your database.INVALID_ARGUMENT(400): a malformed token or payload. A number insidedatais the usual cause.
Keep the cleanup automatic. A list of dead tokens slows every send and inflates every count.
If you would rather not run this
All of the above is what SignOps Push does: it holds your service account and APNs key, sends through HTTP v1 to every device a user has, removes dead tokens and tells your server by webhook. You call one endpoint with your own user id. It is free for 1,000 devices.
Sources: Firebase: migrate from legacy FCM APIs to HTTP v1, Firebase: FCM throttling and quotas.