Register a portfolio with thousands of companies and receive the right news
Create a portfolio, register companies in bulk without tripping rate limits, get notified as registrations land, and pull the material news for the whole portfolio.
Last updated
A portfolio is a named set of companies you want watched. Once companies are in it, one article query covers the whole set — no per-company fan-out — and you can narrow that stream down to the categories that matter to your risk team.
Before you start
- An API key and a working first call — see Authenticate and make your first request.
- Your company list, with the best identifier you have for each row: a DUNS number, a local registration number plus country, or a name plus country.
- Your own key for each row (an ERP or CRM id) to pass as
customer_reference, so results map back to your system later.
Step 1: Create the portfolio
Create a portfolio
curl -sS -X POST https://api.businessradar.com/ext/v3/portfolios \
-H "Authorization: Bearer $BUSINESSRADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Suppliers EU",
"customer_reference": "erp-suppliers-eu",
"default_permission": "view_only"
}'portfolio = client.portfolios.create(
name="Suppliers EU",
customer_reference="erp-suppliers-eu",
default_permission="view_only",
)
print(portfolio.external_id)const portfolio = await client.portfolios.create({
name: 'Suppliers EU',
customer_reference: 'erp-suppliers-eu',
default_permission: 'view_only',
});
console.log(portfolio.external_id);<?php
$portfolio = $client->portfolios->create(
name: 'Suppliers EU',
customerReference: 'erp-suppliers-eu',
defaultPermission: 'view_only',
);
echo $portfolio->external_id, PHP_EOL;default_permission sets what everyone else in your organisation may do with
it: view_only, write or admin.
Step 2: Register the companies
There is no bulk endpoint — each company is one POST /ext/v3/portfolios/{portfolio_id}/companies
call, which registers the company if we do not know it yet and adds it to the
portfolio. Like every registration it is asynchronous: the response is a
registration, not a company.
Pass the strongest identifier you have. A DUNS number resolves without
ambiguity; primary_name plus country needs matching, and
submit_investigation_when_not_identified decides what happens when that
matching fails — an investigation instead of an error.
Register one company into the portfolio
curl -sS -X POST \
"https://api.businessradar.com/ext/v3/portfolios/$PORTFOLIO_ID/companies" \
-H "Authorization: Bearer $BUSINESSRADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"duns_number": "123456789",
"customer_reference": "erp-4711",
"submit_investigation_when_not_identified": true
}'registration = client.portfolios.companies.create(
portfolio.external_id,
duns_number="123456789",
customer_reference="erp-4711",
submit_investigation_when_not_identified=True,
)const registration = await client.portfolios.companies.create(portfolio.external_id, {
duns_number: '123456789',
customer_reference: 'erp-4711',
submit_investigation_when_not_identified: true,
});<?php
$registration = $client->portfolios->companies->create(
$portfolio->external_id,
dunsNumber: '123456789',
customerReference: 'erp-4711',
submitInvestigationWhenNotIdentified: true,
);Doing that ten thousand times
Registering a large book is a loop with a throttle on it. What the API guarantees, and what that means for the loop:
- Re-posting is safe. A company that is already registered returns its
existing registration instead of creating a second one, so a batch that dies
halfway can simply be re-run. Use your
customer_referenceas the key on your side to recognise rows you have already sent. - 429 is the signal to slow down. Keep concurrency modest — a handful of
requests in flight, not hundreds — and back off when a
429arrives. The official clients already retry429and5xxtwice with exponential back-off; treat a repeated 429 as “reduce concurrency”, not “retry harder”. - Registrations finish later. The POST only queues the work. Collect the
returned
external_idvalues and follow them up in step 3 rather than blocking on each one.
Register a list with a bounded number in flight
import concurrent.futures
import csv
import businessradar
def register(row: dict[str, str]) -> str:
registration = client.portfolios.companies.create(
portfolio.external_id,
duns_number=row["duns"] or None,
primary_name=row["name"] or None,
country=row["country"] or None,
customer_reference=row["reference"],
submit_investigation_when_not_identified=True,
)
return registration.external_id
with open("companies.csv") as handle:
rows = list(csv.DictReader(handle))
# Eight in flight is plenty: the work is queued server-side anyway.
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
for row, future in [(row, pool.submit(register, row)) for row in rows]:
try:
print(row["reference"], future.result())
except businessradar.RateLimitError:
print(row["reference"], "throttled — retry this row later")const rows = await loadCompanies(); // your CSV, database, whatever
const CONCURRENCY = 8;
for (let offset = 0; offset < rows.length; offset += CONCURRENCY) {
const batch = rows.slice(offset, offset + CONCURRENCY);
const results = await Promise.allSettled(
batch.map((row) =>
client.portfolios.companies.create(portfolio.external_id, {
duns_number: row.duns ?? null,
primary_name: row.name ?? null,
country: row.country ?? null,
customer_reference: row.reference,
submit_investigation_when_not_identified: true,
}),
),
);
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
console.error(batch[index].reference, result.reason);
}
}
}<?php
use Businessradar\Core\Exceptions\RateLimitException;
foreach ($rows as $row) {
try {
$registration = $client->portfolios->companies->create(
$portfolio->external_id,
dunsNumber: $row['duns'] ?: null,
primaryName: $row['name'] ?: null,
country: $row['country'] ?: null,
customerReference: $row['reference'],
submitInvestigationWhenNotIdentified: true,
);
echo $row['reference'], ' ', $registration->external_id, PHP_EOL;
} catch (RateLimitException $e) {
sleep(5); // and retry this row on the next pass
}
}Step 3: Get told when they land, and when they change
Subscribe once, and Business Radar posts to your endpoint as registrations complete and as company data changes.
company_registration.status_registered— a registration reachedregistered. Payload field:registration.company_registration.status_changed— every intermediate status, if you want the progress detail.company.updated— a tracked company’s attributes changed (a D&B refresh, for example). Payload fields:companyandattribute_changes. This event is portfolio-scoped: passportfoliowhen subscribing and it fires only for companies in that portfolio.
Subscribe for a portfolio
curl -sS -X POST https://api.businessradar.com/ext/v3/webhooks/ \
-H "Authorization: Bearer $BUSINESSRADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/businessradar",
"enabled": true,
"subscriptions": [
{ "event_type": "company_registration.status_registered" },
{ "event_type": "company.updated", "portfolio": "'"$PORTFOLIO_ID"'" }
]
}'webhook = client.webhooks.create(
url="https://example.com/hooks/businessradar",
enabled=True,
subscriptions=[
{"event_type": "company_registration.status_registered"},
{"event_type": "company.updated", "portfolio": portfolio.external_id},
],
)
# The secret is shown once, here. Store it before you do anything else.const webhook = await client.webhooks.create({
url: 'https://example.com/hooks/businessradar',
enabled: true,
subscriptions: [
{ event_type: 'company_registration.status_registered' },
{ event_type: 'company.updated', portfolio: portfolio.external_id },
],
});<?php
$webhook = $client->webhooks->create(
subscriptions: [
['eventType' => 'company_registration.status_registered'],
['eventType' => 'company.updated', 'portfolio' => $portfolio->external_id],
],
url: 'https://example.com/hooks/businessradar',
enabled: true,
);Verify each delivery with the X-Signature-SHA256 header before acting on it —
HMAC-SHA256 over the raw body, keyed with the webhook secret. Rotating the
secret through regenerate_secret invalidates the old one immediately, so
deploy the new value first. You can fire a test delivery at your endpoint with
POST /ext/v3/webhooks/{webhook_external_id}/deliveries/test/ and inspect what
was actually sent through GET /ext/v3/webhooks/{webhook_external_id}/deliveries/.
News is pulled, not pushed. There is no article webhook event: the event types cover compliance checks, registrations and company attribute changes. For news, poll the articles endpoint on a schedule as in step 4 — every few minutes is plenty.
Step 4: Pull the portfolio’s news
GET /ext/v3/articles filters by portfolio_id, so one query covers every
company in the portfolio. The filters that matter for a risk feed:
| Parameter | Use |
|---|---|
portfolio_id |
One or more portfolio UUIDs. |
is_material |
true keeps only articles flagged as relevant to business risk. |
category |
Category UUIDs — the risk categories you care about. |
min_creation_date |
Articles added to our database since your last poll. |
min_publication_date |
Articles published since a date, regardless of when we found them. |
sorting |
priority surfaces the most important articles across the whole result set; publication_datetime is plain reverse-chronological. |
saved_article_filter_id |
Apply a filter set saved in the app, instead of rebuilding it here. |
next_key |
The cursor from the previous page. |
Material news for the portfolio since the last poll
curl -sS -G https://api.businessradar.com/ext/v3/articles \
-H "Authorization: Bearer $BUSINESSRADAR_API_KEY" \
--data-urlencode "portfolio_id=$PORTFOLIO_ID" \
--data-urlencode "is_material=true" \
--data-urlencode "min_creation_date=2026-09-17T08:00:00Z" \
--data-urlencode "sorting=priority" \
--data-urlencode "sorting_order=desc"page = client.news.articles.list(
portfolio_id=[portfolio.external_id],
is_material=True,
min_creation_date="2026-09-17T08:00:00Z",
sorting="priority",
sorting_order="desc",
)
for article in page.results:
print(article.publication_datetime, article.title, article.url)const page = await client.news.articles.list({
portfolio_id: [portfolio.external_id],
is_material: true,
min_creation_date: '2026-09-17T08:00:00Z',
sorting: 'priority',
sorting_order: 'desc',
});
for (const article of page.results) {
console.log(article.publication_datetime, article.title, article.url);
}<?php
$page = $client->news->articles->list(
portfolioID: [$portfolio->external_id],
isMaterial: true,
minCreationDate: new DateTimeImmutable('2026-09-17T08:00:00Z'),
sorting: 'priority',
sortingOrder: 'desc',
);
foreach ($page->getItems() as $article) {
echo $article->title, PHP_EOL;
}Page through, then remember where you stopped
Pass the next_key from each response back until it comes back null. For the
next poll, use min_creation_date — the time an article entered our database,
which is what makes incremental polling reliable even for articles published
days earlier.
Walk every page
curl -sS -G https://api.businessradar.com/ext/v3/articles \
-H "Authorization: Bearer $BUSINESSRADAR_API_KEY" \
--data-urlencode "portfolio_id=$PORTFOLIO_ID" \
--data-urlencode "next_key=eyJvZmZzZXQiOjUwfQ"seen = 0
for article in client.news.articles.list(
portfolio_id=[portfolio.external_id],
min_creation_date=last_polled_at,
):
seen += 1 # pages are fetched as the iterator needs them
print(seen)for await (const article of client.news.articles.list({
portfolio_id: [portfolio.external_id],
min_creation_date: lastPolledAt,
})) {
await store(article);
}<?php
$page = $client->news->articles->list(portfolioID: [$portfolio->external_id]);
foreach ($page->pagingEachItem() as $article) {
// fetches further pages as needed
store($article);
}What you get
{
"total_results": 128,
"next_key": "eyJvZmZzZXQiOjUwfQ",
"results": [
{
"external_id": "9b2c1d0e-7a6f-4c3b-8d1e-2f3a4b5c6d7e",
"title": "Toezichthouder start onderzoek naar Acme Industries",
"title_en": "Regulator opens investigation into Acme Industries",
"snippet": "De toezichthouder bevestigde maandag dat …",
"snippet_en": "The regulator confirmed on Monday that …",
"url": "https://example.com/news/acme-investigation",
"image_url": "https://example.com/media/acme.jpg",
"language": "nl",
"country": "NL",
"sentiment": -0.62,
"publication_datetime": "2026-09-17T06:41:00Z",
"created_at": "2026-09-17T07:02:11Z",
"is_clustered": false,
"source": {
"name": "Example Krant",
"domain": "example.com",
"url": "https://example.com"
},
"categories": [
{
"external_id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
"name": "Regulatory",
"priority": 1,
"is_material": true,
"sub_categories": []
}
],
"company_articles": [
{
"company": {
"external_id": "a7b1f2c4-1e3d-4f5a-8b9c-0d1e2f3a4b5c",
"name": "Acme Industries B.V.",
"country": "NL",
"duns_number": "123456789",
"customer_reference": "erp-4711"
},
"categories": [],
"sentiment": -0.62
}
],
"sub_articles": []
}
]
}
company_articles[].company.customer_reference carries your own identifier
back, so an article maps onto the row in your system without a second lookup.
Narrowing to the right risk categories
Categories come back on every article as a tree, each node with an
external_id, a priority and an is_material flag. Collect the ids of the
categories your team acts on — from a first unfiltered poll, or from the
filters saved in the app — and pass them as category on subsequent calls.
saved_article_filter_id is the shortcut: build the filter once in the app,
list the saved filters with GET /ext/v3/saved_article_filters, and apply it by
id.
The whole thing
A portfolio created, filled from a CSV with a bounded number of registrations
in flight, and then polled for material news since the last run. Store
polled_at wherever your job keeps state; on the next run it becomes
min_creation_date.
Complete portfolio run
#!/usr/bin/env bash
set -euo pipefail
: "${BUSINESSRADAR_API_KEY:?export your API key first}"
BASE=https://api.businessradar.com/ext/v3
AUTH="Authorization: Bearer $BUSINESSRADAR_API_KEY"
STATE=.last_polled_at
portfolio=$(curl -fsS -X POST "$BASE/portfolios" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name": "Suppliers EU", "customer_reference": "erp-suppliers-eu"}' \
| jq -r '.external_id')
# companies.csv: duns,name,country,reference
tail -n +2 companies.csv | while IFS=, read -r duns name country reference; do
curl -fsS -X POST "$BASE/portfolios/$portfolio/companies" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n \
--arg duns "$duns" --arg name "$name" \
--arg country "$country" --arg reference "$reference" \
'{duns_number: ($duns | select(. != "")),
primary_name: ($name | select(. != "")),
country: ($country | select(. != "")),
customer_reference: $reference,
submit_investigation_when_not_identified: true}')" >/dev/null
done
since=$(cat "$STATE" 2>/dev/null || date -u -v-1d +%Y-%m-%dT%H:%M:%SZ)
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
next_key=""
while :; do
response=$(curl -fsS -G "$BASE/articles" -H "$AUTH" \
--data-urlencode "portfolio_id=$portfolio" \
--data-urlencode "is_material=true" \
--data-urlencode "min_creation_date=$since" \
${next_key:+--data-urlencode "next_key=$next_key"})
echo "$response" | jq -r '.results[] | "\(.publication_datetime)\t\(.title)"'
next_key=$(echo "$response" | jq -r '.next_key // empty')
[ -z "$next_key" ] && break
done
echo "$now" > "$STATE"import concurrent.futures
import csv
import datetime
import os
import pathlib
import businessradar
from businessradar import BusinessRadar
client = BusinessRadar(api_key=os.environ["BUSINESSRADAR_API_KEY"])
state = pathlib.Path(".last_polled_at")
portfolio = client.portfolios.create(
name="Suppliers EU",
customer_reference="erp-suppliers-eu",
)
def register(row: dict[str, str]) -> str:
registration = client.portfolios.companies.create(
portfolio.external_id,
duns_number=row["duns"] or None,
primary_name=row["name"] or None,
country=row["country"] or None,
customer_reference=row["reference"],
submit_investigation_when_not_identified=True,
)
return registration.external_id
with open("companies.csv") as handle:
rows = list(csv.DictReader(handle))
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
for row, future in [(row, pool.submit(register, row)) for row in rows]:
try:
future.result()
except businessradar.RateLimitError:
print(row["reference"], "throttled — retry this row later")
now = datetime.datetime.now(datetime.timezone.utc)
since = (
state.read_text().strip()
if state.exists()
else (now - datetime.timedelta(days=1)).isoformat()
)
for article in client.news.articles.list(
portfolio_id=[portfolio.external_id],
is_material=True,
min_creation_date=since,
):
print(article.publication_datetime, article.title)
state.write_text(now.isoformat())import fs from 'node:fs/promises';
import BusinessRadar from '@businessradar/businessradar';
const client = new BusinessRadar({ apiKey: process.env['BUSINESSRADAR_API_KEY'] });
const STATE = '.last_polled_at';
const CONCURRENCY = 8;
const portfolio = await client.portfolios.create({
name: 'Suppliers EU',
customer_reference: 'erp-suppliers-eu',
});
const rows = await loadCompanies(); // your CSV, database, whatever
for (let offset = 0; offset < rows.length; offset += CONCURRENCY) {
const batch = rows.slice(offset, offset + CONCURRENCY);
const results = await Promise.allSettled(
batch.map((row) =>
client.portfolios.companies.create(portfolio.external_id, {
duns_number: row.duns ?? null,
primary_name: row.name ?? null,
country: row.country ?? null,
customer_reference: row.reference,
submit_investigation_when_not_identified: true,
}),
),
);
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
console.error(batch[index].reference, result.reason);
}
}
}
const now = new Date().toISOString();
const since = await fs
.readFile(STATE, 'utf8')
.then((value) => value.trim())
.catch(() => new Date(Date.now() - 86_400_000).toISOString());
for await (const article of client.news.articles.list({
portfolio_id: [portfolio.external_id],
is_material: true,
min_creation_date: since,
})) {
console.log(article.publication_datetime, article.title);
}
await fs.writeFile(STATE, now);<?php
require 'vendor/autoload.php';
use Businessradar\Client;
use Businessradar\Core\Exceptions\RateLimitException;
$client = new Client(apiKey: getenv('BUSINESSRADAR_API_KEY'));
$state = '.last_polled_at';
$portfolio = $client->portfolios->create(
name: 'Suppliers EU',
customerReference: 'erp-suppliers-eu',
);
$handle = fopen('companies.csv', 'r');
$columns = fgetcsv($handle);
while ($line = fgetcsv($handle)) {
$row = array_combine($columns, $line);
try {
$client->portfolios->companies->create(
$portfolio->external_id,
dunsNumber: $row['duns'] ?: null,
primaryName: $row['name'] ?: null,
country: $row['country'] ?: null,
customerReference: $row['reference'],
submitInvestigationWhenNotIdentified: true,
);
} catch (RateLimitException $e) {
sleep(5); // and retry this row on the next pass
}
}
fclose($handle);
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$since = file_exists($state)
? new DateTimeImmutable(trim(file_get_contents($state)))
: $now->modify('-1 day');
$page = $client->news->articles->list(
isMaterial: true,
minCreationDate: $since,
portfolioID: [$portfolio->external_id],
);
foreach ($page->pagingEachItem() as $article) {
echo $article->publication_datetime, ' ', $article->title, PHP_EOL;
}
file_put_contents($state, $now->format(DATE_ATOM));Next steps
- Run a compliance check on a company for screening rather than news.
- Reference: Portfolios, Articles, Webhooks, Companies.