Skip to content
Business RadarLog in

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

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

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.

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

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.

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

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

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"

Next steps