TL;DR: To get WordPress data into Firebase, write it to Cloud Firestore over REST. Sign a service-account JWT, use it directly as the bearer token, and PATCH a document whose ID comes from WordPress, so a retry overwrites instead of duplicating.
- For Firestore, a self-signed JWT with
aud: https://firestore.googleapis.com/is the bearer token. You don't call the token endpoint, and you don't need an SDK. - Requests signed by a service account skip Security Rules. IAM is the only thing that limits them, and IAM roles cover the whole database.
PATCHon a document "updates or inserts" it. With the ID set from WordPress, a repeated delivery lands on the same document.- Firestore REST needs typed values:
integerValueis a string, maps go inmapValue.fields, and arrays cannot hold arrays. - The free quota is 20,000 writes a day. After that, writes cost $0.09 per 100,000 in us-central1.
/ Which Firebase
What does "WordPress Firebase integration" usually mean?
One of two jobs. The first is login: Firebase Authentication as the identity provider for WordPress users. There are plugins for that, and it is a different problem from this article's. The second is data: posts, orders, form entries or user profiles copied into Cloud Firestore, where a mobile app or a web front end built on Firebase reads them in real time. This article covers the second job.
The data job usually comes up when a team has a Flutter or React Native app on Firebase and WordPress is still where editors work and customers buy. The app should show the new article, the order status or the member's tier without polling WordPress, and Firestore's realtime listeners do exactly that once the data is in Firestore.
/ Two routes
Should WordPress write to Firestore directly or through a Cloud Function?
Both work. The difference is where the credential lives and how much it can do.
- Directly over REST. WordPress holds a service-account key and calls the Firestore REST API. There is nothing else to deploy, and it works on the free Spark plan. The cost is that the key can write anywhere in the database its IAM role covers.
- Through an HTTP Cloud Function. WordPress posts plain JSON to an
onRequestfunction with a shared-secret header. The function writes to one collection with the Admin SDK. The key never leaves Google, and the function decides what WordPress is allowed to change. The project has to be on the Blaze plan to deploy functions.
The security difference is bigger than it looks, and it comes from one sentence in the REST docs: with a service account and an OAuth token, "Cloud Firestore allows these requests to ignore your security rules. Instead, Cloud Firestore uses IAM." The rules you wrote for the app do not apply to WordPress. If the WordPress database or file system leaks the key, the attacker can write to every collection.
A useful rule of thumb: if you are already on Blaze, use the function. If you are not, write directly, and give the service account the narrowest role that works, Cloud Datastore User rather than Owner.
/ Auth
How does WordPress authenticate to Firestore without an SDK?
With a JWT that it signs itself and sends directly as the bearer token. Google's service account guide has an addendum on "Service account authorization without OAuth". For APIs that support it, a signed JWT works in place of an access token, with no request to the authorization server first. Its own example uses "aud": "https://firestore.googleapis.com/".
The claims come straight from the service-account JSON file. iss and sub are its client_email, kid is its private_key_id, exp is "exactly 3600 seconds" after iat, and the signature is RS256 with its private_key. PHP's openssl_sign() produces RS256 with no extra steps:
PHP — self-signed JWT for the Firestore REST API
function fs_b64url( string $s ): string { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); } function fs_bearer(): string { $cached = get_transient( 'fs_bearer' ); if ( $cached ) { return $cached; } $sa = json_decode( file_get_contents( FS_SERVICE_ACCOUNT_FILE ), true ); // outside the web root $now = time(); $jwt = fs_b64url( json_encode( [ 'alg' => 'RS256', 'typ' => 'JWT', 'kid' => $sa['private_key_id'] ] ) ) . '.' . fs_b64url( json_encode( [ 'iss' => $sa['client_email'], 'sub' => $sa['client_email'], 'aud' => 'https://firestore.googleapis.com/', 'iat' => $now, 'exp' => $now + 3600, ] ) ); openssl_sign( $jwt, $sig, $sa['private_key'], OPENSSL_ALGO_SHA256 ); $jwt .= '.' . fs_b64url( $sig ); set_transient( 'fs_bearer', $jwt, 55 * MINUTE_IN_SECONDS ); return $jwt; }
The transient keeps the JWT for 55 of its 60 minutes. A retry an hour later signs a new one instead of sending an expired token.
/ Writes
How do you write a WordPress post or order as a Firestore document?
With PATCH to the document's full path, using a document ID you choose. The patch method "updates or inserts a document". When the document does not exist it is created, and when it does exist it is updated. Using the WordPress ID as the document ID makes each event idempotent: orders/1234 is always the same document, however many times the delivery is retried.
Firestore REST does not accept plain JSON. Every value is wrapped in an object that names its type, as the Value reference describes:
PHP — plain PHP values to Firestore typed values, then the PATCH
function fs_value( $v ): array { // PHP 8.1+ (array_is_list) if ( is_null( $v ) ) return [ 'nullValue' => null ]; if ( is_bool( $v ) ) return [ 'booleanValue' => $v ]; if ( is_int( $v ) ) return [ 'integerValue' => (string) $v ]; // a string, by spec if ( is_float( $v ) ) return [ 'doubleValue' => $v ]; if ( is_array( $v ) ) { return array_is_list( $v ) ? [ 'arrayValue' => [ 'values' => array_map( 'fs_value', $v ) ] ] : [ 'mapValue' => [ 'fields' => array_map( 'fs_value', $v ) ] ]; } return [ 'stringValue' => (string) $v ]; } function fs_upsert( string $collection, string $id, array $data ) { $url = 'https://firestore.googleapis.com/v1/projects/' . FS_PROJECT . '/databases/(default)/documents/' . $collection . '/' . rawurlencode( $id ); $mask = implode( '&', array_map( fn( $f ) => 'updateMask.fieldPaths=' . rawurlencode( $f ), array_keys( $data ) ) ); return wp_remote_request( $url . '?' . $mask, [ 'method' => 'PATCH', 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . fs_bearer(), 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( [ 'fields' => array_map( 'fs_value', $data ) ] ), ] ); } fs_upsert( 'orders', (string) $order->get_id(), [ 'status' => $order->get_status(), 'total' => (float) $order->get_total(), // WooCommerce returns a string 'items' => count( $order->get_items() ), 'updated' => time(), ] );
Three details in that code:
updateMask. Without it, a PATCH replaces the whole document, so any field the app wrote (say,seenByCustomer) is deleted. With the mask, "fields not referenced in the mask … are left unchanged".- Numbers that arrive as strings. WooCommerce stores prices as strings. Send
"25.00"and the app cannot sort or sum by total. Cast first. - Arrays inside arrays. An array value "cannot directly contain another array value". A list of line items, each with its own list of meta, needs each item to be a map.
If you would rather have create-only behaviour, the patch method also takes a precondition, currentDocument.exists=false, and createDocument with a documentId refuses an ID that already exists. Both turn a retry into an error rather than a no-op. For syncing, the plain upsert is the safer choice.
A service account doesn't pass through your Security Rules. It goes around them. The rules your app relies on do not protect you from WordPress. — the line in the REST docs that changes the design
| Concern | Hand-rolled call on save_post | Webhook Actions |
|---|---|---|
| When the call runs | Inside the editor's or customer's request; a slow Google response slows the page | Queued and sent in the background, after the request |
| Firestore unreachable or a 5xx | The change is lost unless you build a retry | Retried with backoff; the document ID makes the repeat land on the same document |
| Typed-value conversion | The fs_value() function above | Still yours: a pre-dispatch snippet that returns the fields object |
| The bearer JWT | Signed in your code | Signed in your code too, added through the fswa_headers filter; the log stores Authorization as [redacted] |
| Seeing what was sent | error_log, if you added one | Request and response per attempt in the delivery log, with replay |
| Cloud Function route instead | Same code, different URL | Point the webhook at the function URL with a static secret header from the vault, no JWT at all |
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Function
What does the Cloud Function receiver look like?
Small. It checks a shared secret, accepts one shape of document for one collection, and writes it with the Admin SDK. The Admin SDK uses the function's own service account, so WordPress holds only the secret. HTTP functions can set a timeout of up to an hour, and a single document write needs nowhere near that.
JavaScript — HTTP function that accepts WordPress orders
const { onRequest } = require('firebase-functions/v2/https'); const { initializeApp } = require('firebase-admin/app'); const { getFirestore } = require('firebase-admin/firestore'); initializeApp(); exports.wpOrder = onRequest({ secrets: ['WP_SECRET'] }, async (req, res) => { if (req.method !== 'POST' || req.get('X-WP-Secret') !== process.env.WP_SECRET) { return res.status(401).end(); } const o = req.body; if (!o || !o.id) return res.status(400).end(); // not a retry case try { await getFirestore().collection('orders').doc(String(o.id)).set({ status: String(o.status), total: Number(o.total), updated: Date.now(), }, { merge: true }); // same upsert semantics as PATCH + mask } catch (e) { return res.status(500).end(); // worth a retry } res.status(200).json({ ok: true }); });
The status codes matter as much as the write. A 401 or 400 tells the sender not to try again, because the request itself is wrong. If the write throws, the function returns 500, and a sender that retries on 5xx tries again. The doc(String(o.id)) makes that retry harmless.
/ Limits
What are the Firestore limits and costs for WordPress writes?
From the quotas page: a document can be at most 1 MiB, an API request at most 10 MiB, and maps or arrays can nest at most 20 levels deep. A WordPress post with its full HTML content fits easily. A post with every revision does not, so leave revisions out.
A single document has no fixed write rate. The best-practices page says it "depends highly on the workload". The case to avoid is many events updating one shared document, such as a site-wide "latest orders" counter. Write one document per post or per order, and let the app query the collection.
Cost, from the Firestore pricing table for us-central1, with each event counting as one write:
- Free quota: 20,000 writes a day. A store with 500 orders a day, each updated four times as its status changes, uses 500 × 4 = 2,000 writes, which is 10% of the quota.
- Beyond the free quota: $0.09 per 100,000 writes. One million paid writes a month is 1,000,000 ÷ 100,000 × $0.09 = $0.90.
The writes are rarely the expensive part. The reads are: every app user's realtime listener is charged per document it reads, at $0.03 per 100,000 beyond 50,000 a day. A popular app reading the collection you publish will cost more than WordPress writing to it.
/ Exposure
What does Firebase not protect you from?
A leaked key with no rules in front of it. Covered above, and worth repeating: Security Rules do not apply. Store the service-account file outside the web root, never in the database, and rotate it if WordPress is ever compromised.
Deletes that never arrive. Trashing a post in WordPress fires its own hook. If you only sync on save, the app keeps showing deleted content. Send a delete, or a deleted: true field, from the trash and delete hooks too.
Changes that arrive out of order. Two quick saves can be delivered in either order after a retry. Send updated as a timestamp and have the app, or the function, ignore anything older than what it has.
The same retried, logged delivery is what keeps the other integrations on this site consistent. Why WordPress webhooks silently fail explains what goes wrong without it, and the retry policy article covers the schedule.