Email Enrichment
Pricing
from $3.12 / 1,000 api requests
Go to Apify Store
Email Enrichment
Enrichment lets you find the current job title, company,location and social profiles of the person behind the email. 💡
Email Enrichment
Pricing
from $3.12 / 1,000 api requests
Enrichment lets you find the current job title, company,location and social profiles of the person behind the email. 💡
1[settings]2idiomatic_version_file_enable_tools = []3
4[tools."github:apify/apify-cli"]5version = "latest"6bin = "apify"7
8[tools."github:apify/apify-cli".platforms]9linux-x64 = { asset_pattern = "apify-*-linux-x64" }10linux-arm64 = { asset_pattern = "apify-*-linux-arm64" }11macos-x64 = { asset_pattern = "apify-*-darwin-x64" }12macos-arm64 = { asset_pattern = "apify-*-darwin-arm64" }13windows-x64 = { asset_pattern = "apify-*-windows-x64.exe" }14windows-arm64 = { asset_pattern = "apify-*-windows-arm64.exe" }.prettierignore{ "printWidth": 120, "singleQuote": true, "tabWidth": 4}{ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true }, "include": ["./src/**/*", "./test/**/*"]}# configurations.idea.vscode.zed
# crawlee and apify storage foldersapify_storagecrawlee_storagestorage
# installed filesnode_modules
# git folder.git
# dist folderdisttest# This file tells Git which files shouldn't be added to source control
.idea.vscode.zedstorageapify_storagecrawlee_storagenode_modulesdisttsconfig.tsbuildinfo
# Added by Apify CLI.venv1# Development2
3Notes for maintainers of the Tomba Email Enrichment Actor. The README is the end-user page shown on Apify Store.4
5## Requirements6
7- Node.js 20+8- [Apify CLI](https://docs.apify.com/cli) for deployment9
10## Scripts11
12```bash13npm install14npm run build # compile TypeScript to dist/15npm run lint # ESLint (src and test)16npm run format # Prettier17npm test # unit + end-to-end tests (node:test)18npm start # run locally with tsx19```20
21## Credentials22
23The Actor uses our Tomba account. Credentials come from environment variables, never from the input:24
25| Variable | Description |26| -------------------- | -------------------------------------------------- |27| `TOMBA_API_KEY` | Tomba API key (`ta_…`) |28| `TOMBA_API_SECRET` | Tomba secret (`ts_…`) |29| `TOMBA_API_ENDPOINT` | Optional API base URL; only used by the test suite |3031`.actor/actor.json` maps the variables to Apify secrets:32
33```bash34apify secrets add tombaApiKey ta_xxxxxxxxxxxxxxxxxxxx35apify secrets add tombaApiSecret ts_xxxxxxxxxxxxxxxxxxxx36apify push37```38
39Run locally:40
41```bash42TOMBA_API_KEY=ta_… TOMBA_API_SECRET=ts_… npm start43```44
45## Pricing (pay per event)46
47In **Apify Console → Publication → Monetization**, choose **Pay per event** and add:48
49| Event | Price | Charged when |50| --------------- | -------- | --------------------------------------------- |51| `tomba-request` | $0.00312 | Tomba returns a billable response (see below) |5253Every Tomba credit is one `tomba-request` event. The count follows Tomba's published rule for Email Enrichment, **1 search credit, or 6 with phone data**:54
55| Billable response | Events (`count`) |56| ----------------------------------------------------------------- | ----------------------- |57| Without `enrichMobile` | 1 |58| `enrichMobile=true` and `data.phone_data` has at least one number | 6 (1 + `PHONE_CREDITS`) |59| `enrichMobile=true` and `data.phone_data` missing or empty | 1 |6061`main.ts` passes `count = (body) => 1 + (hasPhoneData(body.data) ? PHONE_CREDITS : 0)` to `callTomba()`. Each item reports the events actually charged in `chargedCredits` (`res.chargedCount ?? 0`, so 0 for cache hits and errors) and the number of phones in `phoneNumbers`.62
63`isBillable()` in `src/tomba.ts` mirrors Tomba's billing:64
65| Tomba outcome | Charged |66| ------------------------------------------------------ | ------- |67| JSON with non-empty `data`, including negative answers | Yes |68| Error status (4xx, 5xx, including 422 and 429) | No |69| Success with empty or null `data` | No |70| Success with an `errors` object | No |71| Non-JSON body (reported as 502) | No |72| Cache hit | No |7374## Architecture75
76- `src/tomba.ts`: shared helper, identical in every Tomba Actor. It handles credentials, caching (per-Actor `tomba-cache-<actorId>` key-value store; falls back to an in-run cache if it can't be opened), retries with exponential backoff, pay-per-event charging, budget reservation, the concurrency pool and resume state.77- `src/main.ts`: normalizes and deduplicates `emails`, keeps at most `maxResults` emails (minus those already done when resuming), calls `Finder.emailEnrichment(email, enrich_mobile, webhook_url)` (`GET /enrich?email=…&enrich_mobile=true&webhook_url=…`; `enrich_mobile` is only sent when `enrichMobile` is on and `webhook_url` only when `webhookUrl` is set, and both are part of the cache key) and pushes one item per email: the Tomba `data` object spread out (including `phone_data` when returned), with `email` overwritten by the input email, plus `source`, `phoneNumbers`, `charged`, `chargedCredits` and `cached`. Non-billable outcomes produce an `error` item.78- The `tomba` SDK v1.1.1 resolves every call to `{ data, rateLimit }`, where `data` is the response body. Its `.d.ts` types still declare the old return type, so always go through `callTomba()`.79
80## Tests81
82- `test/tomba.test.ts`: unit tests for the shared helper (identical in every Actor)83- `test/main.test.ts`: end-to-end tests that run `src/main.ts` against a local mock Tomba API84- `test/helpers.ts`: mock server and Actor runner (identical in every Actor)85
86Locally, the Apify SDK prices every event at $1 when `ACTOR_TEST_PAY_PER_EVENT=true`, so the tests use `maxTotalChargeUsd` as an event count.87
88## API reference89
90- [Tomba API docs](https://docs.tomba.io/introduction)91- [Email Enrichment endpoint](https://docs.tomba.io/api/finder#email-enrichment)92- [Tomba Node.js SDK](https://github.com/tomba-io/node)93
94## Standby mode (real-time API)95
96`.actor/actor.json` sets `usesStandbyMode: true` and `webServerSchema: ./web_server_schema.json` (OpenAPI 3).97
98- `src/standby.ts` (shared, identical in every Actor): `runActor()` runs a batch job, or, when `APIFY_META_ORIGIN=STANDBY`, starts an HTTP server on `Actor.config.get('containerPort')`.99 - `GET /` with the `x-apify-container-server-readiness-probe` header, or with no query: readiness / usage.100 - `GET /?…`: input built by `fromQuery()` from `email` / `emails` (repeated or comma-separated), `enrichMobile`, `webhookUrl` and `maxResults`.101 - `POST /`: the same JSON input as a batch run.102 - Responses: `200 { items }`, `400` invalid input, `402` max charge limit reached, `404`, `405`.103- `run(input, ctx)` in `src/main.ts` is shared by both modes: `ctx.push()` writes to the dataset in batch runs and to the HTTP response in Standby; `ctx.isDone()`/`ctx.markDone()` persist resume state only in batch runs. Input errors throw `InputError` (failed run in batch, `400` in Standby).104- Caching and pay-per-event charging work the same in both modes.105
106Try it locally:107
108```bash109APIFY_META_ORIGIN=STANDBY ACTOR_WEB_SERVER_PORT=8080 TOMBA_API_KEY=ta_… TOMBA_API_SECRET=ts_… npm start110curl "localhost:8080/?email=patrick@stripe.com"111```112
113## Key-value store schema114
115`.actor/key_value_store_schema.json` documents the default key-value store records (`INPUT`, `TOMBA_STATE`). The cross-run cache lives in the separate named store `tomba-cache-<actorId>`, one per Actor: under limited permissions an Actor can only open named storages it created itself, so the Tomba Actors must not share one store. If the store can't be opened, the run logs a warning and caches for this run only.116
117## Memory118
119`defaultMemoryMbytes` is 256: the Actor only makes HTTP calls, so more memory just costs more.1import prettier from 'eslint-config-prettier';2
3import apify from '@apify/eslint-config/ts.js';4import globals from 'globals';5import tsEslint from 'typescript-eslint';6
7// eslint-disable-next-line import/no-default-export8export default [9 { ignores: ['**/dist', 'eslint.config.mjs'] },10 ...apify,11 prettier,12 {13 languageOptions: {14 parser: tsEslint.parser,15 parserOptions: {16 project: 'tsconfig.eslint.json',17 },18 globals: {19 ...globals.node,20 ...globals.jest,21 },22 },23 plugins: {24 '@typescript-eslint': tsEslint.plugin,25 },26 rules: {27 'no-console': 0,28 },29 },30 {31 // node:test's describe/it return promises by design.32 files: ['test/**/*.ts'],33 rules: {34 '@typescript-eslint/no-floating-promises': 0,35 },36 },37];root = true
[*]indent_style = spaceindent_size = 4charset = utf-8trim_trailing_whitespace = trueinsert_final_newline = trueend_of_line = lf{ "extends": "@apify/tsconfig", "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", "target": "ES2022", "outDir": "dist", "noUnusedLocals": false, "skipLibCheck": true, "lib": ["DOM"] }, "include": ["./src/**/*"]}{ "name": "email-enrichment", "version": "1.0.0", "type": "module", "description": "Tomba Email Enrichment Actor for enriching email addresses with professional and personal data", "engines": { "node": ">=18.0.0" }, "dependencies": { "apify": "^3.7.2", "tomba": "^1.1.1" }, "devDependencies": { "@apify/eslint-config": "^1.0.0", "@apify/tsconfig": "^0.1.1", "@types/node": "^22.15.32", "ajv": "^8.20.0", "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "globals": "^16.2.0", "prettier": "^3.5.3", "standard-version": "^9.5.0", "tsx": "^4.20.3", "typescript": "^5.8.3", "typescript-eslint": "^8.34.1" }, "scripts": { "start": "npm run start:dev", "start:prod": "node dist/main.js", "start:dev": "tsx src/main.ts", "build": "tsc", "lint": "eslint src test", "lint:fix": "eslint src test --fix", "format": "prettier --write .", "format:check": "prettier --check .", "release": "standard-version && git push --follow-tags origin main", "test": "node --import tsx --test test/*.test.ts" }, "author": "It's not you it's me", "license": "ISC"}# Specify the base Docker image. You can read more about# the available images at https://docs.apify.com/sdk/js/docs/guides/docker-images# You can also use any other image from Docker Hub.FROM apify/actor-node:22 AS builder
# Check preinstalled packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install all dependencies. Don't audit to speed up the installation.RUN npm install --include=dev --audit=false
# Next, copy the source files using the user set# in the base image.COPY . ./
# Install all dependencies and build the project.# Don't audit to speed up the installation.RUN npm run build
# Create final imageFROM apify/actor-node:22
# Check preinstalled packagesRUN npm ls crawlee apify puppeteer playwright
# Copy just package.json and package-lock.json# to speed up the build using Docker layer cache.COPY package*.json ./
# Install NPM packages, skip optional and development dependencies to# keep the image small. Avoid logging too much and print the dependency# tree for debuggingRUN npm --quiet set progress=false \ && npm install --omit=dev --omit=optional \ && echo "Installed NPM packages:" \ && (npm list --omit=dev --all || true) \ && echo "Node.js version:" \ && node --version \ && echo "NPM version:" \ && npm --version \ && rm -r ~/.npm
# Copy built JS files from builder imageCOPY /usr/src/app/dist ./dist
# Next, copy the remaining files and directories with the source code.# Since we do this after NPM install, quick build will be really fast# for most source file changes.COPY . ./
# Run the image.CMD npm run start:prod --silent{ "actorSpecification": 1, "fields": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "email": { "type": "string", "description": "The email address that was enriched" }, "first_name": { "type": ["string", "null"], "description": "First name of the person associated with the email" }, "last_name": { "type": ["string", "null"], "description": "Last name of the person associated with the email" }, "full_name": { "type": ["string", "null"], "description": "Full name of the person associated with the email" }, "company": { "type": ["string", "null"], "description": "Company or organization associated with the email" }, "position": { "type": ["string", "null"], "description": "Job title or position of the person" }, "country": { "type": ["string", "null"], "description": "Country where the person is located" }, "gender": { "type": ["string", "null"], "description": "Gender of the person" }, "website_url": { "type": ["string", "null"], "description": "Website URL associated with the person or company" }, "twitter": { "type": ["string", "null"], "description": "Twitter profile URL of the person" }, "linkedin": { "type": ["string", "null"], "description": "LinkedIn profile URL of the person" }, "phone_number": { "type": ["boolean", "string", "null"], "description": "Phone number associated with the person" }, "score": { "type": ["number", "null"], "description": "Confidence score for the enrichment data (0-100)" }, "accept_all": { "type": ["boolean", "null"], "description": "Whether the domain accepts all emails" }, "verification": { "type": ["object", "null"], "description": "Email verification information", "properties": { "date": { "type": ["string", "null"], "description": "Date when the email was verified" }, "status": { "type": ["string", "null"], "description": "Verification status (valid, invalid, unknown, etc.)" } } }, "sources": { "type": ["array", "null"], "description": "Sources where the email information was found", "items": { "type": "object", "properties": { "uri": { "type": ["string", "null"], "description": "URI where the email was found" }, "website_url": { "type": ["string", "null"], "description": "Website URL where the email was found" }, "extracted_on": { "type": ["string", "null"], "description": "Date when the email was extracted" }, "last_seen_on": { "type": ["string", "null"], "description": "Date when the email was last seen" }, "still_on_page": { "type": ["boolean", "null"], "description": "Whether the email is still on the page" } } } }, "source": { "type": "string", "description": "Source of the enrichment data (tomba_email_enrichment)" }, "error": { "type": ["string", "null"], "description": "Error message if enrichment failed" }, "phone_data": { "type": ["array", "null"], "description": "Phone numbers found for the person (only when `enrichMobile` is on)", "items": { "type": "object", "properties": { "number": { "type": ["string", "null"], "description": "Phone number" }, "type": { "type": ["string", "null"], "description": "Phone type (for example mobile)" } } } }, "phoneNumbers": { "type": "integer", "description": "Number of phone numbers returned in `phone_data`" }, "charged": { "type": "boolean", "description": "Whether this request was charged (pay-per-event)" }, "chargedCredits": { "type": "integer", "description": "Number of Tomba credits charged for this item (0 when free or cached)" }, "cached": { "type": "boolean", "description": "Whether the result came from the cache (free)" } }, "required": ["email", "source"] }, "views": { "overview": { "title": "Overview", "transformation": { "fields": ["email", "full_name", "position", "company", "website_url", "score", "error"] }, "display": { "component": "table", "properties": { "email": { "label": "Email" }, "full_name": { "label": "Full Name" }, "position": { "label": "Position" }, "company": { "label": "Company" }, "website_url": { "label": "Website" }, "score": { "label": "Confidence Score" }, "error": { "label": "Error" } } } }, "detailed": { "title": "Detailed View", "transformation": { "fields": [ "email", "first_name", "last_name", "full_name", "position", "company", "country", "gender", "website_url", "twitter", "linkedin", "phone_number", "score", "accept_all", "verification", "sources", "source", "error", "phone_data", "phoneNumbers", "charged", "chargedCredits", "cached" ] }, "display": { "component": "table" } }, "enrichment_success": { "title": "Successful Enrichments", "transformation": { "fields": ["email", "full_name", "position", "company", "score"], "filterBy": [ { "field": "error", "operator": "isNull" } ] }, "display": { "component": "table", "properties": { "email": { "label": "Email" }, "full_name": { "label": "Full Name" }, "position": { "label": "Position" }, "company": { "label": "Company" }, "score": { "label": "Confidence Score" } } } }, "enrichment_errors": { "title": "Enrichment Errors", "transformation": { "fields": ["email", "error", "source"], "filterBy": [ { "field": "error", "operator": "isNotNull" } ] }, "display": { "component": "table", "properties": { "email": { "label": "Email" }, "error": { "label": "Error Message" }, "source": { "label": "Source" } } } } }}{ "openapi": "3.0.0", "info": { "title": "Tomba Email Enrichment API", "version": "1.0.0", "description": "Real-time API (Apify Standby mode). Enrich email addresses with the person's name, company, position, social profiles and optionally phone numbers. $0.00312 per credit: an enrichment with an answer costs 1 credit ($0.00312), or 6 credits ($0.01872) when `enrichMobile` is on and phone numbers are returned. Answers with no person details are charged; empty results, errors and cached results are free." }, "paths": { "/": { "get": { "summary": "Enrich one or more emails", "description": "Without query parameters, returns a short usage note (also used as the readiness probe).", "parameters": [ { "name": "email", "in": "query", "description": "Email address to enrich. Repeat the parameter or separate values with commas for several emails.", "required": false, "schema": { "type": "string" }, "example": "patrick@stripe.com" }, { "name": "emails", "in": "query", "description": "Alias of `email`.", "required": false, "schema": { "type": "string" } }, { "name": "enrichMobile", "in": "query", "description": "Also return the person's phone numbers in `phone_data` (6 credits instead of 1 when phone data is returned).", "required": false, "schema": { "type": "boolean", "default": false }, "example": false }, { "name": "webhookUrl", "in": "query", "description": "Optional URL that Tomba notifies when a result is ready (sent to Tomba as `webhook_url`).", "required": false, "schema": { "type": "string", "pattern": "^https?://" } }, { "name": "maxResults", "in": "query", "description": "Maximum number of emails to process (default 50).", "required": false, "schema": { "type": "integer", "minimum": 1, "default": 50 } } ], "responses": { "200": { "$ref": "#/components/responses/Results" }, "400": { "$ref": "#/components/responses/Error" }, "402": { "$ref": "#/components/responses/Error" } } }, "post": { "summary": "Enrich emails using a JSON input", "description": "Accepts the same JSON input as a normal Actor run.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "emails": { "type": "array", "description": "List of email addresses to enrich with detailed information", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "example": ["john@example.com", "jane@company.com", "contact@business.org"] }, "maxResults": { "type": "integer", "description": "Maximum number of emails to process (default: 50, max: 1000)", "default": 50, "minimum": 1, "maximum": 1000, "example": 50 }, "enrichMobile": { "type": "boolean", "description": "Also return the person's phone numbers in `phone_data`. A result with phone data costs 6 credits instead of 1; results without phone data still cost 1 credit.", "default": false }, "webhookUrl": { "type": "string", "description": "Optional URL that Tomba notifies when a result is ready (sent to Tomba as `webhook_url`).", "pattern": "^https?://", "example": "https://example.com/tomba-webhook" } }, "required": ["emails"] } } } }, "responses": { "200": { "$ref": "#/components/responses/Results" }, "400": { "$ref": "#/components/responses/Error" }, "402": { "$ref": "#/components/responses/Error" } } } } }, "components": { "responses": { "Results": { "description": "Enriched profiles, in the same format as the dataset items.", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/EnrichedEmail" } } } } } } }, "Error": { "description": "Invalid input (400) or the run's max charge limit was reached (402).", "content": { "application/json": { "schema": { "type": "object", "properties": { "error": { "type": "string" } } } } } } }, "schemas": { "EnrichedEmail": { "type": "object", "properties": { "email": { "type": "string", "description": "The email address that was enriched" }, "first_name": { "type": "string", "nullable": true, "description": "First name of the person associated with the email" }, "last_name": { "type": "string", "nullable": true, "description": "Last name of the person associated with the email" }, "full_name": { "type": "string", "nullable": true, "description": "Full name of the person associated with the email" }, "company": { "type": "string", "nullable": true, "description": "Company or organization associated with the email" }, "position": { "type": "string", "nullable": true, "description": "Job title or position of the person" }, "country": { "type": "string", "nullable": true, "description": "Country where the person is located" }, "gender": { "type": "string", "nullable": true, "description": "Gender of the person" }, "website_url": { "type": "string", "nullable": true, "description": "Website URL associated with the person or company" }, "twitter": { "type": "string", "nullable": true, "description": "Twitter profile URL of the person" }, "linkedin": { "type": "string", "nullable": true, "description": "LinkedIn profile URL of the person" }, "phone_number": { "type": "boolean", "nullable": true, "description": "Phone number associated with the person" }, "score": { "type": "number", "nullable": true, "description": "Confidence score for the enrichment data (0-100)" }, "accept_all": { "type": "boolean", "nullable": true, "description": "Whether the domain accepts all emails" }, "verification": { "type": "object", "nullable": true, "description": "Email verification information", "properties": { "date": { "type": "string", "nullable": true, "description": "Date when the email was verified" }, "status": { "type": "string", "nullable": true, "description": "Verification status (valid, invalid, unknown, etc.)" } } }, "sources": { "type": "array", "nullable": true, "description": "Sources where the email information was found", "items": { "type": "object", "properties": { "uri": { "type": "string", "nullable": true, "description": "URI where the email was found" }, "website_url": { "type": "string", "nullable": true, "description": "Website URL where the email was found" }, "extracted_on": { "type": "string", "nullable": true, "description": "Date when the email was extracted" }, "last_seen_on": { "type": "string", "nullable": true, "description": "Date when the email was last seen" }, "still_on_page": { "type": "boolean", "nullable": true, "description": "Whether the email is still on the page" } } } }, "source": { "type": "string", "description": "Source of the enrichment data (tomba_email_enrichment)" }, "error": { "type": "string", "nullable": true, "description": "Error message if enrichment failed" }, "phone_data": { "type": "array", "nullable": true, "description": "Phone numbers found for the person (only when `enrichMobile` is on)", "items": { "type": "object", "properties": { "number": { "type": "string", "nullable": true, "description": "Phone number" }, "type": { "type": "string", "nullable": true, "description": "Phone type (for example mobile)" } } } }, "phoneNumbers": { "type": "integer", "description": "Number of phone numbers returned in `phone_data`" }, "charged": { "type": "boolean", "description": "Whether this request was charged (pay-per-event)" }, "chargedCredits": { "type": "integer", "description": "Number of Tomba credits charged for this item (0 when free or cached)" }, "cached": { "type": "boolean", "description": "Whether the result came from the cache (free)" } } } } }}{ "actorOutputSchemaVersion": 1, "title": "Email Enrichment Output", "description": "Output from Tomba Email Enrichment Actor - enriched contact profiles with social and professional data", "type": "object", "properties": { "results": { "type": "string", "title": "Email Enrichment Results", "description": "Enriched contact information with social profiles and professional details", "template": "{{links.apiDefaultDatasetUrl}}/items" } }}{ "actorSpecification": 1, "name": "email-enrichment", "title": "Tomba Email Enrichment", "description": "Enrich email addresses with professional and personal data, and optionally phone numbers, using the Tomba API.", "version": "1.0", "buildTag": "latest", "meta": { "templateId": "ts-empty" }, "dockerfile": "../Dockerfile", "input": "./input_schema.json", "output": "./output_schema.json", "storages": { "dataset": "./dataset_schema.json", "keyValueStore": "./key_value_store_schema.json" }, "environmentVariables": { "TOMBA_API_KEY": "@tombaApiKey", "TOMBA_API_SECRET": "@tombaApiSecret" }, "usesStandbyMode": true, "webServerSchema": "./web_server_schema.json", "defaultMemoryMbytes": 256}{ "actorKeyValueStoreSchemaVersion": 1, "title": "Tomba Email Enrichment storage", "description": "Records the Actor keeps in its default key-value store. Results are in the dataset.", "collections": { "input": { "title": "Input", "description": "The input of this run.", "key": "INPUT", "contentTypes": ["application/json"] }, "progress": { "title": "Progress", "description": "Emails already processed, so a migrated or restarted run continues without charging again.", "key": "TOMBA_STATE", "contentTypes": ["application/json"], "jsonSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "done": { "type": "object", "description": "Processed emails (keys, lowercase) mapped to true", "additionalProperties": { "type": "boolean" } } }, "required": ["done"] } } }}{ "title": "Tomba Email Enrichment Input", "type": "object", "schemaVersion": 1, "properties": { "emails": { "title": "Email Addresses", "type": "array", "description": "List of email addresses to enrich with detailed information", "editor": "stringList", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "example": ["john@example.com", "jane@company.com", "contact@business.org"], "prefill": ["john@stripe.com", "info@tomba.io"] }, "maxResults": { "title": "Maximum Results", "type": "integer", "description": "Maximum number of emails to process (default: 50, max: 1000)", "editor": "number", "default": 50, "minimum": 1, "maximum": 1000, "example": 50 }, "enrichMobile": { "title": "Find phone numbers", "type": "boolean", "description": "Also return the person's phone numbers in `phone_data`. A result with phone data costs 6 credits instead of 1; results without phone data still cost 1 credit.", "default": false, "editor": "checkbox" }, "webhookUrl": { "title": "Webhook URL", "type": "string", "description": "Optional URL that Tomba notifies when a result is ready (sent to Tomba as `webhook_url`).", "editor": "textfield", "pattern": "^https?://", "example": "https://example.com/tomba-webhook" }, "maxConcurrency": { "title": "Max concurrency", "type": "integer", "description": "How many Tomba requests run in parallel. There is no rate limit, so raise it for large lists.", "minimum": 1, "maximum": 50, "default": 10, "editor": "number", "sectionCaption": "Run options", "sectionDescription": "Concurrency, retry and cache settings. The defaults work well for most runs." }, "maxRetries": { "title": "Max retries", "type": "integer", "description": "How many times to retry a request after a network error, 429 or 5xx response (exponential backoff). Failed requests are never charged.", "minimum": 0, "maximum": 10, "default": 3, "editor": "number" }, "useCache": { "title": "Use cache", "type": "boolean", "description": "Reuse results from previous runs for identical requests. Cached results are free.", "default": true, "editor": "checkbox" }, "cacheTtlHours": { "title": "Cache TTL (hours)", "type": "integer", "description": "How long cached results stay valid. 0 disables the cross-run cache.", "minimum": 0, "maximum": 720, "default": 24, "editor": "number", "unit": "hours" } }, "required": ["emails"]}1// Shared test harness for the Tomba Apify Actors.2// Keep this file identical across all Actors.3import { spawn } from 'node:child_process';4import { readFileSync } from 'node:fs';5import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';6import type { IncomingMessage, Server, ServerResponse } from 'node:http';7import { createServer, request as httpRequest } from 'node:http';8import type { AddressInfo } from 'node:net';9import { tmpdir } from 'node:os';10import { join } from 'node:path';11
12import { Ajv } from 'ajv';13
14export interface MockRequest {15 method: string;16 path: string;17 query: Record<string, string>;18 headers: IncomingMessage['headers'];19 body: unknown;20}21
22export interface MockResponse {23 status?: number;24 body?: unknown;25 /** Raw (non-JSON) body. */26 raw?: string;27 headers?: Record<string, string>;28}29
30export type MockHandler = (req: MockRequest) => MockResponse | Promise<MockResponse>;31
32export interface MockServer {33 url: string;34 requests: MockRequest[];35 close: () => Promise<void>;36}37
38/** Start a local HTTP server that impersonates the Tomba API. */39export async function startMockTomba(handler: MockHandler): Promise<MockServer> {40 const requests: MockRequest[] = [];41 const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {42 const chunks: Buffer[] = [];43 req.on('data', (chunk: Buffer) => chunks.push(chunk));44 req.on('end', async () => {45 const url = new URL(req.url ?? '/', 'http://localhost');46 const text = Buffer.concat(chunks).toString();47 let body: unknown = text;48 try {49 body = text ? JSON.parse(text) : undefined;50 } catch {51 // keep raw text52 }53 const request: MockRequest = {54 method: req.method ?? 'GET',55 path: url.pathname,56 query: Object.fromEntries(url.searchParams),57 headers: req.headers,58 body,59 };60 requests.push(request);61 try {62 const out = await handler(request);63 const status = out.status ?? 200;64 if (out.raw !== undefined) {65 res.writeHead(status, { 'content-type': 'text/html', ...out.headers });66 res.end(out.raw);67 } else {68 res.writeHead(status, { 'content-type': 'application/json', ...out.headers });69 res.end(JSON.stringify(out.body ?? {}));70 }71 } catch (err) {72 res.writeHead(500, { 'content-type': 'application/json' });73 res.end(JSON.stringify({ errors: { message: (err as Error).message } }));74 }75 });76 });77 await new Promise<void>((resolve) => {78 server.listen(0, '127.0.0.1', resolve);79 });80 const { port } = server.address() as AddressInfo;81 return {82 url: `http://127.0.0.1:${port}`,83 requests,84 close: async () =>85 new Promise<void>((resolve) => {86 server.close(() => resolve());87 }),88 };89}90
91export interface RunOptions {92 input: Record<string, unknown>;93 /** Mock server URL; omit to run without an endpoint override. */94 endpoint?: string;95 /** Reuse a storage directory (to test cache and resume). */96 storageDir?: string;97 /** Keep the default storages from the previous run in `storageDir`. */98 keepStorage?: boolean;99 /** Max total charge in USD (locally every event costs $1). */100 maxTotalChargeUsd?: number;101 /** Set to false to run without Tomba credentials. */102 withCredentials?: boolean;103 timeoutMs?: number;104}105
106export interface ChargeLogEntry {107 eventName: string;108 chargedCount?: number;109 [key: string]: unknown;110}111
112export interface RunResult {113 code: number | null;114 output: string;115 items: Record<string, unknown>[];116 charges: ChargeLogEntry[];117 storageDir: string;118 /** Number of charged events per event name. */119 chargeCounts: Record<string, number>;120}121
122/** Run the Actor (`src/main.ts`) in a child process against local storage. */123export async function runActor(options: RunOptions): Promise<RunResult> {124 const storageDir = options.storageDir ?? (await mkdtemp(join(tmpdir(), 'tomba-actor-test-')));125 const inputDir = join(storageDir, 'key_value_stores', 'default');126 await mkdir(inputDir, { recursive: true });127 await writeFile(join(inputDir, 'INPUT.json'), JSON.stringify(options.input));128
129 const env: NodeJS.ProcessEnv = {130 PATH: process.env.PATH,131 HOME: process.env.HOME,132 CRAWLEE_STORAGE_DIR: storageDir,133 CRAWLEE_PURGE_ON_START: options.keepStorage ? '0' : '1',134 APIFY_LOG_LEVEL: 'INFO',135 ACTOR_TEST_PAY_PER_EVENT: 'true',136 ACTOR_USE_CHARGING_LOG_DATASET: 'true',137 };138 if (options.withCredentials !== false) {139 env.TOMBA_API_KEY = 'ta_test_key';140 env.TOMBA_API_SECRET = 'ts_test_secret';141 }142 if (options.endpoint) env.TOMBA_API_ENDPOINT = options.endpoint;143 if (options.maxTotalChargeUsd !== undefined) env.ACTOR_MAX_TOTAL_CHARGE_USD = String(options.maxTotalChargeUsd);144
145 const { code, output } = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {146 const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts'], { env, cwd: process.cwd() });147 let out = '';148 child.stdout.on('data', (d: Buffer) => {149 out += d.toString();150 });151 child.stderr.on('data', (d: Buffer) => {152 out += d.toString();153 });154 const timer = setTimeout(() => {155 child.kill('SIGKILL');156 reject(new Error(`Actor run timed out\n${out}`));157 }, options.timeoutMs ?? 60_000);158 child.on('close', (c) => {159 clearTimeout(timer);160 resolve({ code: c, output: out });161 });162 });163
164 const items = await readDataset(storageDir, 'default');165 assertMatchesDatasetSchema(items);166 const charges = (await readDataset(storageDir, 'charging_log')) as ChargeLogEntry[];167 const chargeCounts: Record<string, number> = {};168 for (const c of charges) chargeCounts[c.eventName] = (chargeCounts[c.eventName] ?? 0) + (c.chargedCount ?? 1);169
170 return { code, output, items, charges, storageDir, chargeCounts };171}172
173export async function readDataset(storageDir: string, name: string): Promise<Record<string, unknown>[]> {174 const dir = join(storageDir, 'datasets', name);175 let files: string[];176 try {177 files = (await readdir(dir)).filter((f) => f.endsWith('.json')).sort();178 } catch {179 return [];180 }181 return Promise.all(files.map(async (f) => JSON.parse(await readFile(join(dir, f), 'utf8'))));182}183
184export async function removeStorage(storageDir: string): Promise<void> {185 await rm(storageDir, { recursive: true, force: true });186}187
188/** Total number of charged events across all event names. */189export function totalCharges(result: RunResult): number {190 return Object.values(result.chargeCounts).reduce((a, b) => a + b, 0);191}192
193export interface StandbyActor {194 /** Base URL of the Actor's HTTP server. */195 url: string;196 storageDir: string;197 /** GET or POST a request to the Actor and parse the JSON response. */198 call: (199 path: string,200 init?: { method?: string; body?: unknown; headers?: Record<string, string> },201 ) => Promise<{ status: number; body: Record<string, unknown> }>;202 /** Stop the Actor and return its log output and charges. */203 stop: () => Promise<{ output: string; chargeCounts: Record<string, number> }>;204}205
206async function freePort(): Promise<number> {207 const server = createServer();208 await new Promise<void>((resolve) => {209 server.listen(0, '127.0.0.1', resolve);210 });211 const { port } = server.address() as AddressInfo;212 await new Promise<void>((resolve) => {213 server.close(() => resolve());214 });215 return port;216}217
218async function httpCall(219 url: string,220 init: { method?: string; body?: unknown; headers?: Record<string, string> } = {},221): Promise<{ status: number; body: Record<string, unknown> }> {222 let payload: string | undefined;223 if (typeof init.body === 'string') payload = init.body;224 else if (init.body !== undefined) payload = JSON.stringify(init.body);225 return new Promise((resolve, reject) => {226 const req = httpRequest(227 url,228 {229 method: init.method ?? (payload ? 'POST' : 'GET'),230 headers: { 'content-type': 'application/json', ...init.headers },231 },232 (res) => {233 const chunks: Buffer[] = [];234 res.on('data', (c: Buffer) => chunks.push(c));235 res.on('end', () => {236 const text = Buffer.concat(chunks).toString();237 let body: Record<string, unknown> = {};238 try {239 body = JSON.parse(text);240 } catch {241 body = { raw: text };242 }243 resolve({ status: res.statusCode ?? 0, body });244 });245 },246 );247 req.on('error', reject);248 if (payload) req.write(payload);249 req.end();250 });251}252
253/** Start the Actor in Standby mode (real-time HTTP API) and wait until it is ready. */254export async function startStandbyActor(255 options: { endpoint?: string; maxTotalChargeUsd?: number; withCredentials?: boolean } = {},256): Promise<StandbyActor> {257 const storageDir = await mkdtemp(join(tmpdir(), 'tomba-standby-test-'));258 const port = await freePort();259 const env: NodeJS.ProcessEnv = {260 PATH: process.env.PATH,261 HOME: process.env.HOME,262 CRAWLEE_STORAGE_DIR: storageDir,263 APIFY_LOG_LEVEL: 'INFO',264 APIFY_META_ORIGIN: 'STANDBY',265 ACTOR_WEB_SERVER_PORT: String(port),266 ACTOR_TEST_PAY_PER_EVENT: 'true',267 ACTOR_USE_CHARGING_LOG_DATASET: 'true',268 };269 if (options.withCredentials !== false) {270 env.TOMBA_API_KEY = 'ta_test_key';271 env.TOMBA_API_SECRET = 'ts_test_secret';272 }273 if (options.endpoint) env.TOMBA_API_ENDPOINT = options.endpoint;274 if (options.maxTotalChargeUsd !== undefined) env.ACTOR_MAX_TOTAL_CHARGE_USD = String(options.maxTotalChargeUsd);275
276 const child = spawn(process.execPath, ['--import', 'tsx', 'src/main.ts'], { env, cwd: process.cwd() });277 let output = '';278 child.stdout.on('data', (d: Buffer) => {279 output += d.toString();280 });281 child.stderr.on('data', (d: Buffer) => {282 output += d.toString();283 });284 const exited = new Promise<void>((resolve) => {285 child.on('close', () => resolve());286 });287
288 const url = `http://127.0.0.1:${port}`;289 const deadline = Date.now() + 30_000;290 for (;;) {291 if (child.exitCode !== null) throw new Error(`Standby Actor exited early\n${output}`);292 try {293 const res = await httpCall(`${url}/`, { headers: { 'x-apify-container-server-readiness-probe': '1' } });294 if (res.status === 200) break;295 } catch {296 // not listening yet297 }298 if (Date.now() > deadline) {299 child.kill('SIGKILL');300 throw new Error(`Standby Actor did not become ready\n${output}`);301 }302 await new Promise((r) => {303 setTimeout(r, 100);304 });305 }306
307 return {308 url,309 storageDir,310 call: async (path, init) => httpCall(`${url}${path}`, init),311 stop: async () => {312 child.kill('SIGTERM');313 await exited;314 const charges = (await readDataset(storageDir, 'charging_log')) as ChargeLogEntry[];315 const chargeCounts: Record<string, number> = {};316 for (const c of charges)317 chargeCounts[c.eventName] = (chargeCounts[c.eventName] ?? 0) + (c.chargedCount ?? 1);318 await removeStorage(storageDir);319 return { output, chargeCounts };320 },321 };322}323
324let datasetValidator: ReturnType<Ajv['compile']> | undefined;325
326/**327 * Apify validates every pushed item against `.actor/dataset_schema.json` and fails the run on a mismatch,328 * while local runs don't. Check the items the same way so schema mismatches fail the tests.329 */330export function assertMatchesDatasetSchema(items: Record<string, unknown>[]): void {331 if (!datasetValidator) {332 const { fields } = JSON.parse(readFileSync(join(process.cwd(), '.actor/dataset_schema.json'), 'utf8'));333 delete fields.$schema;334 datasetValidator = new Ajv({ allErrors: true, strict: false }).compile(fields);335 }336 for (const [index, item] of items.entries()) {337 if (!datasetValidator(item)) {338 const errors = (datasetValidator.errors ?? [])339 .map((e) => `${e.instancePath || '/'} ${e.message}`)340 .join('; ');341 throw new Error(342 `Dataset item ${index} doesn't match .actor/dataset_schema.json: ${errors}\n${JSON.stringify(item)}`,343 );344 }345 }346}347
348/**349 * Validate an input against `.actor/input_schema.json` (Apify validates the input before a run or API call350 * starts). Returns the error messages; empty when the input is accepted.351 */352export function inputSchemaErrors(input: Record<string, unknown>): string[] {353 const schema = JSON.parse(readFileSync(join(process.cwd(), '.actor/input_schema.json'), 'utf8'));354 const validate = new Ajv({ allErrors: true, strict: false }).compile({355 type: 'object',356 properties: schema.properties,357 required: schema.required ?? [],358 });359 return validate(input) ? [] : (validate.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`);360}1// Unit tests for src/tomba.ts. Keep this file identical across all Actors.2import assert from 'node:assert/strict';3import { mkdtemp } from 'node:fs/promises';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { after, before, describe, it } from 'node:test';7
8process.env.CRAWLEE_STORAGE_DIR = await mkdtemp(join(tmpdir(), 'tomba-unit-'));9process.env.ACTOR_TEST_PAY_PER_EVENT = 'true';10process.env.TOMBA_API_KEY = 'ta_unit';11process.env.TOMBA_API_SECRET = 'ts_unit';12
13const { Actor } = await import('apify');14const { TombaException } = await import('tomba');15const T = await import('../src/tomba.js');16
17const ok = (body: unknown) => async () => ({ data: body, rateLimit: {} });18const httpError = (status: number, body: unknown = {}, headers: Record<string, string> = {}) =>19 new TombaException('error', { status, headers }, body);20const charged = (event = T.EVENT_REQUEST) => Actor.getChargingManager().getChargedEventCount(event);21
22let seq = 0;23const params = () => ({ n: ++seq });24
25before(async () => {26 await Actor.init();27 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });28});29
30after(async () => {31 await Actor.exit({ exit: false });32});33
34describe('isBillable', () => {35 const cases: [string, unknown, boolean][] = [36 ['data array', { data: [{ a: 1 }] }, true],37 ['data object', { data: { email: 'a@b.co' } }, true],38 ['negative answer (object with empty list)', { data: { organization: {}, emails: [] } }, true],39 ['negative answer (undeliverable)', { data: { email: { status: 'invalid' } } }, true],40 ['empty array', { data: [] }, false],41 ['empty object', { data: {} }, false],42 ['object with only null values', { data: { person: null, company: null } }, false],43 ['negative answer (email null, other fields set)', { data: { email: null, first_name: 'John' } }, true],44 ['null data', { data: null }, false],45 ['missing data', { meta: {} }, false],46 ['errors object', { data: { a: 1 }, errors: { message: 'x' } }, false],47 ['string body', '<html>', false],48 ['null body', null, false],49 ['array body', [1, 2], false],50 ];51 for (const [name, body, expected] of cases) {52 it(name, () => assert.equal(T.isBillable(body), expected));53 }54});55
56describe('normalizers', () => {57 it('normalizeDomain strips protocol, www, path and case', () => {58 assert.equal(T.normalizeDomain(' https://www.Stripe.com/about?x=1 '), 'stripe.com');59 assert.equal(T.normalizeDomain('http://tomba.io'), 'tomba.io');60 assert.equal(T.normalizeDomain('sub.example.org'), 'sub.example.org');61 });62
63 it('normalizeEmail trims and lowercases', () => {64 assert.equal(T.normalizeEmail(' John@Stripe.COM '), 'john@stripe.com');65 });66
67 it('unique keeps first occurrence and drops empty keys', () => {68 assert.deepEqual(T.unique(['a', 'b', 'a', '', 'c']), ['a', 'b', 'c']);69 assert.deepEqual(70 T.unique([{ id: 1 }, { id: 2 }, { id: 1 }], (x) => String(x.id)),71 [{ id: 1 }, { id: 2 }],72 );73 });74});75
76describe('phone data', () => {77 it('counts phone numbers in phone_data', () => {78 assert.equal(T.phoneDataCount({ phone_data: [{ number: '+1' }, { number: '+2' }] }), 2);79 assert.equal(T.phoneDataCount({ phone_data: [] }), 0);80 assert.equal(T.phoneDataCount({ phone_number: true }), 0);81 assert.equal(T.phoneDataCount(null), 0);82 assert.equal(T.phoneDataCount('x'), 0);83 });84
85 it('detects records with phone data', () => {86 assert.equal(T.hasPhoneData({ phone_data: [{ number: '+1' }] }), true);87 assert.equal(T.hasPhoneData({ phone_data: [] }), false);88 assert.equal(T.hasPhoneData({}), false);89 });90
91 it('prices phone data at 5 credits', () => {92 assert.equal(T.PHONE_CREDITS, 5);93 });94});95
96describe('cacheStoreName', () => {97 it("is unique per Actor so limited-permission runs never open another Actor's store", () => {98 const previous = process.env.ACTOR_ID;99 try {100 process.env.ACTOR_ID = 'AbC123xyz';101 assert.equal(T.cacheStoreName(), 'tomba-cache-AbC123xyz');102 delete process.env.ACTOR_ID;103 assert.equal(T.cacheStoreName(), 'tomba-cache');104 } finally {105 if (previous === undefined) delete process.env.ACTOR_ID;106 else process.env.ACTOR_ID = previous;107 }108 });109});110
111describe('setupTomba without cache permissions', () => {112 it('keeps working with an in-run cache when the cache store cannot be opened', async () => {113 const original = Actor.openKeyValueStore.bind(Actor);114 Actor.openKeyValueStore = async () => {115 throw new Error('Insufficient permissions for the key-value store.');116 };117 try {118 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });119 const p = { cachePermissionTest: true };120 let calls = 0;121 const fn = async () => {122 calls++;123 return { data: { data: [1] }, rateLimit: {} };124 };125 const first = await T.callTomba('perm', p, fn);126 const second = await T.callTomba('perm', p, fn);127 assert.equal(first.charged, true);128 assert.equal(second.cached, true);129 assert.equal(calls, 1);130 } finally {131 Actor.openKeyValueStore = original;132 await T.setupTomba({ maxRetries: 1, useCache: true, cacheTtlHours: 1, maxConcurrency: 4 });133 }134 });135});136
137describe('runPool', () => {138 it('processes every item with bounded concurrency', async () => {139 let active = 0;140 let peak = 0;141 const seen: number[] = [];142 await T.runPool(143 Array.from({ length: 20 }, (_, i) => i),144 async (item) => {145 active++;146 peak = Math.max(peak, active);147 await new Promise((r) => {148 setTimeout(r, 5);149 });150 seen.push(item);151 active--;152 },153 3,154 );155 assert.equal(seen.length, 20);156 assert.deepEqual(157 [...seen].sort((a, b) => a - b),158 Array.from({ length: 20 }, (_, i) => i),159 );160 assert.ok(peak <= 3, `peak concurrency ${peak}`);161 });162
163 it('handles an empty list', async () => {164 await T.runPool([], async () => assert.fail('should not run'));165 });166});167
168describe('callTomba', () => {169 it('charges a response with data and unwraps the SDK { data, rateLimit } shape', async () => {170 const start = charged();171 const res = await T.callTomba('test', params(), ok({ data: [{ name: 'React' }] }));172 assert.equal(res.status, 200);173 assert.equal(res.charged, true);174 assert.equal(res.cached, false);175 assert.deepEqual(res.data, [{ name: 'React' }]);176 assert.equal(charged(), start + 1);177 });178
179 it('accepts an SDK that returns the body directly', async () => {180 const res = await T.callTomba('test', params(), async () => ({ data: { x: 1 } }));181 assert.equal(res.charged, true);182 assert.deepEqual(res.data, { x: 1 });183 });184
185 it('charges a negative answer', async () => {186 const res = await T.callTomba('test', params(), ok({ data: { organization: {}, emails: [] } }));187 assert.equal(res.charged, true);188 });189
190 it('serves a repeated request from cache without charging', async () => {191 const p = params();192 let calls = 0;193 const fn = async () => {194 calls++;195 return { data: { data: { v: 1 } }, rateLimit: {} };196 };197 await T.callTomba('test', p, fn);198 const start = charged();199 const res = await T.callTomba('test', { ...p }, fn);200 assert.equal(res.cached, true);201 assert.equal(res.charged, false);202 assert.deepEqual(res.data, { v: 1 });203 assert.equal(calls, 1);204 assert.equal(charged(), start);205 });206
207 it('uses a different cache entry for different params or endpoints', async () => {208 const p = params();209 await T.callTomba('a', p, ok({ data: [1] }));210 const other = await T.callTomba('b', p, ok({ data: [2] }));211 assert.equal(other.cached, false);212 assert.deepEqual(other.data, [2]);213 });214
215 it('does not charge or cache empty data', async () => {216 const p = params();217 const start = charged();218 const res = await T.callTomba('test', p, ok({ data: [] }));219 assert.equal(res.charged, false);220 assert.equal(res.status, 200);221 const again = await T.callTomba('test', p, ok({ data: [] }));222 assert.equal(again.cached, false);223 assert.equal(charged(), start);224 });225
226 it('does not charge a success with an errors object', async () => {227 const res = await T.callTomba('test', params(), ok({ data: { a: 1 }, errors: { message: 'Bad thing' } }));228 assert.equal(res.charged, false);229 assert.equal(res.error, 'Bad thing');230 });231
232 it('reports a non-JSON body as 502 without charging', async () => {233 const res = await T.callTomba('test', params(), ok('<html>oops</html>'));234 assert.equal(res.status, 502);235 assert.equal(res.charged, false);236 });237
238 it('does not retry or charge a 422', async () => {239 let calls = 0;240 const res = await T.callTomba('test', params(), async () => {241 calls++;242 throw httpError(422, { errors: { message: 'Invalid domain' } });243 });244 assert.equal(calls, 1);245 assert.equal(res.status, 422);246 assert.equal(res.charged, false);247 assert.match(res.error ?? '', /Invalid domain/);248 });249
250 it('does not retry SDK input validation errors', async () => {251 let calls = 0;252 const res = await T.callTomba('test', params(), async () => {253 calls++;254 throw new TombaException('Domain is required.');255 });256 assert.equal(calls, 1);257 assert.equal(res.status, 400);258 assert.equal(res.charged, false);259 });260
261 it('retries a 429 honoring Retry-After, then charges the success', async () => {262 let calls = 0;263 const res = await T.callTomba('test', params(), async () => {264 calls++;265 if (calls === 1) throw httpError(429, {}, { 'retry-after': '1' });266 return { data: { data: { ok: true } }, rateLimit: {} };267 });268 assert.equal(calls, 2);269 assert.equal(res.charged, true);270 });271
272 it('retries network errors', async () => {273 let calls = 0;274 const res = await T.callTomba('test', params(), async () => {275 calls++;276 if (calls === 1) throw new TombaException('connect ECONNREFUSED 127.0.0.1:9');277 return { data: { data: [1] }, rateLimit: {} };278 });279 assert.equal(calls, 2);280 assert.equal(res.charged, true);281 });282
283 it('gives up after maxRetries on 5xx without charging', async () => {284 let calls = 0;285 const res = await T.callTomba('test', params(), async () => {286 calls++;287 throw httpError(503);288 });289 assert.equal(calls, 2); // 1 attempt + maxRetries (1)290 assert.equal(res.status, 503);291 assert.equal(res.charged, false);292 });293
294 it('charges the given event name', async () => {295 const start = charged(T.EVENT_PHONE_FINDER);296 await T.callTomba('phone', params(), ok({ data: { phone: '+1' } }), T.EVENT_PHONE_FINDER);297 assert.equal(charged(T.EVENT_PHONE_FINDER), start + 1);298 });299
300 it('charges a fixed count of events', async () => {301 const start = charged();302 const res = await T.callTomba('test', params(), ok({ data: [1] }), T.EVENT_REQUEST, 3);303 assert.equal(res.chargedCount, 3);304 assert.equal(charged(), start + 3);305 });306
307 it('charges a count computed from the response body', async () => {308 const start = charged();309 const res = await T.callTomba(310 'test',311 params(),312 ok({ data: { items: [1, 2, 3, 4] } }),313 T.EVENT_REQUEST,314 (body) => (body.data as { items: unknown[] }).items.length,315 );316 assert.equal(res.chargedCount, 4);317 assert.equal(charged(), start + 4);318 });319
320 it('does not charge a count for a non-billable response', async () => {321 const start = charged();322 const res = await T.callTomba('test', params(), ok({ data: [] }), T.EVENT_REQUEST, 5);323 assert.equal(res.charged, false);324 assert.equal(charged(), start);325 });326
327 it('tracks stats', () => {328 assert.ok(T.stats.requests > 0);329 assert.ok(T.stats.charged > 0);330 assert.ok(T.stats.cached > 0);331 assert.ok(T.stats.retries > 0);332 assert.ok(T.stats.failed > 0);333 });334});1// End-to-end tests: run the Actor against a mock Tomba API.2import assert from 'node:assert/strict';3import { after, afterEach, describe, it } from 'node:test';4
5import type { MockHandler, MockServer } from './helpers.js';6import { removeStorage, runActor, startMockTomba, startStandbyActor, totalCharges } from './helpers.js';7
8function person(email: string): Record<string, unknown> {9 return {10 email,11 first_name: 'Patrick',12 last_name: 'Collison',13 full_name: 'Patrick Collison',14 gender: 'male',15 phone_number: false,16 type: 'personal',17 country: 'US',18 position: 'CEO',19 twitter: 'https://twitter.com/patrickc',20 linkedin: 'https://www.linkedin.com/in/patrickcollison',21 accept_all: false,22 website_url: 'stripe.com',23 company: 'Stripe',24 score: 99,25 verification: { date: '2026-09-01T00:00:00+02:00', status: 'valid' },26 sources: [27 {28 uri: 'https://stripe.com/about',29 website_url: 'stripe.com',30 extracted_on: '2024-09-17T11:26:56+02:00',31 last_seen_on: '2026-09-06T04:51:06+02:00',32 still_on_page: true,33 },34 ],35 };36}37
38/** Negative answer: Tomba knows nothing about the person but still returns a data object. */39const NEGATIVE = {40 email: null,41 first_name: null,42 last_name: null,43 full_name: null,44 company: null,45 score: 0,46 verification: { date: null, status: null },47 sources: [],48};49
50/** Phone fixtures returned with `enrich_mobile=true`: two@ has two numbers, nophone@ none, everyone else one. */51function phonesFor(email: string): Record<string, unknown>[] {52 if (email.startsWith('nophone@')) return [];53 if (email.startsWith('two@')) {54 return [55 { number: '+14155550123', type: 'mobile' },56 { number: '+14155550199', type: 'work' },57 ];58 }59 return [{ number: '+14155550123', type: 'mobile' }];60}61
62/** Default Tomba behaviour keyed by the requested email; `phone_data` only with `enrich_mobile=true`. */63const tomba: MockHandler = (req) => {64 assert.equal(req.method, 'GET');65 assert.equal(req.path, '/enrich');66 const { email, enrich_mobile: enrichMobile } = req.query;67 const phones = enrichMobile === 'true' ? { phone_data: phonesFor(email) } : {};68 if (email === 'empty@example.com') return { body: { data: null } };69 if (email === 'emptyobj@example.com') return { body: { data: {} } };70 if (email === 'unknown@example.com') return { body: { data: { ...NEGATIVE, ...phones } } };71 if (email === 'invalid@example.com') return { status: 422, body: { errors: { message: 'Invalid email' } } };72 if (email === 'html@example.com') return { raw: '<html>Bad gateway</html>' };73 return { body: { data: { ...person(email), ...phones } } };74};75
76const servers: MockServer[] = [];77const dirs: string[] = [];78
79async function mock(handler: MockHandler = tomba): Promise<MockServer> {80 const server = await startMockTomba(handler);81 servers.push(server);82 return server;83}84
85async function run(...args: Parameters<typeof runActor>) {86 const result = await runActor(...args);87 dirs.push(result.storageDir);88 return result;89}90
91afterEach(async () => {92 await Promise.all(servers.splice(0).map(async (s) => s.close()));93});94
95after(async () => {96 await Promise.all(dirs.map(removeStorage));97});98
99describe('email-enrichment', () => {100 it('returns the enriched profile and charges one event per billable email', async () => {101 const server = await mock();102 const result = await run({103 input: { emails: ['patrick@stripe.com', 'empty@example.com', 'emptyobj@example.com'] },104 endpoint: server.url,105 });106
107 assert.equal(result.code, 0, result.output);108 assert.equal(result.items.length, 3);109 const found = result.items.find((i) => i.email === 'patrick@stripe.com');110 assert.deepEqual(found, {111 ...person('patrick@stripe.com'),112 source: 'tomba_email_enrichment',113 phoneNumbers: 0,114 charged: true,115 chargedCredits: 1,116 cached: false,117 });118
119 for (const email of ['empty@example.com', 'emptyobj@example.com']) {120 const item = result.items.find((i) => i.email === email);121 assert.deepEqual(item, {122 email,123 source: 'tomba_email_enrichment',124 phoneNumbers: 0,125 charged: false,126 chargedCredits: 0,127 cached: false,128 error: 'No enrichment data found',129 });130 }131
132 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });133 });134
135 it('charges a negative answer and keeps the input email', async () => {136 const server = await mock();137 const result = await run({ input: { emails: ['unknown@example.com'] }, endpoint: server.url });138
139 assert.equal(result.code, 0, result.output);140 assert.equal(result.items.length, 1);141 assert.equal(result.items[0].email, 'unknown@example.com');142 assert.equal(result.items[0].full_name, null);143 assert.equal(result.items[0].charged, true);144 assert.equal(result.items[0].error, undefined);145 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });146 });147
148 it('sends the built-in credentials to Tomba', async () => {149 const server = await mock();150 await run({ input: { emails: ['patrick@stripe.com'] }, endpoint: server.url });151 assert.equal(server.requests.length, 1);152 assert.equal(server.requests[0].headers['x-tomba-key'], 'ta_test_key');153 assert.equal(server.requests[0].headers['x-tomba-secret'], 'ts_test_secret');154 });155
156 it('normalizes and deduplicates emails', async () => {157 const server = await mock();158 const result = await run({159 input: { emails: [' Patrick@Stripe.com ', 'patrick@stripe.com', 'PATRICK@STRIPE.COM', ''] },160 endpoint: server.url,161 });162 assert.deepEqual(163 server.requests.map((r) => r.query.email),164 ['patrick@stripe.com'],165 );166 assert.equal(result.items.length, 1);167 assert.equal(result.items[0].email, 'patrick@stripe.com');168 });169
170 it('does not charge Tomba error statuses and does not retry them', async () => {171 const server = await mock();172 const result = await run({ input: { emails: ['invalid@example.com'] }, endpoint: server.url });173 assert.equal(result.code, 0, result.output);174 assert.equal(server.requests.length, 1);175 assert.equal(result.items[0].charged, false);176 assert.match(String(result.items[0].error), /422: Invalid email/);177 assert.equal(totalCharges(result), 0);178 });179
180 it('does not charge a non-JSON body', async () => {181 const server = await mock();182 const result = await run({ input: { emails: ['html@example.com'] }, endpoint: server.url });183 assert.equal(result.items[0].charged, false);184 assert.match(String(result.items[0].error), /Invalid response/);185 assert.equal(totalCharges(result), 0);186 });187
188 it('retries 429 and 5xx responses, then charges the success once', async () => {189 let calls = 0;190 const server = await mock(async (req) => {191 calls++;192 if (calls === 1)193 return {194 status: 429,195 body: { errors: { message: 'Too many requests' } },196 headers: { 'retry-after': '1' },197 };198 if (calls === 2) return { status: 503, body: {} };199 return tomba(req);200 });201 const result = await run({ input: { emails: ['patrick@stripe.com'], maxRetries: 3 }, endpoint: server.url });202 assert.equal(server.requests.length, 3);203 assert.equal(result.items.length, 1);204 assert.equal(result.items[0].charged, true);205 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });206 });207
208 it('serves repeated runs from the cache for free', async () => {209 const server = await mock();210 const first = await run({ input: { emails: ['patrick@stripe.com'] }, endpoint: server.url });211 const second = await run({212 input: { emails: ['patrick@stripe.com'] },213 endpoint: server.url,214 storageDir: first.storageDir,215 });216
217 assert.equal(server.requests.length, 1);218 assert.equal(second.items.length, 1);219 assert.equal(second.items[0].full_name, 'Patrick Collison');220 assert.equal(second.items[0].cached, true);221 assert.equal(second.items[0].charged, false);222 assert.equal(totalCharges(second), 0);223 });224
225 it('calls Tomba again when the cache is disabled', async () => {226 const server = await mock();227 const first = await run({ input: { emails: ['patrick@stripe.com'], useCache: false }, endpoint: server.url });228 await run({229 input: { emails: ['patrick@stripe.com'], useCache: false },230 endpoint: server.url,231 storageDir: first.storageDir,232 });233 assert.equal(server.requests.length, 2);234 });235
236 it('stops at the max charge limit and resumes without reprocessing', async () => {237 const server = await mock();238 const emails = ['a@a.com', 'b@b.com', 'c@c.com', 'd@d.com', 'e@e.com'];239 const input = { emails, maxConcurrency: 1, useCache: false, maxResults: 100 };240
241 // Locally every event costs $1, so a $2 budget allows two billable requests.242 const first = await run({ input, endpoint: server.url, maxTotalChargeUsd: 2 });243 assert.equal(first.code, 0, first.output);244 assert.equal(totalCharges(first), 2);245 assert.equal(server.requests.length, 2);246 assert.equal(first.items.length, 2);247
248 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir, keepStorage: true });249 assert.equal(second.code, 0, second.output);250 assert.deepEqual(251 server.requests.map((r) => r.query.email),252 emails,253 );254 assert.equal(second.items.length, 5);255 });256
257 it('respects maxResults', async () => {258 const server = await mock();259 const result = await run({260 input: { emails: ['a@a.com', 'b@b.com', 'c@c.com'], maxResults: 2, maxConcurrency: 1 },261 endpoint: server.url,262 });263 assert.equal(result.items.length, 2);264 assert.equal(server.requests.length, 2);265 });266
267 it('runs requests in parallel', async () => {268 let active = 0;269 let peak = 0;270 const server = await mock(async (req) => {271 active++;272 peak = Math.max(peak, active);273 await new Promise((r) => {274 setTimeout(r, 100);275 });276 active--;277 return tomba(req);278 });279 const emails = Array.from({ length: 8 }, (_, i) => `person${i}@example.org`);280 await run({ input: { emails, maxConcurrency: 4 }, endpoint: server.url });281 assert.equal(server.requests.length, 8);282 assert.ok(peak > 1 && peak <= 4, `peak concurrency ${peak}`);283 });284
285 it('fails without Tomba credentials and never calls the API', async () => {286 const server = await mock();287 const result = await run({288 input: { emails: ['patrick@stripe.com'] },289 endpoint: server.url,290 withCredentials: false,291 });292 assert.notEqual(result.code, 0);293 assert.match(result.output, /misconfigured/);294 assert.doesNotMatch(result.output, /ta_test_key|ts_test_secret/);295 assert.equal(server.requests.length, 0);296 });297
298 it('sends enrich_mobile and webhook_url only when they are set', async () => {299 const server = await mock();300 await run({ input: { emails: ['patrick@stripe.com'], enrichMobile: false }, endpoint: server.url });301 await run({302 input: {303 emails: ['john@stripe.com'],304 enrichMobile: true,305 webhookUrl: 'https://hooks.example.com/tomba',306 },307 endpoint: server.url,308 });309 assert.deepEqual(310 server.requests.map((r) => r.query),311 [312 { email: 'patrick@stripe.com' },313 { email: 'john@stripe.com', enrich_mobile: 'true', webhook_url: 'https://hooks.example.com/tomba' },314 ],315 );316 });317
318 it('fails on a webhook URL that is not http(s) and never calls the API', async () => {319 const server = await mock();320 const result = await run({321 input: { emails: ['patrick@stripe.com'], webhookUrl: 'ftp://hooks.example.com' },322 endpoint: server.url,323 });324 assert.notEqual(result.code, 0);325 assert.equal(server.requests.length, 0);326 });327
328 it('charges 1 credit without enrichMobile and returns no phone data', async () => {329 const server = await mock();330 const result = await run({ input: { emails: ['two@stripe.com'] }, endpoint: server.url });331 assert.equal(result.items[0].phone_data, undefined);332 assert.equal(result.items[0].phoneNumbers, 0);333 assert.equal(result.items[0].chargedCredits, 1);334 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });335 });336
337 it('charges 6 credits per result with phone data, whatever the number of phones', async () => {338 const server = await mock();339 const result = await run({340 input: { emails: ['patrick@stripe.com', 'two@stripe.com'], enrichMobile: true },341 endpoint: server.url,342 });343 assert.equal(result.code, 0, result.output);344 const one = result.items.find((i) => i.email === 'patrick@stripe.com');345 const two = result.items.find((i) => i.email === 'two@stripe.com');346 assert.deepEqual(one?.phone_data, [{ number: '+14155550123', type: 'mobile' }]);347 assert.equal(one?.phoneNumbers, 1);348 assert.equal(one?.chargedCredits, 6);349 assert.equal(one?.charged, true);350 assert.equal(two?.phoneNumbers, 2);351 assert.equal(two?.chargedCredits, 6);352 assert.deepEqual(result.chargeCounts, { 'tomba-request': 12 });353 });354
355 it('charges 1 credit with enrichMobile when no phone number is found', async () => {356 const server = await mock();357 const result = await run({358 input: { emails: ['nophone@stripe.com'], enrichMobile: true },359 endpoint: server.url,360 });361 assert.deepEqual(result.items[0].phone_data, []);362 assert.equal(result.items[0].phoneNumbers, 0);363 assert.equal(result.items[0].chargedCredits, 1);364 assert.deepEqual(result.chargeCounts, { 'tomba-request': 1 });365 });366
367 it('serves phone data from the cache for free', async () => {368 const server = await mock();369 const input = { emails: ['two@stripe.com'], enrichMobile: true };370 const first = await run({ input, endpoint: server.url });371 assert.equal(totalCharges(first), 6);372 const second = await run({ input, endpoint: server.url, storageDir: first.storageDir });373 assert.equal(server.requests.length, 1);374 assert.equal(second.items[0].cached, true);375 assert.equal(second.items[0].charged, false);376 assert.equal(second.items[0].chargedCredits, 0);377 assert.equal(second.items[0].phoneNumbers, 2);378 assert.equal(totalCharges(second), 0);379 });380
381 it('does not reuse a cached result without phone data when enrichMobile is turned on', async () => {382 const server = await mock();383 const first = await run({ input: { emails: ['patrick@stripe.com'] }, endpoint: server.url });384 const second = await run({385 input: { emails: ['patrick@stripe.com'], enrichMobile: true },386 endpoint: server.url,387 storageDir: first.storageDir,388 });389 assert.equal(server.requests.length, 2);390 assert.equal(second.items[0].phoneNumbers, 1);391 assert.equal(totalCharges(second), 6);392 });393
394 it('fails on empty input', async () => {395 const server = await mock();396 const result = await run({ input: { emails: [] }, endpoint: server.url });397 assert.notEqual(result.code, 0);398 assert.equal(server.requests.length, 0);399 });400
401 it('skips blank entries without calling Tomba', async () => {402 const server = await mock();403 const result = await run({ input: { emails: ['', ' '] }, endpoint: server.url });404 assert.equal(result.code, 0, result.output);405 assert.equal(server.requests.length, 0);406 assert.equal(result.items.length, 0);407 assert.equal(totalCharges(result), 0);408 });409});410
411describe('email-enrichment standby (real-time API)', () => {412 it('answers the readiness probe and a bare GET with usage info', async () => {413 const server = await mock();414 const actor = await startStandbyActor({ endpoint: server.url });415 try {416 const probe = await actor.call('/', { headers: { 'x-apify-container-server-readiness-probe': '1' } });417 assert.equal(probe.status, 200);418 const usage = await actor.call('/');419 assert.equal(usage.status, 200);420 assert.match(String(usage.body.usage), /GET/);421 assert.equal(server.requests.length, 0);422 } finally {423 await actor.stop();424 }425 });426
427 it('enriches emails from GET query parameters and charges per credit', async () => {428 const server = await mock();429 const actor = await startStandbyActor({ endpoint: server.url });430 let stopped;431 try {432 const res = await actor.call('/?email=Patrick@Stripe.com&email=empty@example.com');433 assert.equal(res.status, 200);434 const items = res.body.items as Record<string, unknown>[];435 assert.deepEqual(436 items.find((i) => i.email === 'patrick@stripe.com'),437 {438 ...person('patrick@stripe.com'),439 source: 'tomba_email_enrichment',440 phoneNumbers: 0,441 charged: true,442 chargedCredits: 1,443 cached: false,444 },445 );446 assert.equal(items.find((i) => i.email === 'empty@example.com')?.error, 'No enrichment data found');447
448 const phones = await actor.call('/?emails=two@stripe.com&enrichMobile=true');449 const [item] = phones.body.items as Record<string, unknown>[];450 assert.equal(item.phoneNumbers, 2);451 assert.equal(item.chargedCredits, 6);452 assert.equal(server.requests.at(-1)?.query.enrich_mobile, 'true');453 } finally {454 stopped = await actor.stop();455 }456 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 7 });457 });458
459 it('accepts a POST with the same JSON input as a normal run', async () => {460 const server = await mock();461 const actor = await startStandbyActor({ endpoint: server.url });462 try {463 const res = await actor.call('/', {464 body: { emails: ['a@stripe.com', 'b@stripe.com', 'c@stripe.com'], maxResults: 2 },465 });466 assert.equal(res.status, 200);467 assert.equal((res.body.items as unknown[]).length, 2);468 assert.equal(server.requests.length, 2);469 } finally {470 await actor.stop();471 }472 });473
474 it('serves repeated requests from the cache for free', async () => {475 const server = await mock();476 const actor = await startStandbyActor({ endpoint: server.url });477 let stopped;478 try {479 await actor.call('/?email=patrick@stripe.com');480 const second = await actor.call('/?email=patrick@stripe.com');481 assert.ok((second.body.items as Record<string, unknown>[]).every((i) => i.cached === true));482 assert.equal(server.requests.length, 1);483 } finally {484 stopped = await actor.stop();485 }486 assert.deepEqual(stopped.chargeCounts, { 'tomba-request': 1 });487 });488
489 it('keeps serving after a request hits maxResults', async () => {490 const server = await mock();491 const actor = await startStandbyActor({ endpoint: server.url });492 try {493 const first = await actor.call('/?email=a@stripe.com,b@stripe.com&maxResults=1');494 assert.equal((first.body.items as unknown[]).length, 1);495 const second = await actor.call('/?email=c@stripe.com&email=d@stripe.com');496 assert.equal((second.body.items as unknown[]).length, 2);497 } finally {498 await actor.stop();499 }500 });501
502 it('rejects invalid input with 400 and unknown paths with 404', async () => {503 const server = await mock();504 const actor = await startStandbyActor({ endpoint: server.url });505 try {506 assert.equal((await actor.call('/', { body: {} })).status, 400);507 assert.equal((await actor.call('/', { body: 'not json' })).status, 400);508 assert.equal((await actor.call('/?maxResults=abc&email=a@stripe.com')).status, 400);509 assert.equal((await actor.call('/?enrichMobile=maybe&email=a@stripe.com')).status, 400);510 assert.equal((await actor.call('/?email=a@stripe.com&webhookUrl=ftp://x')).status, 400);511 assert.equal((await actor.call('/nope')).status, 404);512 assert.equal((await actor.call('/', { method: 'DELETE' })).status, 405);513 assert.equal(server.requests.length, 0);514 } finally {515 await actor.stop();516 }517 });518
519 it('returns 402 once the max charge limit is reached', async () => {520 const server = await mock();521 const actor = await startStandbyActor({ endpoint: server.url, maxTotalChargeUsd: 1 });522 try {523 const first = await actor.call('/?email=a@stripe.com');524 assert.equal(first.status, 200);525 const second = await actor.call('/?email=b@stripe.com');526 assert.equal(second.status, 402);527 assert.equal(server.requests.length, 1);528 } finally {529 await actor.stop();530 }531 });532});1// Shared runner for the Tomba Apify Actors: batch runs and Standby (real-time HTTP API) mode.2// Keep this file identical across all Actors.3import type { IncomingMessage, ServerResponse } from 'node:http';4import { createServer } from 'node:http';5
6import { Actor, log } from 'apify';7
8import type { RunOptions } from './tomba.js';9import { isStopped, logSummary, setupTomba, useRunState } from './tomba.js';10
11/** Invalid input: a failed batch run, or `400 Bad Request` in Standby mode. */12export class InputError extends Error {}13
14export type Item = Record<string, unknown>;15
16export interface RunContext {17 /** Save result items (dataset in batch runs, HTTP response in Standby mode). */18 push: (items: Item | Item[]) => Promise<void>;19 /** True when this input was already processed (batch runs resume after a migration or restart). */20 isDone: (key: string) => boolean;21 /** Record a processed input so a resumed run skips it. */22 markDone: (key: string) => void;23 standby: boolean;24}25
26/** Processes one Actor input. Throws InputError for invalid input. */27export type ActorRun<I> = (input: I, ctx: RunContext) => Promise<void>;28
29export interface ActorDefinition<I> {30 /** Name used in logs. */31 title: string;32 run: ActorRun<I>;33 /** Builds the Actor input from `GET /?…` query parameters in Standby mode. */34 fromQuery: (query: URLSearchParams) => I;35 /** Number of inputs, for the batch summary log. */36 count?: (input: I) => number;37}38
39const MAX_BODY_BYTES = 1_000_000;40
41export function isStandby(): boolean {42 return Actor.config.get('metaOrigin') === 'STANDBY';43}44
45/** Entry point: runs the Actor as a batch job, or as an HTTP server in Standby mode. */46export async function runActor<I extends RunOptions>(definition: ActorDefinition<I>): Promise<void> {47 await Actor.init();48
49 if (isStandby()) {50 await setupTomba();51 await startStandbyServer(definition);52 return;53 }54
55 const input = ((await Actor.getInput<I>()) ?? {}) as I;56 await setupTomba(input);57 const state = await useRunState();58 const startedAt = Date.now();59 try {60 await definition.run(input, {61 push: async (items) => Actor.pushData(items),62 isDone: (key) => Boolean(state.done[key]),63 markDone: (key) => {64 state.done[key] = true;65 },66 standby: false,67 });68 } catch (err) {69 if (err instanceof InputError) {70 await Actor.fail(err.message);71 return;72 }73 throw err;74 }75 logSummary(definition.title, definition.count?.(input) ?? 0, startedAt);76 await Actor.exit();77}78
79async function startStandbyServer<I>(definition: ActorDefinition<I>): Promise<void> {80 const server = createServer((req, res) => {81 handleRequest(definition, req, res).catch((err: Error) => {82 log.exception(err, 'Standby request failed');83 send(res, 500, { error: 'Internal error' });84 });85 });86 const port = Actor.config.get('containerPort');87 await new Promise<void>((resolve) => {88 server.listen(port, resolve);89 });90 log.info(`${definition.title} is ready for real-time requests`, { port });91}92
93async function handleRequest<I>(definition: ActorDefinition<I>, req: IncomingMessage, res: ServerResponse) {94 if (req.headers['x-apify-container-server-readiness-probe']) {95 send(res, 200, { status: 'ready' });96 return;97 }98
99 const url = new URL(req.url ?? '/', 'http://localhost');100 if (url.pathname !== '/') {101 send(res, 404, { error: 'Not found. Use GET / with query parameters or POST / with a JSON input.' });102 return;103 }104
105 let input: I;106 try {107 if (req.method === 'GET') {108 if ([...url.searchParams.keys()].length === 0) {109 send(res, 200, {110 status: 'ready',111 usage: 'GET /?<parameters> or POST / with the same JSON input as a normal run.',112 });113 return;114 }115 input = definition.fromQuery(url.searchParams);116 } else if (req.method === 'POST') {117 input = (await readJson(req)) as I;118 } else {119 send(res, 405, { error: 'Method not allowed. Use GET or POST.' });120 return;121 }122 } catch (err) {123 send(res, 400, { error: (err as Error).message });124 return;125 }126
127 if (isStopped()) {128 send(res, 402, { error: 'Max charge limit reached for this Actor run.' });129 return;130 }131
132 const items: Item[] = [];133 try {134 await definition.run(input, {135 push: async (pushed) => {136 items.push(...(Array.isArray(pushed) ? pushed : [pushed]));137 },138 isDone: () => false,139 markDone: () => undefined,140 standby: true,141 });142 } catch (err) {143 if (err instanceof InputError) {144 send(res, 400, { error: err.message });145 return;146 }147 throw err;148 }149 send(res, 200, { items });150}151
152async function readJson(req: IncomingMessage): Promise<unknown> {153 const chunks: Buffer[] = [];154 let size = 0;155 for await (const chunk of req) {156 size += (chunk as Buffer).length;157 if (size > MAX_BODY_BYTES) throw new InputError('Request body is too large (max 1 MB).');158 chunks.push(chunk as Buffer);159 }160 const text = Buffer.concat(chunks).toString('utf8').trim();161 if (!text) return {};162 try {163 const body = JSON.parse(text);164 if (typeof body !== 'object' || body === null || Array.isArray(body)) {165 throw new InputError('Request body must be a JSON object.');166 }167 return body;168 } catch (err) {169 if (err instanceof InputError) throw err;170 throw new InputError('Request body must be valid JSON.');171 }172}173
174function send(res: ServerResponse, status: number, body: unknown): void {175 if (res.headersSent) return;176 res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });177 res.end(JSON.stringify(body));178}179
180// --- Query string helpers for `fromQuery` ---------------------------------------------------181
182/** Values of a list parameter: repeated (`?domain=a&domain=b`) and/or comma-separated (`?domains=a,b`). */183export function queryList(query: URLSearchParams, ...names: string[]): string[] {184 return names185 .flatMap((name) => query.getAll(name))186 .flatMap((value) => value.split(','))187 .map((value) => value.trim())188 .filter(Boolean);189}190
191export function queryString(query: URLSearchParams, name: string): string | undefined {192 const value = query.get(name)?.trim();193 return value || undefined;194}195
196export function queryBool(query: URLSearchParams, name: string): boolean | undefined {197 const value = query.get(name)?.trim().toLowerCase();198 if (value === undefined || value === '') return undefined;199 if (['1', 'true', 'yes'].includes(value)) return true;200 if (['0', 'false', 'no'].includes(value)) return false;201 throw new InputError(`Query parameter "${name}" must be true or false.`);202}203
204export function queryInt(query: URLSearchParams, name: string): number | undefined {205 const value = query.get(name)?.trim();206 if (value === undefined || value === '') return undefined;207 const number = Number(value);208 if (!Number.isInteger(number)) throw new InputError(`Query parameter "${name}" must be an integer.`);209 return number;210}1// Shared Tomba helper for the Tomba Apify Actors.2// Keep this file identical across all Actors.3import { createHash } from 'node:crypto';4
5import type { KeyValueStore } from 'apify';6import { Actor, log } from 'apify';7import { TombaClient, TombaException } from 'tomba';8
9/** Pay-per-event event names (configured in Apify Console → Monetization). */10export const EVENT_REQUEST = 'tomba-request';11export const EVENT_PHONE_FINDER = 'phone-finder-request';12
13/**14 * Tomba credits added per phone number (or per address with phone data, depending on the endpoint)15 * returned when `enrich_mobile=true`. Every credit is charged as one `tomba-request` event.16 */17export const PHONE_CREDITS = 5;18
19/** Number of phone numbers in a record's `phone_data` array (0 when missing or empty). */20export function phoneDataCount(record: unknown): number {21 if (!isObject(record)) return 0;22 return Array.isArray(record.phone_data) ? record.phone_data.length : 0;23}24
25/** True when a record has at least one phone number in `phone_data`. */26export function hasPhoneData(record: unknown): boolean {27 return phoneDataCount(record) > 0;28}29
30export interface RunOptions {31 maxConcurrency?: number;32 maxRetries?: number;33 useCache?: boolean;34 cacheTtlHours?: number;35}36
37export interface TombaCallResult {38 /** Raw JSON body returned by Tomba (`{ data, meta, ... }`). */39 body?: Record<string, unknown>;40 /** Shortcut to `body.data`. */41 data?: unknown;42 /** HTTP status (0 for network errors, 502 for a non-JSON body). */43 status: number;44 charged: boolean;45 /** Number of events charged for this call (0 when not charged). */46 chargedCount?: number;47 cached: boolean;48 /** True when the call was not made because the user's max charge limit was reached. */49 skipped?: boolean;50 error?: string;51}52
53export const stats = {54 requests: 0,55 charged: 0,56 cached: 0,57 failed: 0,58 retries: 0,59};60
61const CACHE_STORE_PREFIX = 'tomba-cache';62const BASE_DELAY_MS = 1000;63const MAX_DELAY_MS = 30000;64const NETWORK_ERROR = /ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|socket hang up|timeout|network/i;65
66let client: TombaClient | undefined;67let options: Required<RunOptions> = { maxConcurrency: 10, maxRetries: 3, useCache: true, cacheTtlHours: 24 };68let cacheStore: KeyValueStore | undefined;69const memoryCache = new Map<string, Record<string, unknown>>();70const inFlight = new Map<string, number>();71let stopped = false;72
73/** Initialise the Tomba client from the Actor's environment variables and apply the run options. */74export async function setupTomba(runOptions: RunOptions = {}): Promise<TombaClient> {75 const key = process.env.TOMBA_API_KEY;76 const secret = process.env.TOMBA_API_SECRET;77 if (!key || !secret) {78 await Actor.fail('Actor is misconfigured: Tomba credentials are missing. Please contact the Actor developer.');79 throw new Error('unreachable');80 }81
82 options = {83 maxConcurrency: clamp(runOptions.maxConcurrency ?? 10, 1, 50),84 maxRetries: clamp(runOptions.maxRetries ?? 3, 0, 10),85 useCache: runOptions.useCache ?? true,86 cacheTtlHours: Math.max(0, runOptions.cacheTtlHours ?? 24),87 };88
89 client = new TombaClient();90 // Verification and enrichment can take over a minute, so keep the SDK's 120 s timeout.91 client.setKey(key).setSecret(secret).setTimeout(120_000);92 // Only used by the test suite to point the client at a local mock server.93 if (process.env.TOMBA_API_ENDPOINT) client.setEndpoint(process.env.TOMBA_API_ENDPOINT);94
95 if (options.useCache && options.cacheTtlHours > 0) {96 try {97 cacheStore = await Actor.openKeyValueStore(cacheStoreName());98 } catch (err) {99 // E.g. "Insufficient permissions" under limited permissions: never fail the run because of the cache.100 log.warning('Cross-run cache is unavailable; results are cached for this run only.', {101 error: (err as Error).message,102 });103 cacheStore = undefined;104 }105 }106
107 return client;108}109
110/**111 * Name of the cross-run cache store. It is per Actor: with limited permissions an Actor can only open112 * named storages it created itself, so the Tomba Actors must not share one store.113 */114export function cacheStoreName(): string {115 const actorId = Actor.getEnv().actorId ?? process.env.ACTOR_ID;116 return actorId ? `${CACHE_STORE_PREFIX}-${actorId}` : CACHE_STORE_PREFIX;117}118
119/** The Tomba client created by setupTomba(). */120export function getClient(): TombaClient {121 if (!client) throw new Error('setupTomba() must be called first');122 return client;123}124
125export function getOptions(): Required<RunOptions> {126 return options;127}128
129/** True once the user's max-charge budget is exhausted; workers should stop picking up new items. */130export function isStopped(): boolean {131 return stopped;132}133
134export function stop(): void {135 stopped = true;136}137
138/**139 * Tomba billing rules: charge only when Tomba returns a JSON body without an `errors` object140 * and with a non-empty `data` (a negative answer such as an undeliverable verification141 * or a domain search with no emails is still a non-empty `data` object).142 */143export function isBillable(body: unknown): boolean {144 if (!isObject(body)) return false;145 if (body.errors !== undefined && body.errors !== null) return false;146 const { data } = body;147 if (data === null || data === undefined) return false;148 if (Array.isArray(data)) return data.length > 0;149 // An object whose values are all null (e.g. `{ person: null, company: null }`) is an empty result.150 if (isObject(data)) return Object.values(data).some((v) => v !== null && v !== undefined);151 return data !== '' && data !== false;152}153
154/**155 * Perform one Tomba API call with caching, retry and pay-per-event charging.156 *157 * @param endpoint short endpoint name used for the cache key (e.g. 'technology')158 * @param params request parameters used for the cache key159 * @param fn the SDK call160 * @param eventName pay-per-event name to charge when the response is billable161 * @param count events to charge for a billable response: a number, or a function of the response body162 */163export async function callTomba(164 endpoint: string,165 params: Record<string, unknown>,166 fn: (tomba: TombaClient) => Promise<unknown>,167 eventName: string = EVENT_REQUEST,168 count: number | ((body: Record<string, unknown>) => number) = 1,169): Promise<TombaCallResult> {170 if (!client) throw new Error('setupTomba() must be called first');171
172 const cacheKey = hashKey(endpoint, params);173 const cachedBody = await readCache(cacheKey);174 if (cachedBody) {175 stats.cached++;176 return { body: cachedBody, data: cachedBody.data, status: 200, charged: false, cached: true };177 }178
179 // Reserve budget before calling Tomba so concurrent workers never exceed the user's max charge.180 // A fixed count is known up front, so reserve all of it; a body-dependent count reserves at least 1.181 const reserved = typeof count === 'number' ? Math.max(1, Math.ceil(count)) : 1;182 if (!reserve(eventName, reserved)) {183 stop();184 return { status: 0, charged: false, cached: false, skipped: true, error: 'Max charge limit reached' };185 }186
187 try {188 for (let attempt = 0; ; attempt++) {189 try {190 stats.requests++;191 const body = unwrap(await fn(client));192
193 if (!isObject(body)) {194 stats.failed++;195 return { status: 502, charged: false, cached: false, error: 'Invalid response from Tomba' };196 }197
198 if (!isBillable(body)) {199 const error = errorMessage(body) ?? undefined;200 return { body, data: body.data, status: 200, charged: false, cached: false, error };201 }202
203 const units = Math.max(1, Math.ceil(typeof count === 'function' ? count(body) : count));204 const chargedCount = await charge(eventName, units);205 const charged = chargedCount > 0;206 await writeCache(cacheKey, body);207 return { body, data: body.data, status: 200, charged, chargedCount, cached: false };208 } catch (err) {209 const { status, message, retryAfter } = parseError(err);210 const retryable = status === 0 || status === 429 || status >= 500;211 if (retryable && attempt < options.maxRetries) {212 stats.retries++;213 const delay = retryAfter ?? Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);214 const jitter = Math.random() * 250;215 log.warning(`Tomba ${endpoint} returned ${status || 'network error'}, retrying in ${delay}ms`, {216 attempt: attempt + 1,217 });218 await sleep(delay + jitter);219 continue;220 }221 stats.failed++;222 return { status, charged: false, cached: false, error: message };223 }224 }225 } finally {226 release(eventName, reserved);227 }228}229
230/**231 * Run `worker` over `items` with bounded concurrency. Stops picking new items once the charge limit232 * is reached (`isStopped()`) or `shouldStop()` returns true (e.g. maxResults reached for this run).233 */234export async function runPool<T>(235 items: T[],236 worker: (item: T, index: number) => Promise<void>,237 concurrency = options.maxConcurrency,238 shouldStop: () => boolean = () => false,239): Promise<void> {240 let next = 0;241 const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {242 while (!stopped && !shouldStop() && next < items.length) {243 const index = next++;244 await worker(items[index], index);245 }246 });247 await Promise.all(runners);248}249
250/** Persisted run state used to resume after a migration, restart or resurrection. */251export async function useRunState(): Promise<{ done: Record<string, true> }> {252 return Actor.useState<{ done: Record<string, true> }>('TOMBA_STATE', { done: {} });253}254
255export function logSummary(title: string, total: number, startedAt: number): void {256 const seconds = Math.round((Date.now() - startedAt) / 1000);257 log.info(`${title} finished`, {258 inputs: total,259 tombaRequests: stats.requests,260 charged: stats.charged,261 cacheHits: stats.cached,262 failed: stats.failed,263 retries: stats.retries,264 seconds,265 stoppedByChargeLimit: stopped,266 });267}268
269export function normalizeDomain(value: string): string {270 return value271 .trim()272 .toLowerCase()273 .replace(/^https?:\/\//, '')274 .replace(/^www\./, '')275 .replace(/\/.*$/, '');276}277
278export function normalizeEmail(value: string): string {279 return value.trim().toLowerCase();280}281
282export function unique<T>(items: T[], key: (item: T) => string = String): T[] {283 const seen = new Set<string>();284 return items.filter((item) => {285 const k = key(item);286 if (!k || seen.has(k)) return false;287 seen.add(k);288 return true;289 });290}291
292// ---------------------------------------------------------------------------293
294async function charge(eventName: string, count: number): Promise<number> {295 const result = await Actor.charge({ eventName, count });296 if (result.chargedCount > 0) stats.charged += result.chargedCount;297 if (result.eventChargeLimitReached) {298 log.info('Max charge limit reached, finishing current items and stopping.');299 stop();300 }301 return result.chargedCount;302}303
304function reserve(eventName: string, units: number): boolean {305 if (stopped) return false;306 const manager = Actor.getChargingManager();307 const remaining = manager.calculateMaxEventChargeCountWithinLimit(eventName);308 const current = inFlight.get(eventName) ?? 0;309 if (remaining - current < units) return false;310 inFlight.set(eventName, current + units);311 return true;312}313
314function release(eventName: string, units: number): void {315 inFlight.set(eventName, Math.max(0, (inFlight.get(eventName) ?? units) - units));316}317
318async function readCache(key: string): Promise<Record<string, unknown> | undefined> {319 if (!options.useCache) return undefined;320 const hit = memoryCache.get(key);321 if (hit) return hit;322 if (!cacheStore) return undefined;323 const entry = await cacheStore.getValue<{ savedAt: number; body: Record<string, unknown> }>(key);324 if (!entry) return undefined;325 if (Date.now() - entry.savedAt > options.cacheTtlHours * 3600_000) return undefined;326 memoryCache.set(key, entry.body);327 return entry.body;328}329
330async function writeCache(key: string, body: Record<string, unknown>): Promise<void> {331 if (!options.useCache) return;332 memoryCache.set(key, body);333 if (cacheStore) {334 try {335 await cacheStore.setValue(key, { savedAt: Date.now(), body });336 } catch (err) {337 log.debug('Failed to write cache entry', { error: (err as Error).message });338 }339 }340}341
342function hashKey(endpoint: string, params: Record<string, unknown>): string {343 const sorted = Object.keys(params)344 .sort()345 .map((k) => [k, params[k]]);346 return `${endpoint}-${createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 40)}`;347}348
349/** tomba >= 1.1.0 resolves to `{ data: body, rateLimit }`; older versions resolve to the body itself. */350function unwrap(response: unknown): unknown {351 if (isObject(response) && 'rateLimit' in response && 'data' in response) return response.data;352 return response;353}354
355function parseError(err: unknown): { status: number; message: string; retryAfter?: number } {356 if (err instanceof TombaException) {357 const response = err.code as { status?: number; headers?: Record<string, string> } | undefined;358 // Without an HTTP response it is either a network error (retryable, status 0)359 // or the SDK's own input validation (not retryable, treated as 400).360 const networkError = NETWORK_ERROR.test(err.message ?? '');361 let status = networkError ? 0 : 400;362 if (typeof response?.status === 'number') status = response.status;363 const retryAfterHeader = Number(response?.headers?.['retry-after']);364 const retryAfter =365 Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader * 1000 : undefined;366 const message = errorMessage(err.response) ?? err.message ?? 'Tomba request failed';367 return { status, message: status ? `${status}: ${message}` : message, retryAfter };368 }369 return { status: 0, message: err instanceof Error ? err.message : String(err) };370}371
372function errorMessage(body: unknown): string | undefined {373 if (!isObject(body)) return typeof body === 'string' && body.length < 300 ? body : undefined;374 const errors = body.errors as Record<string, unknown> | undefined;375 if (isObject(errors) && typeof errors.message === 'string') return errors.message;376 return undefined;377}378
379function isObject(value: unknown): value is Record<string, unknown> {380 return typeof value === 'object' && value !== null && !Array.isArray(value);381}382
383function clamp(value: number, min: number, max: number): number {384 return Math.min(max, Math.max(min, Math.floor(value)));385}386
387async function sleep(ms: number): Promise<void> {388 return new Promise((resolve) => {389 setTimeout(resolve, ms);390 });391}1import { log } from 'apify';2import { Finder } from 'tomba';3
4import { InputError, queryBool, queryInt, queryList, queryString, runActor } from './standby.js';5import type { RunOptions } from './tomba.js';6import {7 callTomba,8 getClient,9 hasPhoneData,10 isBillable,11 normalizeEmail,12 PHONE_CREDITS,13 phoneDataCount,14 runPool,15 unique,16} from './tomba.js';17
18interface ActorInput extends RunOptions {19 emails?: string[];20 maxResults?: number;21 enrichMobile?: boolean;22 webhookUrl?: string;23}24
25const SOURCE = 'tomba_email_enrichment';26
27/** Tomba: 1 search credit, or 6 when phone data is returned (`enrich_mobile=true`). */28const credits = (body: Record<string, unknown>) => 1 + (hasPhoneData(body.data) ? PHONE_CREDITS : 0);29
30await runActor<ActorInput>({31 title: 'Email Enrichment',32 count: (input) => input.emails?.length ?? 0,33 fromQuery: (query) => ({34 emails: queryList(query, 'email', 'emails'),35 enrichMobile: queryBool(query, 'enrichMobile'),36 webhookUrl: queryString(query, 'webhookUrl'),37 maxResults: queryInt(query, 'maxResults'),38 }),39 run: async (input, { push, isDone, markDone, standby }) => {40 if (!Array.isArray(input.emails) || !input.emails.length) {41 throw new InputError('Input must contain at least one email in "emails".');42 }43
44 const maxResults = input.maxResults ?? 50;45 const enrichMobile = input.enrichMobile ?? false;46 const webhookUrl = typeof input.webhookUrl === 'string' ? input.webhookUrl.trim() : '';47 if (webhookUrl && !/^https?:\/\//.test(webhookUrl)) {48 throw new InputError('"webhookUrl" must start with http:// or https://.');49 }50
51 const finder = new Finder(getClient());52
53 const emails = unique(input.emails.map((email) => (typeof email === 'string' ? normalizeEmail(email) : '')));54 const doneCount = emails.filter((email) => isDone(email)).length;55 const pending = emails.filter((email) => !isDone(email)).slice(0, Math.max(0, maxResults - doneCount));56 if (doneCount > 0) {57 log.info(`Resuming: ${doneCount} emails already processed.`);58 }59 if (!standby) log.info(`Enriching ${pending.length} emails${enrichMobile ? ' (with phone numbers)' : ''}`);60
61 await runPool(pending, async (email) => {62 // Optional parameters are only sent (and only part of the cache key) when set.63 const params: Record<string, unknown> = { email };64 if (enrichMobile) params.enrich_mobile = true;65 if (webhookUrl) params.webhook_url = webhookUrl;66
67 const res = await callTomba(68 'enrich',69 params,70 async () => finder.emailEnrichment(email, enrichMobile || undefined, webhookUrl || undefined),71 undefined,72 credits,73 );74 if (res.skipped) return;75
76 const chargedCredits = res.chargedCount ?? 0;77 if (isBillable(res.body)) {78 const data = res.data as Record<string, unknown>;79 const phoneNumbers = phoneDataCount(data);80 await push({81 ...data,82 email,83 source: SOURCE,84 phoneNumbers,85 charged: res.charged,86 chargedCredits,87 cached: res.cached,88 });89 const name = data.full_name ?? data.first_name ?? 'Unknown Person';90 const phones = phoneNumbers ? `, ${phoneNumbers} phone numbers` : '';91 log.info(`${email}: ${String(name)}${phones}${res.cached ? ' (cached)' : ''}`);92 } else {93 await push({94 email,95 source: SOURCE,96 phoneNumbers: 0,97 charged: res.charged,98 chargedCredits,99 cached: res.cached,100 error: res.error ?? 'No enrichment data found',101 });102 log.info(`${email}: ${res.error ?? 'no enrichment data found'}`);103 }104
105 markDone(email);106 });107 },108});name: Test and push latest version
on: push: branches: - main - master pull_request: workflow_dispatch:
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
jobs: test: name: Format, lint, build and test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 cache: npm
- name: Install dependencies run: npm ci
- name: Check formatting run: npm run format:check
- name: Lint run: npm run lint
- name: Build run: npm run build
- name: Test run: npm test
push: name: Push Actor to Apify needs: test # Only publish from the default branch, never from pull requests. if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
# .actor/actor.json maps TOMBA_API_KEY / TOMBA_API_SECRET to the Apify CLI secrets # @tombaApiKey / @tombaApiSecret, which `apify push` reads from ~/.apify/secrets.json. - name: Provide Tomba secrets to the Apify CLI env: TOMBA_API_KEY: ${{ secrets.TOMBA_API_KEY }} TOMBA_API_SECRET: ${{ secrets.TOMBA_API_SECRET }} run: | if [ -z "$TOMBA_API_KEY" ] || [ -z "$TOMBA_API_SECRET" ]; then echo "::error::Add the TOMBA_API_KEY and TOMBA_API_SECRET repository secrets." exit 1 fi mkdir -p ~/.apify node -e 'require("fs").writeFileSync(require("os").homedir() + "/.apify/secrets.json", JSON.stringify({ tombaApiKey: process.env.TOMBA_API_KEY, tombaApiSecret: process.env.TOMBA_API_SECRET }))'
- name: Push Actor to Apify id: push uses: apify/push-actor-action@v1 with: token: ${{ secrets.APIFY_TOKEN }}
- name: Print build details run: | echo "Build ${{ steps.push.outputs.build-id }} finished with status ${{ steps.push.outputs.build-status }}"