A Google Maps place URL and a Google Place ID (the ChIJ... string half the Places tooling on the internet asks you for) encode the same underlying identifier. You can convert between them with about 40 lines of JavaScript and no API key, no billing account, and no network call, because the identifier survives the trip as plain bytes. This post writes that conversion out in full, verifies it against a real, known Google place, and is explicit about the two things most write-ups of this trick skip: which URL shapes actually carry the identifier, and that the format is observed, not documented, so it can change under you.
The short version
A normal Google Maps place URL (the one in your address bar after you click a business) embeds the place's internal "feature id" as a pair of 64-bit hex numbers, joined by a colon, in one of two spots:
.../data=!3m1!4b1!4m6!3m5!1s0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52!8m2...
^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
a b
.../maps?ftid=0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52
^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
a b
A ChIJ-shaped Place ID is that same a:b pair, packed into a tiny two-field protobuf message and base64url-encoded. Decode the Place ID and you get the pair back, plus, for free, the numeric CID Google uses in https://maps.google.com/?cid=... links, because the CID is just b read as decimal.
Why this works: the wire bytes
The message is small enough to write by hand rather than pull in a protobuf library:
message FeatureId {
message CellId {
fixed64 high = 1; // wire tag 0x09 = (field 1 << 3) | 1 (fixed64)
fixed64 low = 2; // wire tag 0x11 = (field 2 << 3) | 1 (fixed64)
}
CellId cell_id = 1; // wire tag 0x0a = (field 1 << 3) | 2 (length-delimited), length byte 0x12 (18)
}
Protobuf's fixed64 wire type stores the 8 bytes of the number little-endian: the least-significant byte comes first, not last. So the full byte layout of a Place ID of this shape is:
[0x0a, 0x12, 0x09, <a, 8 bytes, little-endian>, 0x11, <b, 8 bytes, little-endian>]
That's 1 (outer tag) + 1 (length 18) + 1 (inner tag for a) + 8 (a) + 1 (inner tag for b) + 8 (b) = 20 bytes, base64url'd (+// swapped for -/_, no padding).
The code
Plain JS, no dependencies. btoa/atob exist natively in a browser tab; the two one-liners below let the same file run unmodified under Node 22 as well.
function hexToLeBytes(hex) {
const h = hex.replace(/^0x/i, '').padStart(16, '0');
const out = [];
for (let i = 14; i >= 0; i -= 2) out.push(parseInt(h.slice(i, i + 2), 16));
return out; // 8 bytes, least-significant first
}
function leBytesToHex(bytes) {
let hex = '';
for (let i = bytes.length - 1; i >= 0; i--) hex += bytes[i].toString(16).padStart(2, '0');
return '0x' + hex.replace(/^0+(?=.)/, '');
}
function bytesToBase64Url(bytes) {
const bin = String.fromCharCode(...bytes);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bin, 'binary').toString('base64');
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlToBytes(b64url) {
let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const bin = typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
const out = new Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// feature id (a, b as "0x..." hex strings) -> Place ID
function featureIdToPlaceId(aHex, bHex) {
const bytes = [0x0a, 0x12, 0x09, ...hexToLeBytes(aHex), 0x11, ...hexToLeBytes(bHex)];
return bytesToBase64Url(bytes);
}
// Place ID -> { aHex, bHex, cid }, only for this 20-byte, single-CellId shape
function placeIdToFeatureId(placeId) {
const bytes = base64UrlToBytes(placeId);
if (bytes.length !== 20 || bytes[0] !== 0x0a || bytes[1] !== 0x12 || bytes[2] !== 0x09 || bytes[11] !== 0x11) {
return null; // not this Place ID shape
}
const aHex = leBytesToHex(bytes.slice(3, 11));
const bHex = leBytesToHex(bytes.slice(12, 20));
const cid = BigInt(bHex).toString(10); // decimal form used in ?cid= links
return { aHex, bHex, cid };
}
// pull the a:b pair out of a real Maps place URL
function parseMapsUrlFeatureId(url) {
const m =
/!1s(0x[0-9a-f]{1,16}):(0x[0-9a-f]{1,16})/i.exec(url) ||
/[?&]ftid=(0x[0-9a-f]{1,16}):(0x[0-9a-f]{1,16})/i.exec(url);
return m ? { aHex: m[1], bHex: m[2] } : null;
}
Verified against a real place, output pasted as-is
0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52 is the feature id pair for the Google Sydney office; ChIJN1t_tDeuEmsRUsoyG83frY4 is its published Place ID. Running the four functions above against that pair, node 2026-09-25-devto-8-verify.mjs:
--- 1. encode: feature id -> Place ID ---
input a:b = 0x6b12ae37b47f5b37 : 0x8eaddfcd1b32ca52
output Place ID = ChIJN1t_tDeuEmsRUsoyG83frY4
matches known Place ID: true
--- 2. decode: Place ID -> feature id + CID ---
input Place ID = ChIJN1t_tDeuEmsRUsoyG83frY4
output a:b = 0x6b12ae37b47f5b37 : 0x8eaddfcd1b32ca52
output CID (decimal of b) = 10281119596374313554
matches known a:b: true
matches known CID: true
cid link: https://maps.google.com/?cid=10281119596374313554
--- 3. round trip: decode(encode(a,b)) === (a,b) ---
round trip a:b = 0x6b12ae37b47f5b37 : 0x8eaddfcd1b32ca52
round trip OK: true
--- 4. URL parser ---
https://www.google.com/maps/place/Google+Sydney/@-33.86,151.19,17z/dat...
-> a:b = 0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52, Place ID = ChIJN1t_tDeuEmsRUsoyG83frY4
https://www.google.com/maps?ftid=0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52
-> a:b = 0x6b12ae37b47f5b37:0x8eaddfcd1b32ca52, Place ID = ChIJN1t_tDeuEmsRUsoyG83frY4
https://www.google.com/maps/search/dentist/@40.7,-74.0,13z
-> null (no single place in this URL)
https://www.google.com/maps/@40.7,-74.0,13z
-> null (no single place in this URL)
--- 5. byte layout of the verified Place ID, for the write-up ---
decoded bytes (hex): 0a 12 09 37 5b 7f b4 37 ae 12 6b 11 52 ca 32 1b cd df ad 8e
byte count: 20
ALL CHECKS PASS: true
Reading the hex dump against the message shape from earlier: 0a 12 09 is the outer tag, length and inner tag; 37 5b 7f b4 37 ae 12 6b is a little-endian (reverse it and you get 6b12ae37b47f5b37, matching the input); 11 is the tag for b; 52 ca 32 1b cd df ad 8e is b little-endian, reversing to 8eaddfcd1b32ca52.
What this does and does not cover
This is an observed, undocumented format, reverse-engineered from real Maps URLs, not a published Google API contract, and Google is free to change it without notice. Scope it accordingly:
- It only covers Place IDs of this exact shape: a 20-byte message wrapping one
CellIdwith two fixed64 fields. Other Place ID formats Google has issued over the years won't round-trip throughplaceIdToFeatureId(it returnsnullrather than a wrong answer, by design). - A
maps.app.goo.glshort link (what a phone's Share button gives you) has to be opened first, with the redirect followed, before the resulting URL contains a!1s...orftid=pair. The short link itself carries neither. - A search results URL or a bare area/coordinate URL (
/maps/search/..., or/maps/@lat,lng,zoomwith no place selected) doesn't name one business, soparseMapsUrlFeatureIdcorrectly returnsnullfor both, as shown in the output above. - Nothing here calls Google. It's string and byte manipulation on a URL you already have in hand.
Why it's worth having in your browser tab
The Place ID is what a direct Google review link is actually keyed to: https://search.google.com/local/writereview?placeid=YOUR_PLACE_ID opens the write-a-review form for one specific business record, no more, no less. Most people trying to build that link don't have a Place ID sitting around, they have the Maps URL they were just looking at, copied straight from the address bar, which is exactly the input this decode step is built to take. Our free Google review link generator runs this same conversion client-side, in the browser tab, the moment you paste a Maps link in: nothing you paste leaves the page.
Getting the identifier right matters more than it looks, because a review typed through the wrong link lands on the wrong record and never surfaces where a future customer is searching. Our guide to getting more Google reviews goes into why the ask has to point at your one, current profile rather than a stale duplicate, and if the Maps URL you're starting from won't resolve to a business at all, our list of causes and fixes for a business not showing on Google Maps covers the more likely reasons before you go looking for a bug in your own code.
One thing the Place ID does not tell you is what the business is categorized as; that's a separate field on the same record, picked from a fixed Google list rather than encoded in the identifier. If you're setting that up too, our breakdown of Google Business Profile category picks covers how the primary and additional categories are chosen.
Top comments (0)