I build and sell Reestri, a small catalog of paid Apify actors that answer one kind of question: who is this company, who owns it, and what has it won from the state, for Georgia, Armenia and Moldova. This is the engineering story: one shared schema across three very different registers, actors that run in normal mode and as MCP servers at the same time, and the mistakes that cost me runs, memory and a weekly sync. Full disclosure: this is a commercial project and the actors charge per event. Every number here is the real one from my logs.
The problem: three public registers, no APIs
Company data for the Caucasus and Moldova exists. It is public, official, and almost unusable programmatically:
- Georgia: the NAPR business registry at enreg.reestri.gov.ge. (Reestri, the product, is not affiliated with any registry; reestri is simply the Georgian word for registry, which is where the name comes from.) Georgian script (mkhedruli), a web application that stores your search state server side in a session cookie, results as HTML table fragments over XHR. No API, no dumps. Detail views with owners and directors sit behind a captcha.
- Armenia: e-register.moj.am, Armenian script, plus something genuinely rare: beneficial ownership declarations published as BODS 0.2 statements (the Beneficial Ownership Data Standard). Nationality, PEP flags, corporate intermediaries. The search caps at 200 results per query.
- Moldova: no usable per-company endpoint (it is reCAPTCHA gated), but the state publishes a weekly open data XLSX with 302,232 companies including directors and founders with share percentages.
Who pays for this? KYC and sanctions screening teams who get a Georgian or Armenian counterparty and currently open four government portals in three alphabets. Investigative journalists tracing ownership across borders. And increasingly, AI agents doing first-pass screening, which is why four of the five public tools in the catalog are also MCP servers.
One decision shaped everything: I do not bypass captchas. A captcha is the registry saying no automation here. Georgia v1 returns search-level data only; ownership data will come through an official channel or not at all. That costs me a feature. It also means a compliance buyer can use the output without wondering how it was obtained.
Architecture: one schema, per-register adapters, one dual-mode entry point
The schema answers three different questions
The first schema draft died in review. I ran the design past a simulated KYC-analyst review before shipping, and the summary was "would test, would not deploy as designed." The rebuilt schema (v2) rests on one idea: an empty dataset is not an answer. Every query produces at least one record, of an explicit type:
export const RESULT_TYPES = ["company", "participation", "contract", "not_found", "unavailable"];
/** A trustworthy negative: the registry answered, nothing matched. */
export function makeNotFound(country, query, evidence, match) {
return { resultType: "not_found", schemaVersion: SCHEMA_VERSION, country, query, match, evidence };
}
/** The registry could not be read. Never to be confused with not_found. */
export function makeUnavailable(country, query, reason, sourceUrl) {
return { resultType: "unavailable", schemaVersion: SCHEMA_VERSION, country, query, reason,
evidence: makeEvidence(sourceUrl, null, null) };
}
not_found and unavailable look similar and mean opposite things. "The registry answered and had no match" clears a compliance check. "The registry was down" does not, and a tool that returns an empty dataset in both cases will eventually let someone clear a name that was never checked.
Every record carries an evidence block: which URL we read, when, and a sha256 of the raw payload we parsed.
export function makeEvidence(sourceUrl, rawPayload, registryAsOf) {
return {
sourceUrl,
retrievedAt: new Date().toISOString(),
registryAsOf: registryAsOf ?? null,
sha256: rawPayload ? createHash("sha256").update(String(rawPayload)).digest("hex") : null,
};
}
And every name search carries a match block that admits ambiguity instead of hiding it: the method used, a score, whether the result set was ambiguous, and how many candidates the registry reported. A compliance analyst needs "3 candidates, ambiguous: true" far more than they need false confidence.
Two more schema rules earned their place. Personal numbers are never output raw; they become a documented hash, sha256("reestri-person-v1:" + country + ":" + number), published in every README so buyers can join people across records and across vendors. No secret salt, because a secret salt makes the keys worthless to everyone but me. This is pseudonymization for record joining, not anonymization; the underlying numbers are already public registry data, I just decline to republish them raw. And unmapped registry statuses stay unmapped: Georgia's statuses are free text, so classification is keyword based, declares its method, and preserves the registry's original wording. A registry adding a new status must never silently read as active.
Dual mode: one actor, two front doors
Each company-data actor has one entry point that checks how it was started:
await Actor.init();
const isStandby = Actor.getEnv().metaOrigin === "STANDBY"
|| process.env.APIFY_META_ORIGIN === "STANDBY";
if (isStandby) {
const { startStandby } = await import("./mcp.mjs");
await startStandby({ mock: useMock }); // stays alive; platform stops it when idle
} else {
// classic run: read input, charge, push records, exit
}
Run mode and MCP mode share one lookup.mjs, so there is exactly one code path against the registry and the two modes cannot drift apart. The actor's webServerMcpPath is set to /mcp in the actor config, which is what lets Apify's MCP infrastructure find the server.
Pricing is pay per event with Actor.charge. Two events per tool: a flat lookup fee per query, and a per-record fee for what comes back.
Lessons, with the code that learned them
Session bootstrap for a stateful registry
NAPR keeps search state server side. Before you can search at all you need its session cookie, and the default result page is small enough that a 30-hit search would take six round trips. The adapter bootstraps a session once, asks the site through its own user-facing mechanism for a larger page size, and reuses the session briefly:
let session = null;
async function getSession() {
if (session && !expired(session)) return session;
const res = await politeFetch(SESSION_URL);
const cookie = extractSessionCookie(res);
if (!cookie) throw new Error("NAPR did not issue a session cookie (site changed?)");
session = { cookie, createdAt: Date.now() };
await requestLargerPageSize(session).catch(() => {}); // best effort
return session;
}
Note the error message. When a government site changes its login flow, I want the run to fail with "site changed?" in the log, not with a cryptic parse error three functions later.
Charge for the question, not only the answer
The lookup event is charged before the registry is queried, and not_found records are pushed without a record charge:
await Actor.charge({ eventName: EVENT_LOOKUP });
const result = await lookupCompanies({ companyNumber, query, ... });
for (const rec of result.records) {
if (rec.resultType === "company") {
const charge = await Actor.pushData(rec, EVENT_RECORD);
if (charge?.eventChargeLimitReached) {
log.warning("Buyer's max charge reached; stopping early."); break;
}
} else {
await Actor.pushData(rec); // not_found / unavailable: no record charge
}
}
if (result.outcome === "unavailable")
await Actor.fail("Registry unavailable: see the `unavailable` record in the dataset. Not a negative result.");
Three things are going on here. First, a verified negative is a real answer in KYC, so the $0.05 lookup fee applies whether or not a company matched; only actual company records cost extra. Second, Actor.pushData(record, eventName) returns a charge result, and when eventChargeLimitReached comes back true the buyer's maximum charge is exhausted, so the loop stops pushing paid records instead of working for free. Third, when the registry is unreachable the run fails on purpose, with the reason in the dataset. A red run is honest; a green run with a silently empty dataset is a lie a buyer pays for.
One Console gotcha: when you enable pay per event, Apify pre-adds an automatic per-dataset-item event. If your code also charges per record, remove the automatic one or buyers get billed twice.
Failing loudly interacts with the Store quality score
That fail loudly rule has a platform consequence I learned the hard way. The day after deploying, the Store showed 0% run success for all three public tools, because my own failed deploy runs dominated the small recent-run window. Apify says the quality score correlates strongly with Store search ranking and with the search-actors MCP ranking. The fix was daily scheduled runs with default input, so the recent window always contains genuine successful runs against the real registries. A related design rule: an actor given empty input runs a stable public demo query rather than calling Actor.fail, because three failed daily platform tests flag an actor as under maintenance. The lesson worth stating plainly: never publish an actor while its recent-run window is red, because buyers see it.
A 4 GB contracts index in a key-value store
Armenia's procurement registry (PPCM) is a public JSON-over-POST service with no session. A weekly private sync actor walks the registry's own public pagination at a polite rate: 237,994 rows, deduplicated to 236,159 unique contracts across 21,781 suppliers. Fields with no analytical value, including contact details, are stripped at ingest and never stored.
The first full run died at the write stage at 960 of 1,024 MB. Building the whole supplier index in memory and then serializing it doubled peak usage exactly when it hurt. The fix: 4 GB of run memory plus write-and-release, sharding by the last two digits of the supplier tax ID into 116 shards, each written and then deleted from the map before the next.
// ingest: route each contract to its shard
const suffix = /^\d+$/.test(taxId) ? taxId.slice(-2)
: "x" + createHash("sha256").update(taxId).digest("hex").slice(0, 1);
shards.get(suffix).push(contract);
// after ingest: write and release, one shard at a time
for (const s of [...shards.keys()]) {
await store.setValue(`tin-${s}`, shards.get(s));
shards.delete(s); // release before the next serialization
}
The public lookup actor then reads only the one shard a tax ID hashes to, answering in 2 to 6 seconds against an index that would never fit in its own memory. The same pattern is built for the Moldova index: a weekly 38.5 MB XLSX stream-parsed into 100 IDNO shards plus 10 name-index parts, 302,232 companies, held private until the agency confirms re-use terms in writing.
The permissions change that broke a weekly sync
Apify runs actors under LIMITED_PERMISSIONS by default, and a limited actor cannot touch named key-value stores owned by the account. My lookup actors needed actorPermissionLevel: FULL_PERMISSIONS from day one to read the index stores, set via the API, accepting the small quality-score cost since the store is my own.
The sync actors were the trap. They had been writing to named stores under LIMITED_PERMISSIONS for two days, and then Apify tightened enforcement: the Saturday run of the Moldova sync passed, the next scheduled run failed mid-write. The fix was one API call to set FULL_PERMISSIONS and a re-run (302,232 companies indexed), plus proactively applying the same fix to the Armenia contracts sync, which follows the same pattern and runs on Sundays. If your actor writes to a named store and currently works under limited permissions, treat that as borrowed time.
Standby MCP: the actor as its own tool server
Agents do not read READMEs. With Actor Standby and webServerMcpPath: "/mcp", each actor serves streamable-HTTP MCP directly, exposing named tools with typed inputs instead of one generic input object. The server is stateless: one transport and one server instance per request, torn down when the response closes.
app.post("/mcp", async (req, res) => {
const server = buildServer({ mock });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
Tools are registered with zod schemas and descriptions written for a model deciding whether to call them, and charging happens inside the tool handler with the same events as run mode:
server.registerTool("lookup_company_by_number", {
title: "Look up a Georgian company by identification code",
inputSchema: { companyNumber: z.string().regex(/^\d{9}$/)
.describe("9-digit Georgian company identification code, e.g. 404569285") },
}, async ({ companyNumber }) => {
const result = await lookupCompanies({ companyNumber, mock });
await Actor.charge({ eventName: "lookup" });
const n = result.records.filter((r) => r.resultType === "company").length;
if (n) await Actor.charge({ eventName: "company-record", count: n });
return asText({ outcome: result.outcome, records: result.records });
});
Apify's hosted hub then proxies the named tools: a buyer points any MCP client at https://mcp.apify.com/?tools=reestri/ge-company-lookup with their own Apify token, and their account pays the per-event charges. Cold start on standby is about 4.5 seconds; after that, calls hit the warm container.
Two field notes. Apify's standby gateway returns 401 on every path without a token, including /.well-known, so MCP directories that probe anonymously need the hub URL and its OAuth flow, not the raw actor URL. And when my first directory listing was reviewed, the maintainer pointed out that the connect URL returns 401 to any human who clicks it. He was right: a connect URL is for clients, never for a link a person reads. Listings link the profile page; docs give the connect URL in a code block.
Honest economics
Everything is priced per event, no subscriptions, no free tier:
- Georgia company lookup: $0.05 per query + $0.25 per company record
- Georgia person search: $0.05 per query + $0.10 per participation record
- Armenia company with beneficial owners: $0.05 per query + $0.50 per record
- Armenia supplier contracts: $0.05 per query + $0.10 per contract record
- Three-country screen: $0.50 per screen + $0.10 per matched company
Apify's split leaves the creator 80% of revenue, minus platform usage costs, and under pay-per-event standby my compute cost for a buyer's call is effectively zero. One selection rule mattered more than any pricing decision: pick registers people are forced to use.
The first-week reality: five actors public on the Store, Store search now returns them as the top results for "armenia company", listed in the Official MCP Registry, Smithery and Glama, one curated OSINT directory merged after review, and the first direct outreach to KYC and OSINT practitioners underway. One week in, external paying users: zero. The runs on the dashboard are mine and the daily green-run schedules. That is what week one of a data product actually looks like; the thesis is that maintained, evidence-grade coverage of registers nobody else serves compounds, and the journal will record whether it does.
What is next
More registers, chosen by evidence rather than ambition: Moldova's lookup is built but stays private until the agency confirms re-use terms in writing, and Kazakhstan is parked because its open-data portal drops non-browser connections and the official API needs a local registration. Armenia's weekly ownership and contracts snapshots also make a change feed nobody sells, which is the next product. The catalog lives at apify.com/reestri and the project site with live records and the method rules is at reestri.io. If you are building actors, the transferable part is the schema: encode your honesty in it, and let the failures be loud.
Top comments (0)