Skip to content

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.

PushUpdated 19 October 20268 min read

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 sendEach and sendEachForMulticast. The older sendAll and sendMulticast used 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 inside data is 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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Is the FCM legacy API still working?

No. Firebase deprecated sending through the legacy HTTP and XMPP APIs on 20 June 2023 and began shutting them down on 22 July 2024. Requests to fcm.googleapis.com/fcm/send with a server key no longer deliver. Use the HTTP v1 API.

Can HTTP v1 send to multiple tokens in one request?

No. One v1 request carries one message for one target, which can be a token, a topic or a condition. To reach many devices, send one request per token (the Admin SDK does this for you with sendEach and sendEachForMulticast) or subscribe the devices to a topic.

Where do I get the access token for Postman?

From a service account. Download its JSON key from the Firebase console under Project settings, Service accounts, then exchange it for a short-lived OAuth 2.0 token with the firebase.messaging scope, for example with the google-auth-library in Node or gcloud. The token lasts about an hour.

Do device tokens change when I migrate?

No. Tokens your app already has keep working with HTTP v1. Only the server side changes; the endpoint, the authentication and the payload shape.

Send your first push today.

Free for 1,000 push devices, 100 approvals a day, 10,000 passkey sign-ins a month and 500 email codes to try. No card until you pass a free amount.