Baobaobaolin.com
date
entry
028
topic
integration
rev
—

Taiwan's company registry API: $filter vanishes in the shell, and a bad $format returns 200

For company lookups from code, the right entrance is the official open-data API, not the search pages behind a block page and a CAPTCHA. But this API returns a bad $format as HTTP 200, and its parameter names get expanded by the shell inside double quotes.

To look up a Taiwanese company's registration from code, my first idea was to fetch pages from the Ministry of Economic Affairs company registry search (findbiz). That route didn't work this time, and I'm not going to force it. The same basic registration data is available from the government's commerce open-data platform, through an API whose requests carry no key and which returns JSON.

But this API has three ways to make a failing script look successful: the $ in its query parameters gets eaten by the shell, a bad $format returns HTTP 200, and "not found" also returns 200 with an empty body. This note covers why not to fetch the search pages, what a correct request looks like, each of the three traps, and how I check responses now.

Why not fetch the search pages

Hitting the search pages directly with curl on 2026-10-12:

findbiz.nat.gov.tw/fts/company/88187108    403  Attention Required! | Cloudflare
fbfh.trade.gov.tw/fb/web/queryBasicf.do    200  page has a CAPTCHA field (驗證碼, verifyCode)

For this curl request, findbiz returned a Cloudflare block page. The International Trade Administration's exporter and importer search opens, but its form needs a CAPTCHA. I read both as "these pages are meant for people in a browser", so I don't work around them. When you need lookups in bulk, look for the official entrance meant for programs.

A correct request

The commerce open-data platform (data.gcis.nat.gov.tw) has an API for basic company registration data (listed as 公司登記基本資料-應用一). It uses OData-style query parameters: $format, $filter, $skip, $top. Using Chun Yu Works, a listed company (business number 88187108), as the example, with -w to print the status, Content-Type and length:

API=https://data.gcis.nat.gov.tw/od/data/api/5F64D864-61CB-4D0D-8AD9-492047CC1EA6
curl -sS -o ok.json -w 'http=%{http_code} type=%{content_type} bytes=%{size_download}\n' \
  "$API"'?$format=json&$filter=Business_Accounting_NO%20eq%2088187108&$skip=0&$top=50'
http=200 type=application/json;charset=UTF-8 bytes=546

What's in ok.json (excerpt, some fields omitted):

[{"Business_Accounting_NO":"88187108","Company_Status_Desc":"核准設立",
  "Company_Name":"春雨工廠股份有限公司","Capital_Stock_Amount":3920696000,
  "Paid_In_Capital_Amount":3021627000,"Responsible_Name":"翁重鈞",
  "Company_Setup_Date":"0540327", ...}]

Dates use Taiwan's Minguo calendar, a three-digit year then two-digit month and day: 0540327 is year 54, March 27; add 1911 and you get 1965. Note that the query string is in single quotes. The next section is why.

"No key in the request" doesn't mean "no need to ask". The platform's integration guide asks units or individuals using its system APIs to file a usage notice, says access is controlled by a whitelist of source IPs, and lists limits on concurrent connections and daily calls. My handful of test requests got answers without a notice filed; how far an unregistered IP can go, I don't know. For real batch work, file the notice first.

Trap one: inside double quotes, $format is a shell variable

Putting the whole URL in double quotes is the natural way to write it:

$ echo "$API?$format=json&$filter=Business_Accounting_NO%20eq%2088187108&$skip=0&$top=50"
https://data.gcis.nat.gov.tw/od/data/api/5F64D864-...?=json&=Business_Accounting_NO%20eq%2088187108&=0&=50

$format, $filter, $skip and $top are all expanded as shell variables. When they aren't set and the shell doesn't have set -u on, they become empty strings (with set -u you'd get an unbound-variable error instead, which is better). Sending that same double-quoted URL, the server replies:

http=200 type=text/html;charset=UTF-8 bytes=43
$format參數有誤,請查明後繼續。

The message says the $format parameter is wrong, word for word as listed in the platform's integration guide.

Trap two: a bad $format still returns 200

That error is HTTP 200, curl exits 0, the Content-Type changes from application/json to text/html, and the body is one line of Chinese. A script that only checks curl -f or the status code treats it as success. Leaving $format out entirely got me the same 200 and the same message. What other parameter errors, rate limiting or maintenance return, I didn't test.

Trap three: not found is 0 bytes, not []

Testing with the business number 00000000:

http=200 type=application/json;charset=UTF-8 bytes=0

The Content-Type says JSON, the body is empty, not even []. Here is how three common parsers react:

$ printf '' | jq '.[0].Company_Name'; echo "exit=$?"
exit=0                                          # no output, no error
$ python3 -c 'import json; json.loads("")'
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
$ node -e 'JSON.parse("")'
SyntaxError: Unexpected end of JSON input

jq quietly prints nothing and exits 0; Python and Node report that the input isn't valid JSON, which easily reads as a server fault. None of them says what it meant in this test: that business number wasn't found. This is the behaviour I measured; I didn't find "not found returns an empty body" in the platform's documentation.

As an aside, if you leave raw spaces in the URL (Business_Accounting_NO eq 88187108), curl 8.7.1 refuses outright: curl: (3) URL rejected: Malformed input to a URL function. At least that one errors.

How I write it now

Let curl do the encoding, keep the literal $ in parameter names, and check the response layer by layer: curl's exit code, the HTTP status, the media type, emptiness, then the JSON structure and whether the business number matches.

ubn=88187108
meta=$(curl -sS -G "$API" -o body.json -w '%{http_code} %{content_type}' \
  --data-urlencode '$format=json' \
  --data-urlencode "\$filter=Business_Accounting_NO eq $ubn" \
  --data-urlencode '$skip=0' --data-urlencode '$top=50') || { echo "curl failed" >&2; exit 1; }
code=${meta%% *}; type=${meta#* }; type=${type%%;*}
[ "$code" = 200 ] || { echo "HTTP ${code}" >&2; exit 1; }
[ "$type" = application/json ] || { echo "not JSON (${type}): $(head -c 200 body.json)" >&2; exit 1; }
[ -s body.json ] || { echo "no company with number ${ubn}" >&2; exit 2; }
jq -s -e --arg u "$ubn" 'length == 1 and (.[0] | type == "array" and length == 1 and .[0].Business_Accounting_NO == $u)' body.json >/dev/null \
  || { echo "unexpected response: $(head -c 200 body.json)" >&2; exit 1; }
jq -r '.[0].Company_Name' body.json

Run under both bash and zsh, three cases: 88187108 prints the company name and exits 0; 00000000 reports no company and exits 2; with $format deliberately misspelled, it reports a non-JSON text/html response carrying the $format error and exits 1.

-G appends the --data-urlencode values to the URL. Spaces become +; the URL actually sent is ?$format=json&$filter=Business_Accounting_NO+eq+88187108&$skip=0&$top=50, and the API returned the same 546-byte JSON. The $filter line uses double quotes so it can include $ubn, which is why the parameter name's $ is escaped as \$.

The braces in ${type} are there for a reason. My Chinese error message originally had ($type) with full-width parentheses, and under this Mac's /bin/bash (3.2.57, locale zh_TW.UTF-8) it printed (��: ...: the Content-Type gone and garbage in its place. The first UTF-8 byte of the closing full-width parenthesis was swallowed into the variable name, so bash looked up a variable that doesn't exist; with LC_ALL=C, or under zsh, the same line prints correctly. It's the same class of problem as entry 023. That time set -u made it crash; this time it was off, so it became quiet garbage. I only saw it because I was sending a bad parameter on purpose to test this failure path.

This is the same point as my note on verifying deploys: HTTP 200 means the HTTP layer succeeded, not that the query did. In my tests this API put "bad $format", "not found" and "found" all under 200, and telling them apart means looking further down, at the Content-Type, the body and the JSON itself.

If you remember one thing

Keep the literal $ in OData parameter names when you're in a shell: single-quote the whole thing, or write \$ inside double quotes. Back to the start: skipping the search pages for the official API is the right direction, but once you switch entrances, deciding whether a call succeeded becomes your script's job.

Revision history

  1. First published