# Register a portfolio with thousands of companies and receive the right news

Source: https://help.businessradar.com/api/guides/monitor-a-portfolio-for-news
Updated: 2026-09-17

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](/api/guides/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

**curl**

```bash
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"
  }'
```

**python**

```python
portfolio = client.portfolios.create(
    name="Suppliers EU",
    customer_reference="erp-suppliers-eu",
    default_permission="view_only",
)
print(portfolio.external_id)
```

**typescript**

```ts
const portfolio = await client.portfolios.create({
  name: 'Suppliers EU',
  customer_reference: 'erp-suppliers-eu',
  default_permission: 'view_only',
});
console.log(portfolio.external_id);
```

**php**

```php
<?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.

**curl**

```bash
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
  }'
```

**python**

```python
registration = client.portfolios.companies.create(
    portfolio.external_id,
    duns_number="123456789",
    customer_reference="erp-4711",
    submit_investigation_when_not_identified=True,
)
```

**typescript**

```ts
const registration = await client.portfolios.companies.create(portfolio.external_id, {
  duns_number: '123456789',
  customer_reference: 'erp-4711',
  submit_investigation_when_not_identified: true,
});
```

**php**

```php
<?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_reference` as 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 `429` arrives. The
  official clients already retry `429` and `5xx` twice 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_id` values and follow them up in step 3 rather than
  blocking on each one.

**python**

```python

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")
```

**typescript**

```ts
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**

```php
<?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 reached
  `registered`. 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: `company` and `attribute_changes`. This event is
  **portfolio-scoped**: pass `portfolio` when subscribing and it fires only for
  companies in that portfolio.

**curl**

```bash
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"'" }
    ]
  }'
```

**python**

```python
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.
```

**typescript**

```ts
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**

```php
<?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/`.

<p class="callout"><span class="callout-title">News is pulled, not pushed.</span>
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.</p>

## 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. |

**curl**

```bash
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"
```

**python**

```python
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)
```

**typescript**

```ts
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**

```php
<?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.

**curl**

```bash
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"
```

**python**

```python
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)
```

**typescript**

```ts
for await (const article of client.news.articles.list({
  portfolio_id: [portfolio.external_id],
  min_creation_date: lastPolledAt,
})) {
  await store(article);
}
```

**php**

```php
<?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

```json
{
  "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`.

**curl**

```bash
#!/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"
```

**python**

```python

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())
```

**typescript**

```ts

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**

```php
<?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](/api/guides/run-a-compliance-check) for
  screening rather than news.
- Reference: [Portfolios](/api#tag/portfolios), [Articles](/api#tag/articles),
  [Webhooks](/api#tag/webhooks), [Companies](/api#tag/companies).
