FCM Debugging

Debugging FCM INVALID_ARGUMENT, in order of likelihood

The FCM HTTP v1 API returns INVALID_ARGUMENT for about a dozen unrelated mistakes and tells you almost nothing about which one you made. Here is the list, ordered by how often it is actually the cause.

· 3 min read · Notibase

INVALID_ARGUMENT is the least helpful error in push, because FCM uses it for everything from a typo in your project id to a number where a string should be, and the response body is frequently one line that repeats the error name back at you.

Here is the checklist, in the order these actually turn out to be the problem.

1. A value in data is not a string

This is most of them. The data field in the HTTP v1 API is typed map<string, string>. Not map<string, any>. Every value must be a JSON string, and a single number, boolean or nested object fails the entire send.

{ "message": {
  "token": "…",
  "data": { "order_id": 41288, "vip": true, "meta": { "x": 1 } }
}}

Three violations there. What it has to be:

{ "message": {
  "token": "…",
  "data": { "order_id": "41288", "vip": "true", "meta": "{\"x\":1}" }
}}

Nested data gets JSON-encoded into a string and parsed on the client. This is the one that bites when a field is usually a string — a user id that is a UUID for everyone except the seed accounts, where it is an integer — because then it works in testing and fails for a subset of your audience in production.

If you are building payloads dynamically, coerce at the boundary and be done with it:

const data = Object.fromEntries(
  Object.entries(raw).map(([k, v]) => [k, typeof v === "string" ? v : JSON.stringify(v)])
);

2. The project id in the URL is not the project the token belongs to

The v1 endpoint carries the project:

POST https://fcm.googleapis.com/v1/projects/PROJECT_ID/messages:send

If PROJECT_ID does not match the service account you are authenticating with, you get a 403 rather than INVALID_ARGUMENT — but if it does not match the project whose google-services.json shipped in the app that minted the token, you get SENDER_ID_MISMATCH, and people frequently misread the two. Check all three agree: the URL, the service account JSON, and the config file in the app build.

3. apns and android blocks are misnested

The platform blocks have a specific shape, and putting an APNs key one level up or down is an easy mistake in a hand-built payload:

{ "message": {
  "token": "…",
  "apns": {
    "headers": { "apns-priority": "10" },
    "payload": { "aps": { "mutable-content": 1, "sound": "default" } }
  },
  "android": {
    "priority": "high",
    "notification": { "channel_id": "orders" }
  }
}}

Everything Apple-shaped goes under apns.payload.aps. Anything of yours goes under apns.payload next to aps, not inside it. apns.headers values are strings, including the priority — "10", not 10.

4. TTL is not a duration string

android.ttl is a protobuf duration, which serialises to JSON as a string of seconds ending in s: "3600s". An integer fails. So does "1h".

5. The token is empty or whitespace

An empty token string is INVALID_ARGUMENT, not "token not found". If you are reading tokens out of a database column that is nullable, you will eventually send "" and spend twenty minutes reading Google's error reference about payload structure.

6. notification.image is not a reachable https URL

FCM validates the shape but the client fetches the image. An http:// URL, or a URL requiring auth, gives you either a rejection or — worse — an accepted send that renders without the picture.

7. Both notification and a colliding data key

Not strictly an error, but the cause of the "my handler never runs" bug that follows: on Android, a message containing a notification block is displayed by the system when the app is backgrounded and your code never sees it. If you need your own handler to run in all states, send data-only and build the notification yourself.

How to actually see the error

The single most useful habit is to keep the provider's raw response body rather than mapping it to a status. Google frequently includes a fieldViolations array that names the exact field:

{ "error": { "code": 400, "status": "INVALID_ARGUMENT",
  "details": [{ "@type": "type.googleapis.com/google.rpc.BadRequest",
    "fieldViolations": [
      { "field": "message.data[order_id]", "description": "Invalid value at 'message.data[0].value'" }
    ]}]}}

That tells you everything, and it is discarded by every platform that stores a failed boolean. Notibase records the response body per device and shows it in the delivery log, because there is no clever inference that beats reading what the provider said. The mapping to our own codes is in Delivery errorsINVALID_ARGUMENT lands under address_invalid or payload_invalid depending on which field violated, and the raw text is right there either way.